From 7723e93f1997069886325ecdd5063ac6cf8ca238 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Fri, 1 Dec 2023 07:33:25 -0500 Subject: [PATCH 01/10] Converted CoinbaseAPI to V3 --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 1770 ++++++----------- .../ExchangeKrakenAPITests.cs | 2 +- 2 files changed, 617 insertions(+), 1155 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index bc9a7e6d..782045b4 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -10,1171 +10,633 @@ 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. */ +using ExchangeSharp.Coinbase; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; + namespace ExchangeSharp { - using System; - using System.Collections.Generic; - using System.Diagnostics; - using System.Linq; - using System.Net; - using System.Threading.Tasks; - using ExchangeSharp.Coinbase; - using Newtonsoft.Json; - using Newtonsoft.Json.Linq; - - public sealed partial class ExchangeCoinbaseAPI : ExchangeAPI + /// + /// Warning: This API now uses Coinbase Advanced Trade V2/V3. + /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site. + /// These keys must be set before using the Coinbase API (sorry). + /// + public sealed class ExchangeCoinbaseAPI : ExchangeAPI + { + private const string ADVFILL = "advanced_trade_fill"; + private const string CURRENCY = "currency"; + private const string PRODUCTID = "product_id"; + private const string PRODUCTS = "products"; + private const string PRICEBOOK = "pricebook"; + private const string PRICEBOOKS = "pricebooks"; + private const string ASKS = "asks"; + private const string BIDS = "bids"; + private const string PRICE = "price"; + private const string AMOUNT = "amount"; + private const string VALUE = "value"; + private const string SIZE = "size"; + private const string CURSOR = "cursor"; + + + public override string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage"; + private readonly string BaseURLV2 = "https://api.coinbase.com/v2"; // For Wallet Support + public override string BaseUrlWebSocket { get; set; } = "wss://advanced-trade-ws.coinbase.com"; + + private enum PaginationType { None, V2, V3, V3Cursor} + private PaginationType pagination = PaginationType.None; + private string cursorNext; + + private Dictionary Accounts = null; // Cached Account IDs + + private ExchangeCoinbaseAPI() + { + MarketSymbolIsReversed = false; + RequestContentType = "application/json"; + NonceStyle = NonceStyle.None; + WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas; + RateLimit = new RateGate(10, TimeSpan.FromSeconds(1)); + base.RequestMaker.RequestStateChanged = ProcessResponse; + } + + /// + /// This is used to capture Pagination instead of overriding the ProcessResponse + /// because the Pagination info is no longer in the Headers and ProcessResponse does not return the required Content + /// + /// + /// + /// + private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) + { + // We can bypass serialization if we already know the last call isn't paginated + if (state == RequestMakerState.Finished && pagination != PaginationType.None) + { + var token = JsonConvert.DeserializeObject((string)response); + if (pagination == PaginationType.V3) + { + if (token != null && token["has_next"] != null) cursorNext = token["has_next"].Equals("True") ? token[CURSOR].ToStringInvariant() : null; + else cursorNext = null; + } + else if (pagination == PaginationType.V3Cursor) // Only used for V3 fills - go figure. + { + if (token != null && token[CURSOR] != null) cursorNext = token[CURSOR].ToStringInvariant(); + else cursorNext = null; + } + else + { + if (token["pagination"] != null) cursorNext = token["pagination"]["next_starting_after"]?.ToStringInvariant(); + else cursorNext = null; + } + if (cursorNext == string.Empty) cursorNext = null; + } + } + + #region BaseOverrides + + /// + /// Overridden because we no longer need a nonce in the payload and passphrase is no longer used + /// + /// + /// + protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) + { + return (PrivateApiKey != null && PublicApiKey != null); + } + + /// + /// Sometimes the Fiat pairs are reported backwards, but Coinbase requires the fiat to be last of the pair + /// Only three Fiat Currencies are supported + /// + /// + /// + public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) + { + if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) + { + var split = marketSymbol.Split(GlobalMarketSymbolSeparator); + return Task.FromResult(split[1] + GlobalMarketSymbolSeparator + split[0]); + } + else return Task.FromResult(marketSymbol); + } + + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { + if (CanMakeAuthenticatedRequest(payload)) + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); // If you're skittish about the local clock, you may retrieve the timestamp from the Coinbase Site + string body = CryptoUtility.GetJsonForPayload(payload); + + // V2 wants PathAndQuery, V3 wants LocalPath for the sig + string path = request.RequestUri.AbsoluteUri.StartsWith(BaseURLV2) ? request.RequestUri.PathAndQuery : request.RequestUri.LocalPath; + string signature = CryptoUtility.SHA256Sign(timestamp + request.Method.ToUpperInvariant() + path + body, PrivateApiKey.ToUnsecureString()); + + request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString()); + request.AddHeader("CB-ACCESS-SIGN", signature); + request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp); + if (request.Method == "POST") await CryptoUtility.WriteToRequestAsync(request, body); + } + } + + #endregion + + #region GeneralProductEndpoints + + protected internal override async Task> OnGetMarketSymbolsMetadataAsync() + { + var markets = new List(); + JToken products = await MakeJsonRequestAsync("/products"); + foreach (JToken product in products[PRODUCTS]) + { + markets.Add(new ExchangeMarket + { + MarketSymbol = product[PRODUCTID].ToStringUpperInvariant(), + BaseCurrency = product["base_currency_id"].ToStringUpperInvariant(), + QuoteCurrency = product["quote_currency_id"].ToStringUpperInvariant(), + IsActive = string.Equals(product["status"].ToStringInvariant(), "online", StringComparison.OrdinalIgnoreCase), + MinTradeSize = product["base_min_size"].ConvertInvariant(), + MaxTradeSize = product["base_max_size"].ConvertInvariant(), + PriceStepSize = product["quote_increment"].ConvertInvariant() + }); + } + return markets.OrderBy(market => market.MarketSymbol); // Ordered for Convenience + } + + protected override async Task> OnGetMarketSymbolsAsync() + { + return (await GetMarketSymbolsMetadataAsync()).Select(market => market.MarketSymbol); + } + + protected override async Task> OnGetCurrenciesAsync() + { + var currencies = new Dictionary(); + + // We don't have a currencies endpoint, but we can derive the currencies by splitting the products (includes fiat - filter if you wish) + JToken products = await MakeJsonRequestAsync("/products"); + foreach (JToken product in products[PRODUCTS]) + { + var split = product[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + if (!currencies.ContainsKey(split[0])) + { + var currency = new ExchangeCurrency + { + Name = split[0], + FullName = product["base_name"].ToStringInvariant(), + DepositEnabled = true, + WithdrawalEnabled = true + }; + currencies[currency.Name] = currency; + } + if (!currencies.ContainsKey(split[1])) + { + var currency = new ExchangeCurrency + { + Name = split[1], + FullName = product["quote_name"].ToStringInvariant(), + DepositEnabled = true, + WithdrawalEnabled = true + }; + currencies[currency.Name] = currency; + } + } + return currencies; + } + + protected override async Task>> OnGetTickersAsync() + { + var tickers = new List>(); + JToken books = await MakeJsonRequestAsync("/best_bid_ask"); + var Timestamp = CryptoUtility.ParseTimestamp(books["time"], TimestampType.Iso8601UTC); + foreach (JToken book in books[PRICEBOOKS]) + { + var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + // This endpoint does not provide a last or open for the ExchangeTicker + tickers.Add(new KeyValuePair(book[PRODUCTID].ToString(), new ExchangeTicker() + { + MarketSymbol = book[PRODUCTID].ToString(), + Ask = book[ASKS][0][PRICE].ConvertInvariant(), + Bid = book[BIDS][0][PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), + QuoteCurrency = split[1], + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + Timestamp = Timestamp + } + })); + } + return tickers; + } + + protected override async Task OnGetTickerAsync(string marketSymbol) + { + JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol.ToUpperInvariant()); + JToken book = ticker[PRICEBOOKS][0]; + var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + return new ExchangeTicker() + { + MarketSymbol = book[PRODUCTID].ToString(), + Ask = book[ASKS][0][PRICE].ConvertInvariant(), + Bid = book[BIDS][0][PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), + QuoteCurrency = split[1], + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + Timestamp = DateTime.UtcNow + } + }; + } + + protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 50) + { + JToken token = await MakeJsonRequestAsync("/product_book?product_id=" + marketSymbol.ToUpperInvariant() + "&limit=" + maxCount); + ExchangeOrderBook orderBook = new ExchangeOrderBook(); + foreach(JToken bid in token[PRICEBOOK][BIDS]) orderBook.Bids.Add(bid[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = bid[PRICE].ConvertInvariant(), Amount = bid[SIZE].ConvertInvariant() }); + foreach(JToken ask in token[PRICEBOOK][ASKS]) orderBook.Asks.Add(ask[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = ask[PRICE].ConvertInvariant(), Amount = ask[SIZE].ConvertInvariant() }); + return orderBook; + } + + protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = 100) + { + // Limit is required but maxed at 100 with no pagination available + limit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; + JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol.ToUpperInvariant() + "/ticker?limit=" + limit); + List tradeList = new List(); + foreach (JToken trade in trades["trades"]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, "side", "time", TimestampType.Iso8601UTC, "trade_id")); + return tradeList; + } + + protected override async Task OnGetHistoricalTradesAsync(Func, bool> callback, string marketSymbol, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) + { + // There is no Historical Trades endpoint. The best we can do is get the last 100 trades and filter. + // Check for this data on the sockets? + var trades = await OnGetRecentTradesAsync(marketSymbol.ToUpperInvariant()); + + if (startDate != null) trades = trades.Where(t => t.Timestamp >= startDate); + if (endDate != null) trades = trades.Where(t => t.Timestamp <= endDate);; + if (limit != null) trades = trades.Take((int)limit); + + callback(trades); + } + + protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) + { + if (endDate == null) endDate = CryptoUtility.UtcNow; + + string granularity = "UNKNOWN_GRANULARITY"; + if (periodSeconds <= 60) { granularity = "ONE_MINUTE"; periodSeconds = 60; } + else if (periodSeconds <= 300) { granularity = "FIVE_MINUTE"; periodSeconds = 300; } + else if (periodSeconds <= 900) { granularity = "FIFTEEN_MINUTE"; periodSeconds = 900; } + else if (periodSeconds <= 1800) { granularity = "THIRTY_MINUTE"; periodSeconds = 1800; } + else if (periodSeconds <= 3600) { granularity = "ONE_HOUR"; periodSeconds = 3600; } + else if (periodSeconds <= 21600) { granularity = "SIX_HOUR"; periodSeconds = 21600; } + else { granularity = "ONE_DAY"; periodSeconds = 86400; } + + // Returned Candle count is restricted to 300 - and they don't paginate this call + // We're going to keep retrieving candles 300 at a time until we get our date range for the granularity + if (startDate == null) startDate = CryptoUtility.UtcNow.AddMinutes(-(periodSeconds * 300)); + if (startDate >= endDate) throw new APIException("Invalid Date Range"); + DateTime RangeStart = (DateTime)startDate, RangeEnd = (DateTime)endDate; + if ((RangeEnd - RangeStart).TotalSeconds / periodSeconds > 300) RangeStart = RangeEnd.AddSeconds(-(periodSeconds * 300)); + + List candles = new List(); + while (true) + { + JToken token = await MakeJsonRequestAsync(string.Format("/products/{0}/candles?start={1}&end={2}&granularity={3}", marketSymbol.ToUpperInvariant(), ((DateTimeOffset)RangeStart).ToUnixTimeSeconds(), ((DateTimeOffset)RangeEnd).ToUnixTimeSeconds(), granularity)); + foreach (JToken candle in token["candles"]) candles.Add(this.ParseCandle(candle, marketSymbol, periodSeconds, "open", "high", "low", "close", "start", TimestampType.UnixSeconds, "volume")); + if (RangeStart > startDate) + { + // For simplicity, we'll go back 300 each iteration and sort/filter date range before return + RangeStart = RangeStart.AddSeconds(-(periodSeconds * 300)); + RangeEnd = RangeEnd.AddSeconds(-(periodSeconds * 300)); + } + else break; + } + return candles.Where(c => c.Timestamp >= startDate).OrderBy(c => c.Timestamp); + } + + + protected override async Task> OnGetFeesAsync() + { + var symbols = await OnGetMarketSymbolsAsync(); + JToken token = await this.MakeJsonRequestAsync("/transaction_summary"); + Dictionary fees = new Dictionary(); + + // We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per symbol. + // Here, we choose taker fee, which is usually higher + decimal makerRate = token["fee_tier"]["taker_fee_rate"].Value(); //percentage between 0 and 1 + + return symbols.Select(symbol => new KeyValuePair(symbol, makerRate)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + + #endregion + + #region AccountSpecificEndpoints + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task OnGetDepositAddressAsync(string symbol, bool forceRegenerate = false) + { + if (Accounts == null) await GetAmounts(true); // Populate Accounts Cache + if (Accounts.ContainsKey(symbol)) + { + JToken accountWalletAddress = await this.MakeJsonRequestAsync($"/accounts/{Accounts[symbol]}/addresses", BaseURLV2); + return new ExchangeDepositDetails { Address = accountWalletAddress[0]["address"].ToStringInvariant(), Currency = symbol }; // We only support a single Wallet/Address (Coinbase is the only Exchange that has multiple) + } + throw new APIException($"Address not found for {symbol}"); + } + + protected override async Task> OnGetAmountsAsync() + { + return await GetAmounts(false); + } + + protected override async Task> OnGetAmountsAvailableToTradeAsync() + { + return await GetAmounts(true); + } + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task> OnGetWithdrawHistoryAsync(string currency) + { + return await GetTx(true, currency); + } + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task> OnGetDepositHistoryAsync(string currency) + { + return await GetTx(false, currency); + } + + /// + /// WARNING: Only Advanced Trade Open Orders are supported. + /// + /// + /// + protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) + { + List orders = new List(); + // Max return count is 1000 with no pagination available + JToken array = await MakeJsonRequestAsync("/orders/historical/batch?order_status=OPEN" + marketSymbol == null || marketSymbol == string.Empty ? string.Empty : "&product_id=" + marketSymbol ); + foreach (JToken order in array) if (order["type"].ToStringInvariant().Equals(ADVFILL)) orders.Add(ParseOrder(order)); + return orders; + } + + /// + /// WARNING: Only Advanced Trade Completed Orders are supported. + /// + /// + /// + /// + protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) + { + // Legacy Orders may be retrieved using V2 (not implemented here - see GetTx in code below) + List orders = new List(); + pagination = PaginationType.V3Cursor; + string startURL = "/orders/historical/fills"; + + if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol; + if (afterDate != null) startURL += marketSymbol == null ? "?" : "&" + "start_sequence_timestamp=" + ((DateTimeOffset)afterDate).ToUnixTimeSeconds(); + JToken token = await MakeJsonRequestAsync(startURL); + startURL += marketSymbol == null && afterDate == null ? "?" : "&" + "cursor="; + while(true) + { + foreach (JToken fill in token["fills"]) + { + orders.Add(new ExchangeOrderResult() + { + MarketSymbol = fill[PRODUCTID].ToStringInvariant(), + TradeId = fill["trade_id"].ToStringInvariant(), + OrderId = fill["order_id"].ToStringInvariant(), + OrderDate = fill["trade_time"].ToDateTimeInvariant(), + IsBuy = fill["side"].ToStringInvariant() == "buy", + Amount = fill[SIZE].ConvertInvariant(), + AmountFilled = fill[SIZE].ConvertInvariant(), + Price = fill[PRICE].ConvertInvariant(), + Fees = fill["commission"].ConvertInvariant(), + AveragePrice = fill[PRICE].ConvertInvariant() + }); + } + if (string.IsNullOrEmpty(cursorNext)) break; + token = await MakeJsonRequestAsync(startURL + cursorNext); + } + pagination = PaginationType.None; + return orders; + } + + protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) { - public override string BaseUrl { get; set; } = "https://api.pro.coinbase.com"; - public override string BaseUrlWebSocket { get; set; } = "wss://ws-feed.pro.coinbase.com"; - - /// - /// The response will also contain a CB-AFTER header which will return the cursor id to use in your next request for the page after this one. The page after is an older page and not one that happened after this one in chronological time. - /// - private string cursorAfter; - - /// - /// The response will contain a CB-BEFORE header which will return the cursor id to use in your next request for the page before the current one. The page before is a newer page and not one that happened before in chronological time. - /// - private string cursorBefore; - - private ExchangeCoinbaseAPI() - { - RequestContentType = "application/json"; - NonceStyle = NonceStyle.UnixSeconds; - NonceEndPoint = "/time"; - NonceEndPointField = "iso"; - NonceEndPointStyle = NonceStyle.Iso8601; - WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas; - /* Rate limits from Coinbase Pro webpage - * Public endpoints - We throttle public endpoints by IP: 10 requests per second, up to 15 requests per second in bursts. Some endpoints may have custom rate limits. - * Private endpoints - We throttle private endpoints by profile ID: 15 requests per second, up to 30 requests per second in bursts. Some endpoints may have custom rate limits. - * fills endpoint has a custom rate limit of 10 requests per second, up to 20 requests per second in bursts. */ - RateLimit = new RateGate(9, TimeSpan.FromSeconds(1)); // set to 9 to be safe - } - - private ExchangeOrderResult ParseFill(JToken result) - { - decimal amount = result["size"].ConvertInvariant(); - decimal price = result["price"].ConvertInvariant(); - string symbol = result["product_id"].ToStringInvariant(); - - decimal fees = result["fee"].ConvertInvariant(); - - ExchangeOrderResult order = new ExchangeOrderResult - { - TradeId = result["trade_id"].ToStringInvariant(), - Amount = amount, - AmountFilled = amount, - Price = price, - Fees = fees, - AveragePrice = price, - IsBuy = (result["side"].ToStringInvariant() == "buy"), - // OrderDate - not provided here. ideally would be null but ExchangeOrderResult.OrderDate is not nullable - CompletedDate = null, // order not necessarily fully filled at this point - TradeDate = result["created_at"].ToDateTimeInvariant(), // even though it is named "created_at", the documentation says that it is the: timestamp of fill - MarketSymbol = symbol, - OrderId = result["order_id"].ToStringInvariant(), - }; - - return order; - } - - private ExchangeOrderResult ParseOrder(JToken result) - { - decimal executedValue = result["executed_value"].ConvertInvariant(); - decimal amountFilled = result["filled_size"].ConvertInvariant(); - decimal amount = result["size"].ConvertInvariant(amountFilled); - decimal price = result["price"].ConvertInvariant(); - decimal stop_price = result["stop_price"].ConvertInvariant(); - decimal? averagePrice = ( - amountFilled <= 0m ? null : (decimal?)(executedValue / amountFilled) - ); - decimal fees = result["fill_fees"].ConvertInvariant(); - string marketSymbol = result["product_id"].ToStringInvariant( - result["id"].ToStringInvariant() - ); - - ExchangeOrderResult order = new ExchangeOrderResult - { - Amount = amount, - AmountFilled = amountFilled, - Price = price <= 0m ? stop_price : price, - Fees = fees, - FeesCurrency = marketSymbol.Substring(0, marketSymbol.IndexOf('-')), - AveragePrice = averagePrice, - IsBuy = (result["side"].ToStringInvariant() == "buy"), - OrderDate = result["created_at"].ToDateTimeInvariant(), - CompletedDate = result["done_at"].ToDateTimeInvariant(), - MarketSymbol = marketSymbol, - OrderId = result["id"].ToStringInvariant() - }; - switch (result["status"].ToStringInvariant()) - { - case "pending": - order.Result = ExchangeAPIOrderResult.PendingOpen; - break; - case "active": - case "open": - if (order.Amount == order.AmountFilled) - { - order.Result = ExchangeAPIOrderResult.Filled; - } - else if (order.AmountFilled > 0.0m) - { - order.Result = ExchangeAPIOrderResult.FilledPartially; - } - else - { - order.Result = ExchangeAPIOrderResult.Open; - } - break; - case "done": - case "settled": - switch (result["done_reason"].ToStringInvariant()) - { - case "cancelled": - case "canceled": - order.Result = ExchangeAPIOrderResult.Canceled; - break; - case "filled": - order.Result = ExchangeAPIOrderResult.Filled; - break; - default: - order.Result = ExchangeAPIOrderResult.Unknown; - break; - } - break; - case "rejected": - order.Result = ExchangeAPIOrderResult.Rejected; - break; - case "cancelled": - case "canceled": - order.Result = ExchangeAPIOrderResult.Canceled; - break; - default: - throw new NotImplementedException( - $"Unexpected status type: {result["status"].ToStringInvariant()}" - ); - } - return order; - } - - protected override bool CanMakeAuthenticatedRequest( - IReadOnlyDictionary payload - ) - { - return base.CanMakeAuthenticatedRequest(payload) && Passphrase != null; - } - - protected override async Task ProcessRequestAsync( - IHttpWebRequest request, - Dictionary payload - ) - { - if (CanMakeAuthenticatedRequest(payload)) - { - // Coinbase is funny and wants a seconds double for the nonce, weird... we convert it to double and back to string invariantly to ensure decimal dot is used and not comma - string timestamp = payload["nonce"].ToStringInvariant(); - payload.Remove("nonce"); - string form = CryptoUtility.GetJsonForPayload(payload); - byte[] secret = CryptoUtility.ToBytesBase64Decode(PrivateApiKey); - string toHash = - timestamp - + request.Method.ToUpperInvariant() - + request.RequestUri.PathAndQuery - + form; - string signatureBase64String = CryptoUtility.SHA256SignBase64(toHash, secret); - secret = null; - toHash = null; - request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString()); - request.AddHeader("CB-ACCESS-SIGN", signatureBase64String); - request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp); - request.AddHeader( - "CB-ACCESS-PASSPHRASE", - CryptoUtility.ToUnsecureString(Passphrase) - ); - if (request.Method == "POST") - { - await CryptoUtility.WriteToRequestAsync(request, form); - } - } - } - - protected override void ProcessResponse(IHttpWebResponse response) - { - base.ProcessResponse(response); - cursorAfter = response.GetHeader("CB-AFTER").FirstOrDefault(); - cursorBefore = response.GetHeader("CB-BEFORE").FirstOrDefault(); - } - - protected internal override async Task< - IEnumerable - > OnGetMarketSymbolsMetadataAsync() - { - var markets = new List(); - JToken products = await MakeJsonRequestAsync("/products"); - foreach (JToken product in products) - { - var market = new ExchangeMarket - { - MarketSymbol = product["id"].ToStringUpperInvariant(), - QuoteCurrency = product["quote_currency"].ToStringUpperInvariant(), - BaseCurrency = product["base_currency"].ToStringUpperInvariant(), - IsActive = string.Equals( - product["status"].ToStringInvariant(), - "online", - StringComparison.OrdinalIgnoreCase - ), - MinTradeSize = product["base_min_size"].ConvertInvariant(), - MaxTradeSize = product["base_max_size"].ConvertInvariant(), - PriceStepSize = product["quote_increment"].ConvertInvariant(), - QuantityStepSize = product["base_increment"].ConvertInvariant(), - }; - markets.Add(market); - } - - return markets; - } - - protected override async Task> OnGetMarketSymbolsAsync() - { - return (await GetMarketSymbolsMetadataAsync()) - .Where(market => market.IsActive ?? true) - .Select(market => market.MarketSymbol); - } - - protected override async Task< - IReadOnlyDictionary - > OnGetCurrenciesAsync() - { - var currencies = new Dictionary(); - JToken products = await MakeJsonRequestAsync("/currencies"); - foreach (JToken product in products) - { - var currency = new ExchangeCurrency - { - Name = product["id"].ToStringUpperInvariant(), - FullName = product["name"].ToStringInvariant(), - DepositEnabled = true, - WithdrawalEnabled = true - }; - - currencies[currency.Name] = currency; - } - - return currencies; - } - - protected override async Task OnGetTickerAsync(string marketSymbol) - { - JToken ticker = await MakeJsonRequestAsync( - "/products/" + marketSymbol + "/ticker" - ); - return await this.ParseTickerAsync( - ticker, - marketSymbol, - "ask", - "bid", - "price", - "volume", - null, - "time", - TimestampType.Iso8601UTC - ); - } + JToken obj = await MakeJsonRequestAsync("/orders/historical/" + orderId); + return ParseOrder(obj); + } - protected override async Task OnGetDepositAddressAsync( - string symbol, - bool forceRegenerate = false - ) - { - // Hack found here: https://github.com/coinbase/gdax-node/issues/91#issuecomment-352441654 + using Fiddler - - // Get coinbase accounts - JArray accounts = await this.MakeJsonRequestAsync( - "/coinbase-accounts", - null, - await GetNoncePayloadAsync(), - "GET" - ); - - foreach (JToken token in accounts) - { - string currency = token["currency"].ConvertInvariant(); - if (currency.Equals(symbol, StringComparison.InvariantCultureIgnoreCase)) - { - JToken accountWalletAddress = await this.MakeJsonRequestAsync( - $"/coinbase-accounts/{token["id"]}/addresses", - null, - await GetNoncePayloadAsync(), - "POST" - ); - - return new ExchangeDepositDetails - { - Address = accountWalletAddress["address"].ToStringInvariant(), - Currency = currency - }; - } - } - throw new APIException($"Address not found for {symbol}"); - } - - protected override async Task< - IEnumerable> - > OnGetTickersAsync() - { - Dictionary tickers = new Dictionary( - StringComparer.OrdinalIgnoreCase - ); - System.Threading.ManualResetEvent evt = new System.Threading.ManualResetEvent(false); - List symbols = (await GetMarketSymbolsAsync()).ToList(); - - // stupid Coinbase does not have a one shot API call for tickers outside of web sockets - using ( - var socket = await GetTickersWebSocketAsync( - (t) => - { - lock (tickers) - { - if (symbols.Count != 0) - { - foreach (var kv in t) - { - if (!tickers.ContainsKey(kv.Key)) - { - tickers[kv.Key] = kv.Value; - symbols.Remove(kv.Key); - } - } - if (symbols.Count == 0) - { - evt.Set(); - } - } - } - } - ) - ) - { - evt.WaitOne(10000); - return tickers; - } - } - - protected override Task OnGetDeltaOrderBookWebSocketAsync( - Action callback, - int maxCount = 20, - params string[] marketSymbols - ) - { - return ConnectPublicWebSocketAsync( - string.Empty, - (_socket, msg) => - { - string message = msg.ToStringFromUTF8(); - var book = new ExchangeOrderBook(); - - // string comparison on the json text for faster deserialization - // More likely to be an l2update so check for that first - if (message.Contains(@"""l2update""")) - { - // parse delta update - var delta = JsonConvert.DeserializeObject( - message, - SerializerSettings - ); - book.MarketSymbol = delta.ProductId; - book.SequenceId = delta.Time.Ticks; - foreach (string[] change in delta.Changes) - { - decimal price = change[1].ConvertInvariant(); - decimal amount = change[2].ConvertInvariant(); - if (change[0] == "buy") - { - book.Bids[price] = new ExchangeOrderPrice - { - Amount = amount, - Price = price - }; - } - else - { - book.Asks[price] = new ExchangeOrderPrice - { - Amount = amount, - Price = price - }; - } - } - } - else if (message.Contains(@"""snapshot""")) - { - // parse snapshot - var snapshot = JsonConvert.DeserializeObject( - message, - SerializerSettings - ); - book.MarketSymbol = snapshot.ProductId; - foreach (decimal[] ask in snapshot.Asks) - { - decimal price = ask[0]; - decimal amount = ask[1]; - book.Asks[price] = new ExchangeOrderPrice - { - Amount = amount, - Price = price - }; - } - - foreach (decimal[] bid in snapshot.Bids) - { - decimal price = bid[0]; - decimal amount = bid[1]; - book.Bids[price] = new ExchangeOrderPrice - { - Amount = amount, - Price = price - }; - } - } - else - { - // no other message type handled - return Task.CompletedTask; - } - - callback(book); - return Task.CompletedTask; - }, - async (_socket) => - { - // subscribe to order book channel for each symbol - if (marketSymbols == null || marketSymbols.Length == 0) - { - marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); - } - var chan = new Channel - { - Name = ChannelType.Level2, - ProductIds = marketSymbols.ToList() - }; - var channelAction = new ChannelAction - { - Type = ActionType.Subscribe, - Channels = new List { chan } - }; - await _socket.SendMessageAsync(channelAction); - } - ); - } - - protected override async Task OnGetTickersWebSocketAsync( - Action>> callback, - params string[] marketSymbols - ) - { - return await ConnectPublicWebSocketAsync( - "/", - async (_socket, msg) => - { - JToken token = JToken.Parse(msg.ToStringFromUTF8()); - if (token["type"].ToStringInvariant() == "ticker") - { - ExchangeTicker ticker = await this.ParseTickerAsync( - token, - token["product_id"].ToStringInvariant(), - "best_ask", - "best_bid", - "price", - "volume_24h", - null, - "time", - TimestampType.Iso8601UTC - ); - callback( - new List>() - { - new KeyValuePair( - token["product_id"].ToStringInvariant(), - ticker - ) - } - ); - } - }, - async (_socket) => - { - marketSymbols = - marketSymbols == null || marketSymbols.Length == 0 - ? (await GetMarketSymbolsAsync()).ToArray() - : marketSymbols; - var subscribeRequest = new - { - type = "subscribe", - product_ids = marketSymbols, - channels = new object[] - { - new { name = "ticker", product_ids = marketSymbols.ToArray() } - } - }; - await _socket.SendMessageAsync(subscribeRequest); - } - ); - } - - protected override async Task OnGetTradesWebSocketAsync( - Func, Task> callback, - params string[] marketSymbols - ) - { - if (marketSymbols == null || marketSymbols.Length == 0) - { - marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); - } - return await ConnectPublicWebSocketAsync( - "/", - async (_socket, msg) => - { - JToken token = JToken.Parse(msg.ToStringFromUTF8()); - if (token["type"].ToStringInvariant() == "error") - { // {{ "type": "error", "message": "Failed to subscribe", "reason": "match is not a valid channel" }} - Logger.Info( - token["message"].ToStringInvariant() - + ": " - + token["reason"].ToStringInvariant() - ); - return; - } - if (token["type"].ToStringInvariant() != "match") - return; //the ticker channel provides the trade information as well - if (token["time"] == null) - return; - ExchangeTrade trade = ParseTradeWebSocket(token); - string marketSymbol = token["product_id"].ToStringInvariant(); - await callback(new KeyValuePair(marketSymbol, trade)); - }, - async (_socket) => - { - var subscribeRequest = new - { - type = "subscribe", - product_ids = marketSymbols, - channels = new object[] - { - new { name = "matches", product_ids = marketSymbols } - } - }; - await _socket.SendMessageAsync(subscribeRequest); - } - ); - } - - private ExchangeTrade ParseTradeWebSocket(JToken token) - { - return token.ParseTradeCoinbase( - "size", - "price", - "side", - "time", - TimestampType.Iso8601UTC, - "trade_id" - ); - } - - protected override async Task OnUserDataWebSocketAsync(Action callback) - { - return await ConnectPublicWebSocketAsync( - "/", - async (_socket, msg) => - { - var token = msg.ToStringFromUTF8(); - var response = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - switch (response.Type) - { - case ResponseType.Subscriptions: - var subscription = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - if (subscription.Channels == null || !subscription.Channels.Any()) - { - Trace.WriteLine( - $"{nameof(OnUserDataWebSocketAsync)}() no channels subscribed" - ); - } - else - { - Trace.WriteLine( - $"{nameof(OnUserDataWebSocketAsync)}() subscribed to " - + $"{string.Join(",", subscription.Channels.Select(c => c.ToString()))}" - ); - } - break; - case ResponseType.Ticker: - throw new NotImplementedException( - $"Not expecting type {response.Type} in {nameof(OnUserDataWebSocketAsync)}()" - ); - case ResponseType.Snapshot: - throw new NotImplementedException( - $"Not expecting type {response.Type} in {nameof(OnUserDataWebSocketAsync)}()" - ); - case ResponseType.L2Update: - throw new NotImplementedException( - $"Not expecting type {response.Type} in {nameof(OnUserDataWebSocketAsync)}()" - ); - case ResponseType.Heartbeat: - var heartbeat = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - Trace.WriteLine( - $"{nameof(OnUserDataWebSocketAsync)}() heartbeat received {heartbeat}" - ); - break; - case ResponseType.Received: - var received = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - callback(received.ExchangeOrderResult); - break; - case ResponseType.Open: - var open = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - callback(open.ExchangeOrderResult); - break; - case ResponseType.Done: - var done = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - callback(done.ExchangeOrderResult); - break; - case ResponseType.Match: - var match = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - callback(match.ExchangeOrderResult); - break; - case ResponseType.LastMatch: - //var lastMatch = JsonConvert.DeserializeObject(token); - throw new NotImplementedException( - $"Not expecting type {response.Type} in {nameof(OnUserDataWebSocketAsync)}()" - ); - case ResponseType.Error: - var error = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - throw new APIException($"{error.Reason}: {error.Message}"); - case ResponseType.Change: - var change = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - callback(change.ExchangeOrderResult); - break; - case ResponseType.Activate: - var activate = JsonConvert.DeserializeObject( - token, - SerializerSettings - ); - callback(activate.ExchangeOrderResult); - break; - case ResponseType.Status: - //var status = JsonConvert.DeserializeObject(token); - throw new NotImplementedException( - $"Not expecting type {response.Type} in {nameof(OnUserDataWebSocketAsync)}()" - ); - default: - throw new NotImplementedException( - $"Not expecting type {response.Type} in {nameof(OnUserDataWebSocketAsync)}()" - ); - } - }, - async (_socket) => - { - var marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); - var nonce = await GetNoncePayloadAsync(); - string timestamp = nonce["nonce"].ToStringInvariant(); - byte[] secret = CryptoUtility.ToBytesBase64Decode(PrivateApiKey); - string toHash = timestamp + "GET" + "/users/self/verify"; - var subscribeRequest = new - { - type = "subscribe", - channels = new object[] - { - new { name = "user", product_ids = marketSymbols, } - }, - signature = CryptoUtility.SHA256SignBase64(toHash, secret), // signature base 64 string - key = PublicApiKey.ToUnsecureString(), - passphrase = CryptoUtility.ToUnsecureString(Passphrase), - timestamp = timestamp - }; - await _socket.SendMessageAsync(subscribeRequest); - } - ); - } - - protected override async Task OnGetHistoricalTradesAsync( - Func, bool> callback, - string marketSymbol, - DateTime? startDate = null, - DateTime? endDate = null, - int? limit = null - ) - { - /* - [{ - "time": "2014-11-07T22:19:28.578544Z", - "trade_id": 74, - "price": "10.00000000", - "size": "0.01000000", - "side": "buy" - }, { - "time": "2014-11-07T01:08:43.642366Z", - "trade_id": 73, - "price": "100.00000000", - "size": "0.01000000", - "side": "sell" - }] - */ - - ExchangeHistoricalTradeHelper state = new ExchangeHistoricalTradeHelper(this) - { - Callback = callback, - EndDate = endDate, - ParseFunction = (JToken token) => - token.ParseTrade( - "size", - "price", - "side", - "time", - TimestampType.Iso8601UTC, - "trade_id" - ), - StartDate = startDate, - MarketSymbol = marketSymbol, - Url = "/products/[marketSymbol]/trades", - UrlFunction = (ExchangeHistoricalTradeHelper _state) => - { - return _state.Url - + ( - string.IsNullOrWhiteSpace(cursorBefore) - ? string.Empty - : "?before=" + cursorBefore.ToStringInvariant() - ); - } - }; - await state.ProcessHistoricalTrades(); - } - - protected override async Task> OnGetRecentTradesAsync( - string marketSymbol, - int? limit = null - ) - { - //https://docs.pro.coinbase.com/#pagination Coinbase limit is 100, however pagination can return more (4 later) - int requestLimit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; - - string baseUrl = - "/products/" - + marketSymbol.ToUpperInvariant() - + "/trades" - + "?limit=" - + requestLimit; - JToken trades = await MakeJsonRequestAsync(baseUrl); - List tradeList = new List(); - foreach (JToken trade in trades) - { - tradeList.Add( - trade.ParseTrade( - "size", - "price", - "side", - "time", - TimestampType.Iso8601UTC, - "trade_id" - ) - ); - } - return tradeList; - } - - protected override async Task OnGetOrderBookAsync( - string marketSymbol, - int maxCount = 50 - ) - { - string url = "/products/" + marketSymbol.ToUpperInvariant() + "/book?level=2"; - JToken token = await MakeJsonRequestAsync(url); - return token.ParseOrderBookFromJTokenArrays(); - } - - 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"); - } - - // /products//candles - // https://api.pro.coinbase.com/products/LTC-BTC/candles?granularity=86400&start=2017-12-04T18:15:33&end=2017-12-11T18:15:33 - List candles = new List(); - string url = "/products/" + marketSymbol + "/candles?granularity=" + periodSeconds; - if (startDate == null) - { - startDate = CryptoUtility.UtcNow.Subtract(TimeSpan.FromDays(1.0)); - } - url += - "&start=" - + startDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture); - if (endDate == null) - { - endDate = CryptoUtility.UtcNow; - } - url += - "&end=" - + endDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture); - - // time, low, high, open, close, volume - JToken token = await MakeJsonRequestAsync(url); - foreach (JToken candle in token) - { - candles.Add( - this.ParseCandle( - candle, - marketSymbol, - periodSeconds, - 3, - 2, - 1, - 4, - 0, - TimestampType.UnixSeconds, - 5 - ) - ); - } - // re-sort in ascending order - candles.Sort((c1, c2) => c1.Timestamp.CompareTo(c2.Timestamp)); - return candles; - } - - protected override async Task> OnGetAmountsAsync() - { - Dictionary amounts = new Dictionary( - StringComparer.OrdinalIgnoreCase - ); - JArray array = await MakeJsonRequestAsync( - "/accounts", - null, - await GetNoncePayloadAsync(), - "GET" - ); - foreach (JToken token in array) - { - decimal amount = token["balance"].ConvertInvariant(); - if (amount > 0m) - { - amounts[token["currency"].ToStringInvariant()] = amount; - } - } - return amounts; - } - - protected override async Task< - Dictionary - > OnGetAmountsAvailableToTradeAsync() - { - Dictionary amounts = new Dictionary( - StringComparer.OrdinalIgnoreCase - ); - JArray array = await MakeJsonRequestAsync( - "/accounts", - null, - await GetNoncePayloadAsync(), - "GET" - ); - foreach (JToken token in array) - { - decimal amount = token["available"].ConvertInvariant(); - if (amount > 0m) - { - amounts[token["currency"].ToStringInvariant()] = amount; - } - } - return amounts; - } + protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { + Dictionary payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; + await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); + } - protected override async Task> OnGetFeesAsync() - { - var symbols = await OnGetMarketSymbolsAsync(); - - Dictionary fees = new Dictionary( - StringComparer.OrdinalIgnoreCase - ); - - JObject token = await MakeJsonRequestAsync( - "/fees", - null, - await GetNoncePayloadAsync(), - "GET" - ); - /* - * We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per symbol. - * Here, we choose taker fee, which are usually higher - */ - decimal makerRate = token["taker_fee_rate"].Value(); //percentage between 0 and 1 - - fees = symbols - .Select(symbol => new KeyValuePair(symbol, makerRate)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - - return fees; - } + protected override Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + return base.OnPlaceOrderAsync(order); + } - protected override async Task OnWithdrawAsync( - ExchangeWithdrawalRequest request - ) - { - var nonce = await GenerateNonceAsync(); - var payload = new Dictionary - { - { "nonce", nonce }, - { "amount", request.Amount }, - { "currency", request.Currency }, - { "crypto_address", request.Address }, - { "add_network_fee_to_total", !request.TakeFeeFromAmount }, - }; - - if (!string.IsNullOrEmpty(request.AddressTag)) - { - payload.Add("destination_tag", request.AddressTag); - } - - var result = await MakeJsonRequestAsync( - "/withdrawals/crypto", - null, - payload, - "POST" - ); - var feeParsed = decimal.TryParse(result.Fee, out var fee); - - return new ExchangeWithdrawalResponse - { - Id = result.Id, - Fee = feeParsed ? fee : (decimal?)null - }; - } + protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) + { + return base.OnWithdrawAsync(withdrawalRequest); + } - protected override async Task OnPlaceOrderAsync( - ExchangeOrderRequest order - ) - { - object nonce = await GenerateNonceAsync(); - Dictionary payload = new Dictionary - { - { "nonce", nonce }, - { "type", order.OrderType.ToStringLowerInvariant() }, - { "side", (order.IsBuy ? "buy" : "sell") }, - { "product_id", order.MarketSymbol }, - { "size", order.RoundAmount().ToStringInvariant() } - }; - payload["time_in_force"] = "GTC"; // good til cancel - switch (order.OrderType) - { - case OrderType.Limit: - if (order.IsPostOnly != null) - payload["post_only"] = order.IsPostOnly; // [optional]** Post only flag, ** Invalid when time_in_force is IOC or FOK - if (order.Price == null) - throw new ArgumentNullException(nameof(order.Price)); - payload["price"] = order.Price.ToStringInvariant(); - break; - - case OrderType.Stop: - payload["stop"] = (order.IsBuy ? "entry" : "loss"); - payload["stop_price"] = order.StopPrice.ToStringInvariant(); - if (order.Price == null) - throw new ArgumentNullException(nameof(order.Price)); - payload["type"] = order.Price > 0m ? "limit" : "market"; - break; - - case OrderType.Market: - default: - break; - } - - order.ExtraParameters.CopyTo(payload); - var result = await MakeJsonRequestFullAsync("/orders", null, payload, "POST"); - var resultOrder = ParseOrder(result.Response); - resultOrder.HTTPHeaderDate = result.HTTPHeaderDate.Value.UtcDateTime; - return resultOrder; - } - - protected override async Task OnGetOrderDetailsAsync( - string orderId, - string marketSymbol = null, - bool isClientOrderId = false - ) - { // Orders may be queried using either the exchange assigned id or the client assigned client_oid. When using client_oid it must be preceded by the client: namespace. - JToken obj = await MakeJsonRequestAsync( - "/orders/" + (isClientOrderId ? "client:" : "") + orderId, - null, - await GetNoncePayloadAsync(), - "GET" - ); - var order = ParseOrder(obj); - if ( - !order.MarketSymbol.Equals( - marketSymbol, - StringComparison.InvariantCultureIgnoreCase - ) - ) - throw new DataMisalignedException( - $"Order {orderId} found, but symbols {order.MarketSymbol} and {marketSymbol} don't match" - ); - else - return order; - } - protected override async Task> OnGetOpenOrderDetailsAsync( - string marketSymbol = null - ) - { - List orders = new List(); - JArray array = await MakeJsonRequestAsync( - "orders?status=open&status=pending&status=active" - + ( - string.IsNullOrWhiteSpace(marketSymbol) - ? string.Empty - : "&product_id=" + marketSymbol - ), - null, - await GetNoncePayloadAsync(), - "GET" - ); - foreach (JToken token in array) - { - orders.Add(ParseOrder(token)); - } - - return orders; - } + #endregion - protected override async Task< - IEnumerable - > OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) - { - List orders = new List(); - JArray array = await MakeJsonRequestAsync( - "orders?status=done" - + ( - string.IsNullOrWhiteSpace(marketSymbol) - ? string.Empty - : "&product_id=" + marketSymbol - ), - null, - await GetNoncePayloadAsync(), - "GET" - ); - foreach (JToken token in array) - { - ExchangeOrderResult result = ParseOrder(token); - if (afterDate == null || result.OrderDate >= afterDate) - { - orders.Add(result); - } - } - - return orders; - } + #region SocketEndpoints - public async Task> GetFillsAsync( - string marketSymbol = null, - DateTime? afterDate = null - ) - { - List orders = new List(); - marketSymbol = NormalizeMarketSymbol(marketSymbol); - var productId = ( - string.IsNullOrWhiteSpace(marketSymbol) - ? string.Empty - : "product_id=" + marketSymbol - ); - do - { - var after = cursorAfter == null ? string.Empty : $"after={cursorAfter}&"; - await new SynchronizationContextRemover(); - await MakeFillRequest(afterDate, productId, orders, after); - } while (cursorAfter != null); - return orders; - } + protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) + { + return base.OnGetDeltaOrderBookWebSocketAsync(callback); + } - private async Task MakeFillRequest( - DateTime? afterDate, - string productId, - List orders, - string after - ) - { - var interrogation = after != "" || productId != "" ? "?" : string.Empty; - JArray array = await MakeJsonRequestAsync( - $"fills{interrogation}{after}{productId}", - null, - await GetNoncePayloadAsync() - ); - - foreach (JToken token in array) - { - ExchangeOrderResult result = ParseFill(token); - if (afterDate == null || result.OrderDate >= afterDate) - { - orders.Add(result); - } - - if (afterDate != null && result.OrderDate < afterDate) - { - cursorAfter = null; - break; - } - } - } + protected override Task OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) + { + return base.OnGetTickersWebSocketAsync(callback, marketSymbols); + } - protected override async Task OnCancelOrderAsync( - string orderId, - string marketSymbol = null, - bool isClientOrderId = false - ) + protected override Task OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) { - var jToken = await MakeJsonRequestAsync( - "orders/" + (isClientOrderId ? "client:" : "") + orderId, - null, - await GetNoncePayloadAsync(), - "DELETE" - ); - if (jToken.ToStringInvariant() != orderId) - throw new APIException( - $"Cancelled {jToken.ToStringInvariant()} when trying to cancel {orderId}" - ); - } - } - - public partial class ExchangeName - { - public const string Coinbase = "Coinbase"; - } + return base.OnGetTradesWebSocketAsync(callback, marketSymbols); + } + + #endregion + + #region PrivateFunctions + + private async Task> GetAmounts(bool AvailableOnly) + { + Accounts ??= new Dictionary(); // This function is the only place where Accounts cache is populated + + Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + pagination = PaginationType.V3; + JToken token = await MakeJsonRequestAsync("/accounts"); + while(true) + { + foreach (JToken account in token["accounts"]) + { + Accounts[account[CURRENCY].ToString()] = account["uuid"].ToString(); // populate Accounts cache as we go + decimal amount = AvailableOnly ? account["available_balance"][VALUE].ConvertInvariant() : account["available_balance"][VALUE].ConvertInvariant() + account["hold"][VALUE].ConvertInvariant(); + if (amount > 0.0m) amounts[account[CURRENCY].ToStringInvariant()] = amount; + } + if (cursorNext == null) break; + token = await MakeJsonRequestAsync("/accounts?starting_after=" + cursorNext); + } + pagination = PaginationType.None; + return amounts; + } + + /// + /// Warning: This call uses V2 Transactions + /// + /// + /// + /// + private async Task> GetTx(bool Withdrawals, string currency) + { + if (Accounts == null) await GetAmounts(true); + pagination = PaginationType.V2; + List transfers = new List(); + JToken tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions", BaseURLV2); + while(true) + { + foreach (JToken token in tokens) + { + // A "send" to Coinbase is when someone "sent" you coin - or a receive to the rest of the world + // Likewise, a "receive" is when someone "received" coin from you. In other words, it's back-asswards. + if (!Withdrawals && token["type"].ToStringInvariant().Equals("send")) transfers.Add(ParseTransaction(token)); + else if (Withdrawals && token["type"].ToStringInvariant().Equals("receive")) transfers.Add(ParseTransaction(token)); + + // Legacy Order and other Coinbase Tx Types can be parsed using this V2 code block + //var tmp = ParseOrder(token); + } + if (string.IsNullOrEmpty(cursorNext)) break; + tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions?starting_after={cursorNext}", BaseURLV2); + } + pagination = PaginationType.None; + return transfers; + } + + /// + /// Parse V2 Transaction of type of either "Send" or "Receive" + /// + /// + /// + private ExchangeTransaction ParseTransaction(JToken token) + { + // The Coin Address/TxFee isn't available but can be retrieved using the Network Hash/BlockChainId + return new ExchangeTransaction() + { + PaymentId = token["id"].ToStringInvariant(), // Not sure how this is used elsewhere but here it is the Coinbase TransactionID + BlockchainTxId = token["network"]["hash"].ToStringInvariant(), + Currency = token[AMOUNT][CURRENCY].ToStringInvariant(), + Amount = token[AMOUNT][AMOUNT].ConvertInvariant(), + Timestamp = token["created_at"].ToObject(), + Status = token["status"].ToStringInvariant() == "completed" ? TransactionStatus.Complete : TransactionStatus.Unknown, + Notes = token["description"].ToStringInvariant() + // Address + // AddressTag + // TxFee + }; + } + + + /// + /// Parse both Advanced Trade and Legacy Transactions + /// + /// + /// + private ExchangeOrderResult ParseOrder(JToken result) + { + decimal amount = 0, amountFilled = 0, price = 0, fees = 0; + string marketSymbol = string.Empty; + bool isBuy = true; + + Debug.WriteLine(result["type"].ToStringInvariant()); + switch(result["type"].ToStringInvariant()) + { + case ADVFILL: + // Buys/Sells have reversed amounts? + + + break; + case "send": + case "receive": + return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; + case "buy": + case "sell": + case "trade": + case "request": + case "transfer": + + case "exchange_deposit": + case "fiat_deposit": + case "fiat_withdrawal": + case "pro_withdrawal": + case "vault_withdrawal": + default: + return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; + } + + amount = result["amount"]["amount"].ConvertInvariant(amountFilled); + amountFilled = amount; + + price = result[ADVFILL]["fill_price"].ConvertInvariant(); + fees = result[ADVFILL]["commission"].ConvertInvariant(); + marketSymbol = result[ADVFILL][PRODUCTID].ToStringInvariant(result["id"].ToStringInvariant()); + isBuy = (result[ADVFILL]["order_side"].ToStringInvariant() == "buy"); + + ExchangeOrderResult order = new ExchangeOrderResult() + { + IsBuy = isBuy, + Amount = amount, + AmountFilled = amountFilled, + Price = price, + Fees = fees, + FeesCurrency = result["native_amount"]["currency"].ToStringInvariant(), + OrderDate = result["created_at"].ToDateTimeInvariant(), + CompletedDate = result["updated_at"].ToDateTimeInvariant(), + MarketSymbol = marketSymbol, + OrderId = result["id"].ToStringInvariant(), + Message = result["type"].ToStringInvariant() + }; + + switch (result["status"].ToStringInvariant()) + { + case "completed": + order.Result = ExchangeAPIOrderResult.Filled; + break; + case "waiting_for_clearing": + case "waiting_for_signature": + case "pending": + order.Result = ExchangeAPIOrderResult.PendingOpen; + break; + case "expired": + case "canceled": + order.Result = ExchangeAPIOrderResult.Canceled; + break; + default: + order.Result = ExchangeAPIOrderResult.Unknown; + break; + } + return order; + } + + #endregion + + } + + public partial class ExchangeName { public const string Coinbase = "Coinbase"; } } diff --git a/tests/ExchangeSharpTests/ExchangeKrakenAPITests.cs b/tests/ExchangeSharpTests/ExchangeKrakenAPITests.cs index 5343f462..a81c0c75 100644 --- a/tests/ExchangeSharpTests/ExchangeKrakenAPITests.cs +++ b/tests/ExchangeSharpTests/ExchangeKrakenAPITests.cs @@ -38,7 +38,7 @@ public void ExtendResultsWithOrderDescrAndPriceTest() var extendedOrder = api.ExtendResultsWithOrderDescr(new ExchangeOrderResult(), toParse); extendedOrder.IsBuy.Should().BeTrue(); - extendedOrder.Amount.Should().Be(0.001254); + extendedOrder.Amount.Should().Be(0.001254m); extendedOrder.MarketSymbol.Should().Be("BTCUSDT"); extendedOrder.Price.Should().Be(1000); } From ec9113c9c667f6ea162ca5bb365b200cb899fe51 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Fri, 1 Dec 2023 10:10:17 -0500 Subject: [PATCH 02/10] Fixed formating. Removed Debug --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index 782045b4..b530d6cb 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -15,7 +15,6 @@ The above copyright notice and this permission notice shall be included in all c using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -26,7 +25,7 @@ namespace ExchangeSharp /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site. /// These keys must be set before using the Coinbase API (sorry). /// - public sealed class ExchangeCoinbaseAPI : ExchangeAPI + public sealed class ExchangeCoinbaseAPI : ExchangeAPI { private const string ADVFILL = "advanced_trade_fill"; private const string CURRENCY = "currency"; @@ -114,14 +113,14 @@ protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary /// public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) - { + { if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) { var split = marketSymbol.Split(GlobalMarketSymbolSeparator); return Task.FromResult(split[1] + GlobalMarketSymbolSeparator + split[0]); } else return Task.FromResult(marketSymbol); - } + } protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) { @@ -151,7 +150,7 @@ protected internal override async Task> OnGetMarketS JToken products = await MakeJsonRequestAsync("/products"); foreach (JToken product in products[PRODUCTS]) { - markets.Add(new ExchangeMarket + markets.Add(new ExchangeMarket() { MarketSymbol = product[PRODUCTID].ToStringUpperInvariant(), BaseCurrency = product["base_currency_id"].ToStringUpperInvariant(), @@ -213,18 +212,18 @@ protected override async Task>> foreach (JToken book in books[PRICEBOOKS]) { var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - // This endpoint does not provide a last or open for the ExchangeTicker + // This endpoint does not provide a last or open for the ExchangeTicker. We might get this from the sockets, but this call is extremely fast? tickers.Add(new KeyValuePair(book[PRODUCTID].ToString(), new ExchangeTicker() { MarketSymbol = book[PRODUCTID].ToString(), Ask = book[ASKS][0][PRICE].ConvertInvariant(), Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() + Volume = new ExchangeVolume() { - BaseCurrency = split[0], + BaseCurrency = split[0], BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), Timestamp = Timestamp } })); @@ -234,6 +233,7 @@ protected override async Task>> protected override async Task OnGetTickerAsync(string marketSymbol) { + // Again, me might also get this from the sockets, but this seems preferable for now. JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol.ToUpperInvariant()); JToken book = ticker[PRICEBOOKS][0]; var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); @@ -242,12 +242,12 @@ protected override async Task OnGetTickerAsync(string marketSymb MarketSymbol = book[PRODUCTID].ToString(), Ask = book[ASKS][0][PRICE].ConvertInvariant(), Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() + Volume = new ExchangeVolume() { - BaseCurrency = split[0], + BaseCurrency = split[0], BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), Timestamp = DateTime.UtcNow } }; @@ -264,7 +264,7 @@ protected override async Task OnGetOrderBookAsync(string mark protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = 100) { - // Limit is required but maxed at 100 with no pagination available + // Limit is required but maxed at 100 with no pagination available. Check Sockets? limit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol.ToUpperInvariant() + "/ticker?limit=" + limit); List tradeList = new List(); @@ -401,7 +401,7 @@ protected override async Task> OnGetCompletedOr pagination = PaginationType.V3Cursor; string startURL = "/orders/historical/fills"; - if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol; + if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol.ToStringUpperInvariant(); if (afterDate != null) startURL += marketSymbol == null ? "?" : "&" + "start_sequence_timestamp=" + ((DateTimeOffset)afterDate).ToUnixTimeSeconds(); JToken token = await MakeJsonRequestAsync(startURL); startURL += marketSymbol == null && afterDate == null ? "?" : "&" + "cursor="; @@ -564,7 +564,7 @@ private ExchangeOrderResult ParseOrder(JToken result) string marketSymbol = string.Empty; bool isBuy = true; - Debug.WriteLine(result["type"].ToStringInvariant()); + //Debug.WriteLine(result["type"].ToStringInvariant()); switch(result["type"].ToStringInvariant()) { case ADVFILL: @@ -590,7 +590,7 @@ private ExchangeOrderResult ParseOrder(JToken result) return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; } - amount = result["amount"]["amount"].ConvertInvariant(amountFilled); + amount = result[AMOUNT][AMOUNT].ConvertInvariant(amountFilled); amountFilled = amount; price = result[ADVFILL]["fill_price"].ConvertInvariant(); From a3f32318ff34b79d683a27fc65dedd1be84ef016 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Sat, 2 Dec 2023 04:07:16 -0500 Subject: [PATCH 03/10] Update PlaceOrder - not fully tested. --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 106 ++++++++++++++---- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index b530d6cb..95c0f411 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -69,28 +69,20 @@ private ExchangeCoinbaseAPI() /// /// /// - private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) - { + private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) + { // We can bypass serialization if we already know the last call isn't paginated if (state == RequestMakerState.Finished && pagination != PaginationType.None) { - var token = JsonConvert.DeserializeObject((string)response); - if (pagination == PaginationType.V3) - { - if (token != null && token["has_next"] != null) cursorNext = token["has_next"].Equals("True") ? token[CURSOR].ToStringInvariant() : null; - else cursorNext = null; - } - else if (pagination == PaginationType.V3Cursor) // Only used for V3 fills - go figure. - { - if (token != null && token[CURSOR] != null) cursorNext = token[CURSOR].ToStringInvariant(); - else cursorNext = null; - } - else - { - if (token["pagination"] != null) cursorNext = token["pagination"]["next_starting_after"]?.ToStringInvariant(); - else cursorNext = null; - } - if (cursorNext == string.Empty) cursorNext = null; + cursorNext = null; + JToken token = JsonConvert.DeserializeObject((string)response); + if (token == null) return; + switch(pagination) + { + case PaginationType.V2: cursorNext = token["pagination"]?["next_starting_after"]?.ToStringInvariant(); break; + case PaginationType.V3: cursorNext = token["has_next"].ToStringInvariant().Equals("True") ? token[CURSOR]?.ToStringInvariant() : null; break; + case PaginationType.V3Cursor: cursorNext = token[CURSOR]?.ToStringInvariant(); break; // Only used for V3 Fills - go figure. + } } } @@ -442,11 +434,79 @@ protected override async Task OnCancelOrderAsync(string orderId, string marketSy await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); } - protected override Task OnPlaceOrderAsync(ExchangeOrderRequest order) - { - return base.OnPlaceOrderAsync(order); - } +/// + /// This supports two Entries in the Order ExtraParameters: + /// "post_only" : true/false (defaults to false if does not exist) + /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC + /// + /// + /// + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + Dictionary configuration = new Dictionary(); + switch (order.OrderType) + { + case OrderType.Limit: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + { + configuration.Add("limit_limit_gtd", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + }); + } + else + { + configuration.Add("limit_limit_gtc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + }); + } + break; + case OrderType.Stop: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + { + configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? + }); + } + else + { + configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? + }); + } + break; + case OrderType.Market: + configuration.Add("market_market_ioc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() } + }); + break; + } + + Dictionary payload = new Dictionary { { "order_configuration", configuration} }; + string side = order.IsBuy ? "buy" : "sell"; + JToken result = await MakeJsonRequestAsync($"/orders?product_id={order.MarketSymbol.ToUpperInvariant()}&side={side}", payload: payload, requestMethod: "POST"); + // We don't have the proper return type for the POST - will probably require a separate parsing function and return Success/Fail + return ParseOrder(result); + } protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) { return base.OnWithdrawAsync(withdrawalRequest); From 5357586ecd702891aa0776352e0aaea7595aa1e5 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Sat, 2 Dec 2023 04:12:24 -0500 Subject: [PATCH 04/10] Updated --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index 95c0f411..2cc8314d 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -434,7 +434,7 @@ protected override async Task OnCancelOrderAsync(string orderId, string marketSy await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); } -/// + /// /// This supports two Entries in the Order ExtraParameters: /// "post_only" : true/false (defaults to false if does not exist) /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC @@ -470,23 +470,23 @@ protected override async Task OnPlaceOrderAsync(ExchangeOrd case OrderType.Stop: if (order.ExtraParameters.ContainsKey("gtd_timestamp")) { - configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() + configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() { {"base_size", order.Amount.ToStringInvariant() }, {"limit_price", order.Price.ToStringInvariant() }, {"stop_price", order.StopPrice.ToStringInvariant() }, + {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? }); } else { - configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() + configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() { {"base_size", order.Amount.ToStringInvariant() }, {"limit_price", order.Price.ToStringInvariant() }, {"stop_price", order.StopPrice.ToStringInvariant() }, - {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? }); @@ -507,15 +507,15 @@ protected override async Task OnPlaceOrderAsync(ExchangeOrd // We don't have the proper return type for the POST - will probably require a separate parsing function and return Success/Fail return ParseOrder(result); } - protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) - { - return base.OnWithdrawAsync(withdrawalRequest); - } + protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) + { + return base.OnWithdrawAsync(withdrawalRequest); + } - #endregion + #endregion - #region SocketEndpoints + #region SocketEndpoints protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) { @@ -534,7 +534,7 @@ protected override Task OnGetTradesWebSocketAsync(Func> GetAmounts(bool AvailableOnly) { From 65adf9256a9b5e842ccb7c583358d6fb7dee6354 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Sun, 3 Dec 2023 06:31:55 -0500 Subject: [PATCH 05/10] Completed Trades WebSocket --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index 2cc8314d..8d9510c3 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -163,7 +163,7 @@ protected override async Task> OnGetMarketSymbolsAsync() protected override async Task> OnGetCurrenciesAsync() { - var currencies = new Dictionary(); + var currencies = new Dictionary(); // We could order the return (like Market Symbols are) if we populate as a list then sort and select into a dictionary before return, but is it worth the overhead? // We don't have a currencies endpoint, but we can derive the currencies by splitting the products (includes fiat - filter if you wish) JToken products = await MakeJsonRequestAsync("/products"); @@ -527,10 +527,39 @@ protected override Task OnGetTickersWebSocketAsync(Action OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) + protected override async Task OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) { - return base.OnGetTradesWebSocketAsync(callback, marketSymbols); - } + if (marketSymbols == null || marketSymbols.Length == 0) marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); + return await ConnectWebSocketAsync("/", async (_socket, msg) => + { + JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); + foreach(var token in tokens["events"]?[0]?["trades"]) + { + await callback(new KeyValuePair(token["product_id"].ToStringInvariant(), new ExchangeTrade() + { + Amount = token["size"].ConvertInvariant(), + Price = token["price"].ConvertInvariant(), + IsBuy = token["side"].ToStringInvariant().Equals("buy"), + Id = token["trade_id"].ToStringInvariant(), + Timestamp = token["time"].ConvertInvariant() + })); + } + }, async (_socket) => + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + "market_trades" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new + { + type = "subscribe", + product_ids = marketSymbols, + channel = "market_trades", + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); + } #endregion From 7841064d66a65cf2b941edad8dca25ad97447d26 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Sun, 3 Dec 2023 08:52:34 -0500 Subject: [PATCH 06/10] Removed unused Code for CoinbaseAPI, Removed unneeded parsing from ExchangeAPIExtensions --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 48 ++- .../Coinbase/Models/CoinbaseTrade.cs | 31 -- .../Coinbase/Models/Request/Channel.cs | 31 -- .../Coinbase/Models/Request/ChannelAction.cs | 29 -- .../Coinbase/Models/Response/Level2.cs | 29 -- .../Coinbase/Models/Response/Messages.cs | 275 ------------------ .../Coinbase/Models/Response/Snapshot.cs | 28 -- .../Models/Response/WithdrawalResult.cs | 34 --- .../Coinbase/Models/Types/ActionType.cs | 25 -- .../Coinbase/Models/Types/ChannelType.cs | 37 --- .../Coinbase/Models/Types/ResponseType.cs | 50 ---- .../Exchanges/Coinbase/Models/Types/Types.cs | 43 --- .../Exchanges/_Base/ExchangeAPIExtensions.cs | 26 -- 13 files changed, 43 insertions(+), 643 deletions(-) delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/CoinbaseTrade.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/Channel.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/ChannelAction.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Level2.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Messages.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Snapshot.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/WithdrawalResult.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ActionType.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ChannelType.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ResponseType.cs delete mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/Types.cs diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index 8d9510c3..e494523c 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -10,7 +10,7 @@ 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. */ -using ExchangeSharp.Coinbase; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -522,10 +522,48 @@ protected override Task OnGetDeltaOrderBookWebSocketAsync(Action OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) + protected override async Task OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) { - return base.OnGetTickersWebSocketAsync(callback, marketSymbols); - } + return await ConnectWebSocketAsync("/", async (_socket, msg) => + { + JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); + + var timestamp = tokens["timestamp"].ConvertInvariant(); + List> ticks = new List>(); + foreach(var token in tokens["events"]?[0]?["tickers"]) + { + string product = token["product_id"].ToStringInvariant(); + var split = product.Split(GlobalMarketSymbolSeparator); + ticks.Add(new KeyValuePair(product, new ExchangeTicker() + { + // We don't have Bid or Ask info on this feed + ApiResponse = token, + Last = token["price"].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + QuoteCurrency = split[1], + BaseCurrencyVolume = token["volume_24_h"].ConvertInvariant(), + Timestamp = timestamp + } + } )); + } + callback?.Invoke(ticks); + }, async (_socket) => + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + "ticker" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new + { + type = "subscribe", + product_ids = marketSymbols, + channel = "ticker", + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); } protected override async Task OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) { @@ -535,7 +573,7 @@ protected override async Task OnGetTradesWebSocketAsync(Func(token["product_id"].ToStringInvariant(), new ExchangeTrade() + await callback?.Invoke(new KeyValuePair(token["product_id"].ToStringInvariant(), new ExchangeTrade() { Amount = token["size"].ConvertInvariant(), Price = token["price"].ConvertInvariant(), diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/CoinbaseTrade.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/CoinbaseTrade.cs deleted file mode 100644 index a754d1d4..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/CoinbaseTrade.cs +++ /dev/null @@ -1,31 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace ExchangeSharp.Coinbase -{ - public class CoinbaseTrade : ExchangeTrade - { - public Guid MakerOrderId { get; set; } - public Guid TakerOrderId { get; set; } - - public override string ToString() - { - return string.Format("{0},{1},{2}", base.ToString(), MakerOrderId, TakerOrderId); - } - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/Channel.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/Channel.cs deleted file mode 100644 index 519c59e6..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/Channel.cs +++ /dev/null @@ -1,31 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -namespace ExchangeSharp.Coinbase -{ - using System.Collections.Generic; - - using Newtonsoft.Json; - using Newtonsoft.Json.Converters; - - internal class Channel - { - [JsonConverter(typeof(StringEnumConverter))] - [JsonProperty("name")] - public ChannelType Name { get; set; } - - [JsonProperty("product_ids")] - public List ProductIds { get; set; } - - public override string ToString() => $"{Name} channel w/ {ProductIds.Count} symbols"; - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/ChannelAction.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/ChannelAction.cs deleted file mode 100644 index 0f09f803..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Request/ChannelAction.cs +++ /dev/null @@ -1,29 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -namespace ExchangeSharp.Coinbase -{ - using System.Collections.Generic; - - using Newtonsoft.Json; - using Newtonsoft.Json.Converters; - - internal class ChannelAction - { - [JsonConverter(typeof(StringEnumConverter))] - [JsonProperty("type")] - public ActionType Type { get; set; } - - [JsonProperty("channels")] - public List Channels { get; set; } - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Level2.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Level2.cs deleted file mode 100644 index 23ef74ca..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Level2.cs +++ /dev/null @@ -1,29 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -namespace ExchangeSharp.Coinbase -{ - using System; - using System.Collections.Generic; - - using Newtonsoft.Json; - - internal class Level2 : BaseMessage - { - [JsonProperty("product_id")] - public string ProductId { get; set; } - - public DateTime Time { get; set; } - - public List Changes { get; set; } - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Messages.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Messages.cs deleted file mode 100644 index 2e29e39f..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Messages.cs +++ /dev/null @@ -1,275 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -using System; -using System.Collections.Generic; - -namespace ExchangeSharp.Coinbase -{ - internal class BaseMessage - { - public ResponseType Type { get; set; } - } - - internal class Activate : BaseMessage - { - public Guid OrderId { get; set; } - public StopType OrderType { get; set; } - public decimal Size { get; set; } - public decimal Funds { get; set; } - public decimal TakerFeeRate { get; set; } - public bool Private { get; set; } - public decimal StopPrice { get; set; } - public string UserId { get; set; } - public Guid ProfileId { get; set; } - public OrderSide Side { get; set; } - public string ProductId { get; set; } - public DateTimeOffset TimeStamp { get; set; } - - public ExchangeOrderResult ExchangeOrderResult => - new ExchangeOrderResult() - { - OrderId = OrderId.ToString(), - ClientOrderId = null, // not provided here - Result = ExchangeAPIOrderResult.PendingOpen, // order has just been activated (so it starts in PendingOpen) - Message = null, // + can use for something in the future if needed - Amount = Size, - AmountFilled = 0, // just activated, so none filled - Price = null, // not specified here (only StopPrice is) - AveragePrice = null, // not specified here (only StopPrice is) - OrderDate = TimeStamp.UtcDateTime, // order activated event - CompletedDate = null, // order is active - MarketSymbol = ProductId, - IsBuy = Side == OrderSide.Buy, - Fees = null, // only TakerFeeRate is specified - no fees have been charged yet - TradeId = null, // no trades have been made - UpdateSequence = null, // unfortunately, the Activate event doesn't provide a sequence number - }; - } - - internal class Change : BaseMessage - { - public Guid OrderId { get; set; } - public decimal NewSize { get; set; } - public decimal OldSize { get; set; } - public decimal OldFunds { get; set; } - public decimal NewFunds { get; set; } - public decimal Price { get; set; } - public OrderSide Side { get; set; } - public string ProductId { get; set; } - public long Sequence { get; set; } - public DateTime Time { get; set; } - - public ExchangeOrderResult ExchangeOrderResult => - new ExchangeOrderResult() - { - OrderId = OrderId.ToString(), - ClientOrderId = null, // not provided here - Result = ExchangeAPIOrderResult.Unknown, // change messages are sent anytime an order changes in size; this includes resting orders (open) as well as received but not yet open - Message = null, // can use for something in the future if needed - Amount = NewSize, - AmountFilled = null, // not specified here - Price = Price, - AveragePrice = null, // not specified here - OrderDate = Time, // + unclear if the Time in the Change msg is the new OrderDate or whether that is unchanged - CompletedDate = null, // order is active - MarketSymbol = ProductId, - IsBuy = Side == OrderSide.Buy, - Fees = null, // only TakerFeeRate is specified - no fees have been charged yet - TradeId = null, // not a trade msg - UpdateSequence = Sequence, - }; - } - - internal class Done : BaseMessage - { - public OrderSide Side { get; set; } - public Guid OrderId { get; set; } - public DoneReasonType Reason { get; set; } - public string ProductId { get; set; } - public decimal Price { get; set; } - public decimal RemainingSize { get; set; } - public long Sequence { get; set; } - public DateTimeOffset Time { get; set; } - - public ExchangeOrderResult ExchangeOrderResult => - new ExchangeOrderResult() - { - OrderId = OrderId.ToString(), - ClientOrderId = null, // not provided here - Result = - Reason == DoneReasonType.Filled - ? ExchangeAPIOrderResult.Filled - : ExchangeAPIOrderResult.Canceled, // no way to tell it it is FilledPartiallyAndCenceled here - Message = null, // can use for something in the future if needed - Amount = 0, // ideally, this would be null, but ExchangeOrderResult.Amount is not nullable - AmountFilled = RemainingSize, - IsAmountFilledReversed = true, // since only RemainingSize is provided, not Size or FilledSize - Price = Price, - AveragePrice = null, // not specified here - // OrderDate - not provided here. ideally would be null but ExchangeOrderResult.OrderDate - CompletedDate = Time.UtcDateTime, - MarketSymbol = ProductId, - IsBuy = Side == OrderSide.Buy, - Fees = null, // not specified here - TradeId = null, // not a trade msg - UpdateSequence = Sequence, - }; - } - - internal class Error : BaseMessage - { - public string Message { get; set; } - public string Reason { get; set; } - } - - internal class Heartbeat : BaseMessage - { - public long LastTradeId { get; set; } - public string ProductId { get; set; } - public long Sequence { get; set; } - public DateTimeOffset Time { get; set; } - - public override string ToString() - { - return $"Heartbeat: Last TID {LastTradeId}, Product Id {ProductId}, Sequence {Sequence}, Time {Time}"; - } - } - - internal class LastMatch : BaseMessage - { - public long TradeId { get; set; } - public Guid MakerOrderId { get; set; } - public Guid TakerOrderId { get; set; } - public OrderSide Side { get; set; } - public decimal Size { get; set; } - public decimal Price { get; set; } - public string ProductId { get; set; } - public long Sequence { get; set; } - public DateTimeOffset Time { get; set; } - } - - internal class Match : BaseMessage - { - public long TradeId { get; set; } - public Guid MakerOrderId { get; set; } - public Guid TakerOrderId { get; set; } - public string TakerUserId { get; set; } - public string UserId { get; set; } - public Guid? TakerProfileId { get; set; } - public Guid ProfileId { get; set; } - public OrderSide Side { get; set; } - public decimal Size { get; set; } - public decimal Price { get; set; } - public string ProductId { get; set; } - public long Sequence { get; set; } - public DateTimeOffset Time { get; set; } - public string MakerUserId { get; set; } - public Guid? MakerProfileId { get; set; } - public decimal? MakerFeeRate { get; set; } - public decimal? TakerFeeRate { get; set; } - - public ExchangeOrderResult ExchangeOrderResult => - new ExchangeOrderResult() - { - OrderId = - MakerProfileId != null ? MakerOrderId.ToString() : TakerOrderId.ToString(), - ClientOrderId = null, // not provided here - Result = ExchangeAPIOrderResult.FilledPartially, // could also be completely filled, but unable to determine that here - Message = null, // can use for something in the future if needed - Amount = 0, // ideally, this would be null, but ExchangeOrderResult.Amount is not nullable - AmountFilled = Size, - IsAmountFilledReversed = false, // the size here appears to be amount filled, no no need to reverse - Price = Price, - AveragePrice = Price, // not specified here - // OrderDate - not provided here. ideally would be null but ExchangeOrderResult.OrderDate is not nullable - CompletedDate = null, // order not necessarily fullly filled at this point - TradeDate = Time.UtcDateTime, - MarketSymbol = ProductId, - IsBuy = Side == OrderSide.Buy, - Fees = (MakerFeeRate ?? TakerFeeRate) * Price * Size, - TradeId = TradeId.ToString(), - UpdateSequence = Sequence, - }; - } - - internal class Open : BaseMessage - { - public OrderSide Side { get; set; } - public decimal Price { get; set; } - public Guid OrderId { get; set; } - public decimal RemainingSize { get; set; } - public string ProductId { get; set; } - public long Sequence { get; set; } - public DateTimeOffset Time { get; set; } - - public ExchangeOrderResult ExchangeOrderResult => - new ExchangeOrderResult() - { - OrderId = OrderId.ToString(), - ClientOrderId = null, // not provided here - Result = ExchangeAPIOrderResult.Open, // order is now Open - Message = null, // can use for something in the future if needed - Amount = 0, // ideally, this would be null, but ExchangeOrderResult.Amount is not nullable - AmountFilled = RemainingSize, - IsAmountFilledReversed = true, // since only RemainingSize is provided, not Size or FilledSize - Price = Price, - AveragePrice = null, // not specified here - OrderDate = Time.UtcDateTime, // order open event - CompletedDate = null, // order is active - MarketSymbol = ProductId, - IsBuy = Side == OrderSide.Buy, - Fees = null, // not specified here - TradeId = null, // not a trade msg - UpdateSequence = Sequence, - }; - } - - internal class Received : BaseMessage - { - public Guid OrderId { get; set; } - public OrderType OrderType { get; set; } - public decimal Size { get; set; } - public decimal Price { get; set; } - public OrderSide Side { get; set; } - public Guid? ClientOid { get; set; } - public string ProductId { get; set; } - public long Sequence { get; set; } - public DateTimeOffset Time { get; set; } - - public ExchangeOrderResult ExchangeOrderResult => - new ExchangeOrderResult() - { - OrderId = OrderId.ToString(), - ClientOrderId = ClientOid.ToString(), - Result = ExchangeAPIOrderResult.PendingOpen, // order is now Pending - Message = null, // can use for something in the future if needed - Amount = Size, - AmountFilled = 0, // order received but not yet open, so none filled - IsAmountFilledReversed = false, - Price = Price, - AveragePrice = null, // not specified here - OrderDate = Time.UtcDateTime, // order received event - CompletedDate = null, // order is active - MarketSymbol = ProductId, - IsBuy = Side == OrderSide.Buy, - Fees = null, // not specified here - TradeId = null, // not a trade msg - UpdateSequence = Sequence, - }; - } - - internal class Subscription : BaseMessage - { - public List Channels { get; set; } - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Snapshot.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Snapshot.cs deleted file mode 100644 index c107d92e..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/Snapshot.cs +++ /dev/null @@ -1,28 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -namespace ExchangeSharp.Coinbase -{ - using System.Collections.Generic; - - using Newtonsoft.Json; - - internal class Snapshot : BaseMessage - { - [JsonProperty("product_id")] - public string ProductId { get; set; } - - public List Bids { get; set; } - - public List Asks { get; set; } - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/WithdrawalResult.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/WithdrawalResult.cs deleted file mode 100644 index 1a6568f4..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Response/WithdrawalResult.cs +++ /dev/null @@ -1,34 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -using Newtonsoft.Json; - -namespace ExchangeSharp.Coinbase -{ - public class WithdrawalResult - { - [JsonProperty("id")] - public string Id { get; set; } - - [JsonProperty("amount")] - public string Amount { get; set; } - - [JsonProperty("currency")] - public string Currency { get; set; } - - [JsonProperty("fee")] - public string Fee { get; set; } - - [JsonProperty("subtotal")] - public string Subtotal { get; set; } - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ActionType.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ActionType.cs deleted file mode 100644 index 7c96f939..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ActionType.cs +++ /dev/null @@ -1,25 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -namespace ExchangeSharp.Coinbase -{ - using System.Runtime.Serialization; - - internal enum ActionType - { - [EnumMember(Value = "subscribe")] - Subscribe, - - [EnumMember(Value = "unsubscribe")] - Unsubscribe - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ChannelType.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ChannelType.cs deleted file mode 100644 index 9e5e63f2..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ChannelType.cs +++ /dev/null @@ -1,37 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -namespace ExchangeSharp.Coinbase -{ - using System.Runtime.Serialization; - - internal enum ChannelType - { - [EnumMember(Value = "full")] - Full, - - [EnumMember(Value = "heartbeat")] - Heartbeat, - - [EnumMember(Value = "level2")] - Level2, - - [EnumMember(Value = "matches")] - Matches, - - [EnumMember(Value = "ticker")] - Ticker, - - [EnumMember(Value = "user")] - User - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ResponseType.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ResponseType.cs deleted file mode 100644 index 261a9e76..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/ResponseType.cs +++ /dev/null @@ -1,50 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -namespace ExchangeSharp.Coinbase -{ - using System.Runtime.Serialization; - - internal enum ResponseType - { - Unknown = 0, - - Subscriptions, - - Heartbeat, - - Ticker, - - Snapshot, - - L2Update, - - Received, - - Open, - - Done, - - Match, - - [EnumMember(Value = "last_match")] - LastMatch, - - Change, - - Activate, - - Error, - - Status, - } -} diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/Types.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/Types.cs deleted file mode 100644 index 00ad6a89..00000000 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/Models/Types/Types.cs +++ /dev/null @@ -1,43 +0,0 @@ -/* -MIT LICENSE - -Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. -*/ - -using System.Runtime.Serialization; - -namespace ExchangeSharp.Coinbase -{ - internal enum OrderSide - { - [EnumMember(Value = "buy")] - Buy, - - [EnumMember(Value = "sell")] - Sell - } - - internal enum StopType - { - [EnumMember(Value = "Unknown")] - Unknown, - - [EnumMember(Value = "loss")] - Loss, - - [EnumMember(Value = "entry")] - Entry, - } - - internal enum DoneReasonType - { - Canceled, - Filled - } -} diff --git a/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs b/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs index db887c28..829f05d4 100644 --- a/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs +++ b/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs @@ -21,7 +21,6 @@ The above copyright notice and this permission notice shall be included in all c using ExchangeSharp.Bitflyer; using ExchangeSharp.Bitstamp; using ExchangeSharp.Bybit; -using ExchangeSharp.Coinbase; using ExchangeSharp.Kraken; using ExchangeSharp.KuCoin; using ExchangeSharp.NDAX; @@ -835,31 +834,6 @@ internal static ExchangeTrade ParseTradeBitstamp( return trade; } - internal static ExchangeTrade ParseTradeCoinbase( - this JToken token, - object amountKey, - object priceKey, - object typeKey, - object timestampKey, - TimestampType timestampType, - object idKey, - string typeKeyIsBuyValue = "buy" - ) - { - var trade = ParseTradeComponents( - token, - amountKey, - priceKey, - typeKey, - timestampKey, - timestampType, - idKey, - typeKeyIsBuyValue - ); - trade.MakerOrderId = (Guid)token["maker_order_id"]; - trade.TakerOrderId = (Guid)token["taker_order_id"]; - return trade; - } internal static ExchangeTrade ParseTradeFTX( this JToken token, From f7a888997e7997a144adaaa9e2d03b44f3601e06 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Sun, 3 Dec 2023 13:24:01 -0500 Subject: [PATCH 07/10] Final WebSockets Updated --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 1364 +++++++++-------- 1 file changed, 710 insertions(+), 654 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index e494523c..41fafc0a 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -10,7 +10,6 @@ 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. */ - using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -20,59 +19,68 @@ The above copyright notice and this permission notice shall be included in all c namespace ExchangeSharp { - /// - /// Warning: This API now uses Coinbase Advanced Trade V2/V3. - /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site. - /// These keys must be set before using the Coinbase API (sorry). - /// + + /// + /// Warning: This API now uses Coinbase Advanced Trade V2/V3. + /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site. + /// These keys must be set before using the Coinbase API (sorry). + /// public sealed class ExchangeCoinbaseAPI : ExchangeAPI - { - private const string ADVFILL = "advanced_trade_fill"; - private const string CURRENCY = "currency"; - private const string PRODUCTID = "product_id"; - private const string PRODUCTS = "products"; - private const string PRICEBOOK = "pricebook"; - private const string PRICEBOOKS = "pricebooks"; - private const string ASKS = "asks"; - private const string BIDS = "bids"; - private const string PRICE = "price"; - private const string AMOUNT = "amount"; - private const string VALUE = "value"; - private const string SIZE = "size"; - private const string CURSOR = "cursor"; - - - public override string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage"; - private readonly string BaseURLV2 = "https://api.coinbase.com/v2"; // For Wallet Support - public override string BaseUrlWebSocket { get; set; } = "wss://advanced-trade-ws.coinbase.com"; + { + private const string ADVFILL = "advanced_trade_fill"; + private const string CURRENCY = "currency"; + private const string PRODUCTID = "product_id"; + private const string PRODUCTS = "products"; + private const string PRICEBOOK = "pricebook"; + private const string PRICEBOOKS = "pricebooks"; + private const string ASKS = "asks"; + private const string BIDS = "bids"; + private const string PRICE = "price"; + private const string AMOUNT = "amount"; + private const string VALUE = "value"; + private const string SIZE = "size"; + private const string CURSOR = "cursor"; + private const string TYPE = "type"; + private const string SUBSCRIBE = "subscribe"; + private const string MARKETTRADES = "market_trades"; + private const string TICKER = "ticker"; + private const string EVENTS = "events"; + private const string LEVEL2 = "level2"; + private const string PRICELEVEL = "price_level"; + private const string SIDE = "side"; + private const string BUY = "buy"; + + public override string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage"; + private readonly string BaseURLV2 = "https://api.coinbase.com/v2"; // For Wallet Support + public override string BaseUrlWebSocket { get; set; } = "wss://advanced-trade-ws.coinbase.com"; - private enum PaginationType { None, V2, V3, V3Cursor} - private PaginationType pagination = PaginationType.None; - private string cursorNext; - - private Dictionary Accounts = null; // Cached Account IDs - - private ExchangeCoinbaseAPI() - { - MarketSymbolIsReversed = false; - RequestContentType = "application/json"; - NonceStyle = NonceStyle.None; - WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas; - RateLimit = new RateGate(10, TimeSpan.FromSeconds(1)); - base.RequestMaker.RequestStateChanged = ProcessResponse; - } - - /// - /// This is used to capture Pagination instead of overriding the ProcessResponse - /// because the Pagination info is no longer in the Headers and ProcessResponse does not return the required Content - /// - /// - /// - /// - private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) - { - // We can bypass serialization if we already know the last call isn't paginated - if (state == RequestMakerState.Finished && pagination != PaginationType.None) + private enum PaginationType { None, V2, V3, V3Cursor} + private PaginationType pagination = PaginationType.None; + private string cursorNext; + + private Dictionary Accounts = null; // Cached Account IDs + + private ExchangeCoinbaseAPI() + { + MarketSymbolIsReversed = false; + RequestContentType = "application/json"; + NonceStyle = NonceStyle.None; + WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas; + RateLimit = new RateGate(30, TimeSpan.FromSeconds(1)); + base.RequestMaker.RequestStateChanged = ProcessResponse; + } + + /// + /// This is used to capture Pagination instead of overriding the ProcessResponse + /// because the Pagination info is no longer in the Headers and ProcessResponse does not return the required Content + /// + /// + /// + /// + private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) + { + // We can bypass serialization if we already know the last call isn't paginated + if (state == RequestMakerState.Finished && pagination != PaginationType.None) { cursorNext = null; JToken token = JsonConvert.DeserializeObject((string)response); @@ -84,486 +92,536 @@ private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, ob case PaginationType.V3Cursor: cursorNext = token[CURSOR]?.ToStringInvariant(); break; // Only used for V3 Fills - go figure. } } - } - - #region BaseOverrides - - /// - /// Overridden because we no longer need a nonce in the payload and passphrase is no longer used - /// - /// - /// - protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) - { - return (PrivateApiKey != null && PublicApiKey != null); - } - - /// - /// Sometimes the Fiat pairs are reported backwards, but Coinbase requires the fiat to be last of the pair - /// Only three Fiat Currencies are supported - /// - /// - /// - public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) - { - if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) - { - var split = marketSymbol.Split(GlobalMarketSymbolSeparator); - return Task.FromResult(split[1] + GlobalMarketSymbolSeparator + split[0]); - } - else return Task.FromResult(marketSymbol); - } + } - protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) - { - if (CanMakeAuthenticatedRequest(payload)) - { - string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); // If you're skittish about the local clock, you may retrieve the timestamp from the Coinbase Site - string body = CryptoUtility.GetJsonForPayload(payload); + #region BaseOverrides - // V2 wants PathAndQuery, V3 wants LocalPath for the sig - string path = request.RequestUri.AbsoluteUri.StartsWith(BaseURLV2) ? request.RequestUri.PathAndQuery : request.RequestUri.LocalPath; - string signature = CryptoUtility.SHA256Sign(timestamp + request.Method.ToUpperInvariant() + path + body, PrivateApiKey.ToUnsecureString()); + /// + /// Overridden because we no longer need a nonce in the payload and passphrase is no longer used + /// + /// + /// + protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) + { + return (PrivateApiKey != null && PublicApiKey != null); + } - request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString()); - request.AddHeader("CB-ACCESS-SIGN", signature); - request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp); - if (request.Method == "POST") await CryptoUtility.WriteToRequestAsync(request, body); - } - } + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { + if (CanMakeAuthenticatedRequest(payload)) + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); // If you're skittish about the local clock, you may retrieve the timestamp from the Coinbase Site + string body = CryptoUtility.GetJsonForPayload(payload); - #endregion + // V2 wants PathAndQuery, V3 wants LocalPath for the sig (I guess they wanted to shave a nano-second or two - silly) + string path = request.RequestUri.AbsoluteUri.StartsWith(BaseURLV2) ? request.RequestUri.PathAndQuery : request.RequestUri.LocalPath; + string signature = CryptoUtility.SHA256Sign(timestamp + request.Method.ToUpperInvariant() + path + body, PrivateApiKey.ToUnsecureString()); - #region GeneralProductEndpoints + request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString()); + request.AddHeader("CB-ACCESS-SIGN", signature); + request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp); + if (request.Method == "POST") await CryptoUtility.WriteToRequestAsync(request, body); + } + } - protected internal override async Task> OnGetMarketSymbolsMetadataAsync() - { - var markets = new List(); - JToken products = await MakeJsonRequestAsync("/products"); - foreach (JToken product in products[PRODUCTS]) - { - markets.Add(new ExchangeMarket() - { - MarketSymbol = product[PRODUCTID].ToStringUpperInvariant(), - BaseCurrency = product["base_currency_id"].ToStringUpperInvariant(), - QuoteCurrency = product["quote_currency_id"].ToStringUpperInvariant(), - IsActive = string.Equals(product["status"].ToStringInvariant(), "online", StringComparison.OrdinalIgnoreCase), - MinTradeSize = product["base_min_size"].ConvertInvariant(), - MaxTradeSize = product["base_max_size"].ConvertInvariant(), - PriceStepSize = product["quote_increment"].ConvertInvariant() - }); - } - return markets.OrderBy(market => market.MarketSymbol); // Ordered for Convenience - } + /// + /// Sometimes the Fiat pairs are reported backwards, but Coinbase requires the fiat to be last of the pair + /// Only three Fiat Currencies are supported + /// + /// + /// + public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) + { + if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) + { + var split = marketSymbol.Split(GlobalMarketSymbolSeparator); + marketSymbol = split[1] + GlobalMarketSymbolSeparator + split[0]; + } + return Task.FromResult(marketSymbol); + } - protected override async Task> OnGetMarketSymbolsAsync() - { - return (await GetMarketSymbolsMetadataAsync()).Select(market => market.MarketSymbol); - } + #endregion - protected override async Task> OnGetCurrenciesAsync() - { - var currencies = new Dictionary(); // We could order the return (like Market Symbols are) if we populate as a list then sort and select into a dictionary before return, but is it worth the overhead? + #region GeneralProductEndpoints - // We don't have a currencies endpoint, but we can derive the currencies by splitting the products (includes fiat - filter if you wish) - JToken products = await MakeJsonRequestAsync("/products"); - foreach (JToken product in products[PRODUCTS]) - { - var split = product[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - if (!currencies.ContainsKey(split[0])) - { - var currency = new ExchangeCurrency - { - Name = split[0], - FullName = product["base_name"].ToStringInvariant(), - DepositEnabled = true, - WithdrawalEnabled = true - }; - currencies[currency.Name] = currency; - } - if (!currencies.ContainsKey(split[1])) - { - var currency = new ExchangeCurrency - { - Name = split[1], - FullName = product["quote_name"].ToStringInvariant(), - DepositEnabled = true, - WithdrawalEnabled = true - }; - currencies[currency.Name] = currency; - } - } - return currencies; - } - - protected override async Task>> OnGetTickersAsync() - { - var tickers = new List>(); - JToken books = await MakeJsonRequestAsync("/best_bid_ask"); - var Timestamp = CryptoUtility.ParseTimestamp(books["time"], TimestampType.Iso8601UTC); - foreach (JToken book in books[PRICEBOOKS]) - { - var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - // This endpoint does not provide a last or open for the ExchangeTicker. We might get this from the sockets, but this call is extremely fast? - tickers.Add(new KeyValuePair(book[PRODUCTID].ToString(), new ExchangeTicker() - { - MarketSymbol = book[PRODUCTID].ToString(), - Ask = book[ASKS][0][PRICE].ConvertInvariant(), - Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() - { - BaseCurrency = split[0], - BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), - QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), - Timestamp = Timestamp - } - })); - } - return tickers; - } - - protected override async Task OnGetTickerAsync(string marketSymbol) - { - // Again, me might also get this from the sockets, but this seems preferable for now. - JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol.ToUpperInvariant()); - JToken book = ticker[PRICEBOOKS][0]; - var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - return new ExchangeTicker() - { - MarketSymbol = book[PRODUCTID].ToString(), - Ask = book[ASKS][0][PRICE].ConvertInvariant(), - Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() - { - BaseCurrency = split[0], - BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), - QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), - Timestamp = DateTime.UtcNow - } - }; - } - - protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 50) - { - JToken token = await MakeJsonRequestAsync("/product_book?product_id=" + marketSymbol.ToUpperInvariant() + "&limit=" + maxCount); - ExchangeOrderBook orderBook = new ExchangeOrderBook(); - foreach(JToken bid in token[PRICEBOOK][BIDS]) orderBook.Bids.Add(bid[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = bid[PRICE].ConvertInvariant(), Amount = bid[SIZE].ConvertInvariant() }); - foreach(JToken ask in token[PRICEBOOK][ASKS]) orderBook.Asks.Add(ask[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = ask[PRICE].ConvertInvariant(), Amount = ask[SIZE].ConvertInvariant() }); - return orderBook; - } - - protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = 100) - { - // Limit is required but maxed at 100 with no pagination available. Check Sockets? - limit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; - JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol.ToUpperInvariant() + "/ticker?limit=" + limit); - List tradeList = new List(); - foreach (JToken trade in trades["trades"]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, "side", "time", TimestampType.Iso8601UTC, "trade_id")); - return tradeList; - } - - protected override async Task OnGetHistoricalTradesAsync(Func, bool> callback, string marketSymbol, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) - { - // There is no Historical Trades endpoint. The best we can do is get the last 100 trades and filter. - // Check for this data on the sockets? - var trades = await OnGetRecentTradesAsync(marketSymbol.ToUpperInvariant()); - - if (startDate != null) trades = trades.Where(t => t.Timestamp >= startDate); - if (endDate != null) trades = trades.Where(t => t.Timestamp <= endDate);; - if (limit != null) trades = trades.Take((int)limit); - - callback(trades); - } - - protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) - { - if (endDate == null) endDate = CryptoUtility.UtcNow; - - string granularity = "UNKNOWN_GRANULARITY"; - if (periodSeconds <= 60) { granularity = "ONE_MINUTE"; periodSeconds = 60; } - else if (periodSeconds <= 300) { granularity = "FIVE_MINUTE"; periodSeconds = 300; } - else if (periodSeconds <= 900) { granularity = "FIFTEEN_MINUTE"; periodSeconds = 900; } - else if (periodSeconds <= 1800) { granularity = "THIRTY_MINUTE"; periodSeconds = 1800; } - else if (periodSeconds <= 3600) { granularity = "ONE_HOUR"; periodSeconds = 3600; } - else if (periodSeconds <= 21600) { granularity = "SIX_HOUR"; periodSeconds = 21600; } - else { granularity = "ONE_DAY"; periodSeconds = 86400; } - - // Returned Candle count is restricted to 300 - and they don't paginate this call - // We're going to keep retrieving candles 300 at a time until we get our date range for the granularity - if (startDate == null) startDate = CryptoUtility.UtcNow.AddMinutes(-(periodSeconds * 300)); - if (startDate >= endDate) throw new APIException("Invalid Date Range"); - DateTime RangeStart = (DateTime)startDate, RangeEnd = (DateTime)endDate; - if ((RangeEnd - RangeStart).TotalSeconds / periodSeconds > 300) RangeStart = RangeEnd.AddSeconds(-(periodSeconds * 300)); - - List candles = new List(); - while (true) - { - JToken token = await MakeJsonRequestAsync(string.Format("/products/{0}/candles?start={1}&end={2}&granularity={3}", marketSymbol.ToUpperInvariant(), ((DateTimeOffset)RangeStart).ToUnixTimeSeconds(), ((DateTimeOffset)RangeEnd).ToUnixTimeSeconds(), granularity)); - foreach (JToken candle in token["candles"]) candles.Add(this.ParseCandle(candle, marketSymbol, periodSeconds, "open", "high", "low", "close", "start", TimestampType.UnixSeconds, "volume")); - if (RangeStart > startDate) - { - // For simplicity, we'll go back 300 each iteration and sort/filter date range before return - RangeStart = RangeStart.AddSeconds(-(periodSeconds * 300)); - RangeEnd = RangeEnd.AddSeconds(-(periodSeconds * 300)); - } - else break; - } - return candles.Where(c => c.Timestamp >= startDate).OrderBy(c => c.Timestamp); - } - - - protected override async Task> OnGetFeesAsync() - { - var symbols = await OnGetMarketSymbolsAsync(); - JToken token = await this.MakeJsonRequestAsync("/transaction_summary"); - Dictionary fees = new Dictionary(); - - // We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per symbol. - // Here, we choose taker fee, which is usually higher - decimal makerRate = token["fee_tier"]["taker_fee_rate"].Value(); //percentage between 0 and 1 - - return symbols.Select(symbol => new KeyValuePair(symbol, makerRate)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - - - #endregion - - #region AccountSpecificEndpoints - - // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. - protected override async Task OnGetDepositAddressAsync(string symbol, bool forceRegenerate = false) - { - if (Accounts == null) await GetAmounts(true); // Populate Accounts Cache - if (Accounts.ContainsKey(symbol)) - { - JToken accountWalletAddress = await this.MakeJsonRequestAsync($"/accounts/{Accounts[symbol]}/addresses", BaseURLV2); - return new ExchangeDepositDetails { Address = accountWalletAddress[0]["address"].ToStringInvariant(), Currency = symbol }; // We only support a single Wallet/Address (Coinbase is the only Exchange that has multiple) - } - throw new APIException($"Address not found for {symbol}"); - } - - protected override async Task> OnGetAmountsAsync() - { - return await GetAmounts(false); - } - - protected override async Task> OnGetAmountsAvailableToTradeAsync() - { - return await GetAmounts(true); - } - - // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. - protected override async Task> OnGetWithdrawHistoryAsync(string currency) - { - return await GetTx(true, currency); - } - - // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. - protected override async Task> OnGetDepositHistoryAsync(string currency) - { - return await GetTx(false, currency); - } - - /// - /// WARNING: Only Advanced Trade Open Orders are supported. - /// - /// - /// - protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) - { - List orders = new List(); - // Max return count is 1000 with no pagination available - JToken array = await MakeJsonRequestAsync("/orders/historical/batch?order_status=OPEN" + marketSymbol == null || marketSymbol == string.Empty ? string.Empty : "&product_id=" + marketSymbol ); - foreach (JToken order in array) if (order["type"].ToStringInvariant().Equals(ADVFILL)) orders.Add(ParseOrder(order)); - return orders; - } - - /// - /// WARNING: Only Advanced Trade Completed Orders are supported. - /// - /// - /// - /// - protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) - { - // Legacy Orders may be retrieved using V2 (not implemented here - see GetTx in code below) - List orders = new List(); - pagination = PaginationType.V3Cursor; - string startURL = "/orders/historical/fills"; - - if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol.ToStringUpperInvariant(); - if (afterDate != null) startURL += marketSymbol == null ? "?" : "&" + "start_sequence_timestamp=" + ((DateTimeOffset)afterDate).ToUnixTimeSeconds(); - JToken token = await MakeJsonRequestAsync(startURL); - startURL += marketSymbol == null && afterDate == null ? "?" : "&" + "cursor="; - while(true) - { - foreach (JToken fill in token["fills"]) - { - orders.Add(new ExchangeOrderResult() - { - MarketSymbol = fill[PRODUCTID].ToStringInvariant(), - TradeId = fill["trade_id"].ToStringInvariant(), - OrderId = fill["order_id"].ToStringInvariant(), - OrderDate = fill["trade_time"].ToDateTimeInvariant(), - IsBuy = fill["side"].ToStringInvariant() == "buy", - Amount = fill[SIZE].ConvertInvariant(), - AmountFilled = fill[SIZE].ConvertInvariant(), - Price = fill[PRICE].ConvertInvariant(), - Fees = fill["commission"].ConvertInvariant(), - AveragePrice = fill[PRICE].ConvertInvariant() - }); - } - if (string.IsNullOrEmpty(cursorNext)) break; - token = await MakeJsonRequestAsync(startURL + cursorNext); - } - pagination = PaginationType.None; - return orders; - } - - protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) - { - JToken obj = await MakeJsonRequestAsync("/orders/historical/" + orderId); - return ParseOrder(obj); - } + protected internal override async Task> OnGetMarketSymbolsMetadataAsync() + { + var markets = new List(); + JToken products = await MakeJsonRequestAsync("/products"); + foreach (JToken product in products[PRODUCTS]) + { + markets.Add(new ExchangeMarket + { + MarketSymbol = product[PRODUCTID].ToStringUpperInvariant(), + BaseCurrency = product["base_currency_id"].ToStringUpperInvariant(), + QuoteCurrency = product["quote_currency_id"].ToStringUpperInvariant(), + IsActive = string.Equals(product["status"].ToStringInvariant(), "online", StringComparison.OrdinalIgnoreCase), + MinTradeSize = product["base_min_size"].ConvertInvariant(), + MaxTradeSize = product["base_max_size"].ConvertInvariant(), + PriceStepSize = product["quote_increment"].ConvertInvariant() + }); + } + return markets.OrderBy(market => market.MarketSymbol); // Ordered for Convenience + } - protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) - { - Dictionary payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; - await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); - } + protected override async Task> OnGetMarketSymbolsAsync() + { + return (await GetMarketSymbolsMetadataAsync()).Select(market => market.MarketSymbol); + } - /// - /// This supports two Entries in the Order ExtraParameters: - /// "post_only" : true/false (defaults to false if does not exist) - /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC - /// - /// - /// - protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) - { - Dictionary configuration = new Dictionary(); - switch (order.OrderType) + protected override async Task> OnGetCurrenciesAsync() { - case OrderType.Limit: - if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + var currencies = new Dictionary(); + + // We don't have a currencies endpoint, but we can derive the currencies by splitting the products (includes fiat - filter if you wish) + JToken products = await MakeJsonRequestAsync("/products"); + foreach (JToken product in products[PRODUCTS]) + { + var split = product[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + if (!currencies.ContainsKey(split[0])) { - configuration.Add("limit_limit_gtd", new Dictionary() + var currency = new ExchangeCurrency { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - }); + Name = split[0], + FullName = product["base_name"].ToStringInvariant(), + DepositEnabled = true, + WithdrawalEnabled = true + }; + currencies[currency.Name] = currency; } - else - { - configuration.Add("limit_limit_gtc", new Dictionary() + if (!currencies.ContainsKey(split[1])) + { + var currency = new ExchangeCurrency { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - }); + Name = split[1], + FullName = product["quote_name"].ToStringInvariant(), + DepositEnabled = true, + WithdrawalEnabled = true + }; + currencies[currency.Name] = currency; } - break; - case OrderType.Stop: - if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + } + return currencies; + } + + protected override async Task>> OnGetTickersAsync() + { + var tickers = new List>(); + JToken books = await MakeJsonRequestAsync("/best_bid_ask"); + var Timestamp = CryptoUtility.ParseTimestamp(books["time"], TimestampType.Iso8601UTC); + foreach (JToken book in books[PRICEBOOKS]) + { + var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + // This endpoint does not provide a last or open for the ExchangeTicker + tickers.Add(new KeyValuePair(book[PRODUCTID].ToString(), new ExchangeTicker() { - configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() + MarketSymbol = book[PRODUCTID].ToString(), + Ask = book[ASKS][0][PRICE].ConvertInvariant(), + Bid = book[BIDS][0][PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"stop_price", order.StopPrice.ToStringInvariant() }, - {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? - }); + BaseCurrency = split[0], + BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), + QuoteCurrency = split[1], + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + Timestamp = Timestamp + } + })); + } + return tickers; + } + + protected override async Task OnGetTickerAsync(string marketSymbol) + { + JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol.ToUpperInvariant()); + JToken book = ticker[PRICEBOOKS][0]; + var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + return new ExchangeTicker() + { + MarketSymbol = book[PRODUCTID].ToString(), + Ask = book[ASKS][0][PRICE].ConvertInvariant(), + Bid = book[BIDS][0][PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), + QuoteCurrency = split[1], + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + Timestamp = DateTime.UtcNow } - else + }; + } + + protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 50) + { + JToken token = await MakeJsonRequestAsync("/product_book?product_id=" + marketSymbol.ToUpperInvariant() + "&limit=" + maxCount); + ExchangeOrderBook orderBook = new ExchangeOrderBook(); + foreach(JToken bid in token[PRICEBOOK][BIDS]) orderBook.Bids.Add(bid[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = bid[PRICE].ConvertInvariant(), Amount = bid[SIZE].ConvertInvariant() }); + foreach(JToken ask in token[PRICEBOOK][ASKS]) orderBook.Asks.Add(ask[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = ask[PRICE].ConvertInvariant(), Amount = ask[SIZE].ConvertInvariant() }); + return orderBook; + } + + protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = 100) + { + // Limit is required but maxed at 100 with no pagination available + limit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; + JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol.ToUpperInvariant() + "/ticker?limit=" + limit); + List tradeList = new List(); + foreach (JToken trade in trades["trades"]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, SIDE, "time", TimestampType.Iso8601UTC, "trade_id")); + return tradeList; + } + + protected override async Task OnGetHistoricalTradesAsync(Func, bool> callback, string marketSymbol, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) + { + // There is no Historical Trades endpoint. The best we can do is get the last 100 trades and filter. + // Check for this data on the sockets? + var trades = await OnGetRecentTradesAsync(marketSymbol.ToUpperInvariant()); + + if (startDate != null) trades = trades.Where(t => t.Timestamp >= startDate); + if (endDate != null) trades = trades.Where(t => t.Timestamp <= endDate);; + if (limit != null) trades = trades.Take((int)limit); + + callback(trades); + } + + protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) + { + if (endDate == null) endDate = CryptoUtility.UtcNow; + + string granularity = "UNKNOWN_GRANULARITY"; + if (periodSeconds <= 60) { granularity = "ONE_MINUTE"; periodSeconds = 60; } + else if (periodSeconds <= 300) { granularity = "FIVE_MINUTE"; periodSeconds = 300; } + else if (periodSeconds <= 900) { granularity = "FIFTEEN_MINUTE"; periodSeconds = 900; } + else if (periodSeconds <= 1800) { granularity = "THIRTY_MINUTE"; periodSeconds = 1800; } + else if (periodSeconds <= 3600) { granularity = "ONE_HOUR"; periodSeconds = 3600; } + else if (periodSeconds <= 21600) { granularity = "SIX_HOUR"; periodSeconds = 21600; } + else { granularity = "ONE_DAY"; periodSeconds = 86400; } + + // Returned Candle count is restricted to 300 and they don't paginate this call + // We're going to keep retrieving candles 300 at a time until we get our date range for the granularity + if (startDate == null) startDate = CryptoUtility.UtcNow.AddMinutes(-(periodSeconds * 300)); + if (startDate >= endDate) throw new APIException("Invalid Date Range"); + DateTime RangeStart = (DateTime)startDate, RangeEnd = (DateTime)endDate; + if ((RangeEnd - RangeStart).TotalSeconds / periodSeconds > 300) RangeStart = RangeEnd.AddSeconds(-(periodSeconds * 300)); + + List candles = new List(); + while (true) + { + JToken token = await MakeJsonRequestAsync(string.Format("/products/{0}/candles?start={1}&end={2}&granularity={3}", marketSymbol.ToUpperInvariant(), ((DateTimeOffset)RangeStart).ToUnixTimeSeconds(), ((DateTimeOffset)RangeEnd).ToUnixTimeSeconds(), granularity)); + foreach (JToken candle in token["candles"]) candles.Add(this.ParseCandle(candle, marketSymbol, periodSeconds, "open", "high", "low", "close", "start", TimestampType.UnixSeconds, "volume")); + if (RangeStart > startDate) { - configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() + // For simplicity, we'll go back 300 each iteration and sort/filter date range before return + RangeStart = RangeStart.AddSeconds(-(periodSeconds * 300)); + RangeEnd = RangeEnd.AddSeconds(-(periodSeconds * 300)); + } + else break; + } + return candles.Where(c => c.Timestamp >= startDate).OrderBy(c => c.Timestamp); + } + + + protected override async Task> OnGetFeesAsync() + { + var symbols = await OnGetMarketSymbolsAsync(); + JToken token = await this.MakeJsonRequestAsync("/transaction_summary"); + Dictionary fees = new Dictionary(); + + // We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per market symbol. + // Here, we choose taker fee, which is usually higher + decimal makerRate = token["fee_tier"]["taker_fee_rate"].Value(); //percentage between 0 and 1 + + return symbols.Select(symbol => new KeyValuePair(symbol, makerRate)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + #endregion + + #region AccountSpecificEndpoints + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task OnGetDepositAddressAsync(string symbol, bool forceRegenerate = false) + { + if (Accounts == null) await GetAmounts(true); // Populate Accounts Cache + if (Accounts.ContainsKey(symbol)) + { + JToken accountWalletAddress = await this.MakeJsonRequestAsync($"/accounts/{Accounts[symbol]}/addresses", BaseURLV2); + return new ExchangeDepositDetails { Address = accountWalletAddress[0]["address"].ToStringInvariant(), Currency = symbol }; // We only support a single Wallet/Address (Coinbase is the only Exchange that has multiple) + } + throw new APIException($"Address not found for {symbol}"); + } + + protected override async Task> OnGetAmountsAsync() + { + return await GetAmounts(false); + } + + protected override async Task> OnGetAmountsAvailableToTradeAsync() + { + return await GetAmounts(true); + } + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task> OnGetWithdrawHistoryAsync(string currency) + { + return await GetTx(true, currency); + } + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task> OnGetDepositHistoryAsync(string currency) + { + return await GetTx(false, currency); + } + + /// + /// WARNING: Only Advanced Trade Open Orders are supported. + /// + /// + /// + protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) + { + List orders = new List(); + // Max return count is 1000 with no pagination available + JToken array = await MakeJsonRequestAsync("/orders/historical/batch?order_status=OPEN" + marketSymbol == null || marketSymbol == string.Empty ? string.Empty : "&product_id=" + marketSymbol ); + foreach (JToken order in array) if (order[TYPE].ToStringInvariant().Equals(ADVFILL)) orders.Add(ParseOrder(order)); + return orders; + } + + /// + /// WARNING: Only Advanced Trade Completed Orders are supported. + /// + /// + /// + /// + protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) + { + // Legacy Orders may be retrieved using V2 (not implemented here - see GetTx in code below) + List orders = new List(); + pagination = PaginationType.V3Cursor; + string startURL = "/orders/historical/fills"; + + if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol; + if (afterDate != null) startURL += marketSymbol == null ? "?" : "&" + "start_sequence_timestamp=" + ((DateTimeOffset)afterDate).ToUnixTimeSeconds(); + JToken token = await MakeJsonRequestAsync(startURL); + startURL += marketSymbol == null && afterDate == null ? "?" : "&" + "cursor="; + while(true) + { + foreach (JToken fill in token["fills"]) + { + orders.Add(new ExchangeOrderResult() { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"stop_price", order.StopPrice.ToStringInvariant() }, - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? + MarketSymbol = fill[PRODUCTID].ToStringInvariant(), + TradeId = fill["trade_id"].ToStringInvariant(), + OrderId = fill["order_id"].ToStringInvariant(), + OrderDate = fill["trade_time"].ToDateTimeInvariant(), + IsBuy = fill[SIDE].ToStringInvariant() == BUY, + Amount = fill[SIZE].ConvertInvariant(), + AmountFilled = fill[SIZE].ConvertInvariant(), + Price = fill[PRICE].ConvertInvariant(), + Fees = fill["commission"].ConvertInvariant(), + AveragePrice = fill[PRICE].ConvertInvariant() }); } + if (string.IsNullOrEmpty(cursorNext)) break; + token = await MakeJsonRequestAsync(startURL + cursorNext); + } + pagination = PaginationType.None; + return orders; + } + + protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { + JToken obj = await MakeJsonRequestAsync("/orders/historical/" + orderId); + return ParseOrder(obj); + } + + /// + /// This supports two Entries in the Order ExtraParameters: + /// "post_only" : true/false (defaults to false if does not exist) + /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC + /// + /// + /// + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + Dictionary configuration = new Dictionary(); + switch (order.OrderType) + { + case OrderType.Limit: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + { + configuration.Add("limit_limit_gtd", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + }); + } + else + { + configuration.Add("limit_limit_gtc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + }); + } break; - case OrderType.Market: - configuration.Add("market_market_ioc", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() } - }); + case OrderType.Stop: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + { + configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? + }); + } + else + { + configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? + }); + } break; - } + case OrderType.Market: + configuration.Add("market_market_ioc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() } + }); + break; + } - Dictionary payload = new Dictionary { { "order_configuration", configuration} }; - string side = order.IsBuy ? "buy" : "sell"; - JToken result = await MakeJsonRequestAsync($"/orders?product_id={order.MarketSymbol.ToUpperInvariant()}&side={side}", payload: payload, requestMethod: "POST"); + Dictionary payload = new Dictionary + { + { "order_configuration", configuration} + }; - // We don't have the proper return type for the POST - will probably require a separate parsing function and return Success/Fail - return ParseOrder(result); - } - protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) - { - return base.OnWithdrawAsync(withdrawalRequest); - } + string side = order.IsBuy ? BUY : "sell"; + JToken result = await MakeJsonRequestAsync($"/orders?product_id={order.MarketSymbol.ToUpperInvariant()}&side={side}", payload: payload, requestMethod: "POST"); + // We don't have the proper return type for a successful POST - will probably require a separate parsing function and return Success/Fail + return ParseOrder(result); + } - #endregion + protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { + Dictionary payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; + await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); + } - #region SocketEndpoints + protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) + { + return base.OnWithdrawAsync(withdrawalRequest); + } - protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) - { - return base.OnGetDeltaOrderBookWebSocketAsync(callback); - } + #endregion - protected override async Task OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) - { + #region SocketEndpoints + + protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) + { + return ConnectWebSocketAsync("/", (_socket, msg) => + { + JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); + string type = tokens[EVENTS][0][TYPE].ToStringInvariant(); + if (type.Equals("update") || type.Equals("snapshot")) + { + var book = new ExchangeOrderBook(){ MarketSymbol = tokens[EVENTS][0][PRODUCTID].ToStringInvariant(), LastUpdatedUtc = DateTime.UtcNow, SequenceId = tokens["sequence_num"].ConvertInvariant() }; + int askCount = 0, bidCount = 0; + foreach(var token in tokens[EVENTS][0]["updates"]) + { + if (token[SIDE].ToStringInvariant().Equals("bid")) + { + if (bidCount++ < maxCount) + { + decimal price = token[PRICELEVEL].ConvertInvariant(); + book.Bids.Add( price, new ExchangeOrderPrice(){ Price = price, Amount=token["new_quantity"].ConvertInvariant()} ); + } + } + else + { + if (askCount++ < maxCount) + { + decimal price = token[PRICELEVEL].ConvertInvariant(); + book.Asks.Add( price, new ExchangeOrderPrice(){ Price = price, Amount=token["new_quantity"].ConvertInvariant()} ); + } + } + if (askCount >= maxCount && bidCount >=maxCount) break; + } + callback?.Invoke(book); + } + return Task.CompletedTask; + }, async (_socket) => + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + LEVEL2 + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new + { + type = SUBSCRIBE, + product_ids = marketSymbols, + channel = LEVEL2, + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); + } + + protected override async Task OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) + { return await ConnectWebSocketAsync("/", async (_socket, msg) => { JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); var timestamp = tokens["timestamp"].ConvertInvariant(); List> ticks = new List>(); - foreach(var token in tokens["events"]?[0]?["tickers"]) + foreach(var token in tokens[EVENTS]?[0]?["tickers"]) { - string product = token["product_id"].ToStringInvariant(); + string product = token[PRODUCTID].ToStringInvariant(); var split = product.Split(GlobalMarketSymbolSeparator); ticks.Add(new KeyValuePair(product, new ExchangeTicker() { - // We don't have Bid or Ask info on this feed - ApiResponse = token, - Last = token["price"].ConvertInvariant(), - Volume = new ExchangeVolume() - { - BaseCurrency = split[0], - QuoteCurrency = split[1], - BaseCurrencyVolume = token["volume_24_h"].ConvertInvariant(), - Timestamp = timestamp - } + // We don't have Bid or Ask info on this feed + MarketSymbol = product, + ApiResponse = token, + Exchange = this.Name, + Last = token[PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + QuoteCurrency = split[1], + BaseCurrencyVolume = token["volume_24_h"].ConvertInvariant(), + Timestamp = timestamp + } } )); - } - callback?.Invoke(ticks); - }, async (_socket) => - { - string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); - string signature = CryptoUtility.SHA256Sign(timestamp + "ticker" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); - var subscribeRequest = new + } + callback?.Invoke(ticks); + }, async (_socket) => { - type = "subscribe", - product_ids = marketSymbols, - channel = "ticker", - api_key = PublicApiKey.ToUnsecureString(), - timestamp, - signature - }; - await _socket.SendMessageAsync(subscribeRequest); - }); } + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + TICKER + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new + { + type = SUBSCRIBE, + product_ids = marketSymbols, + channel = TICKER, + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); + } protected override async Task OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) { @@ -571,13 +629,13 @@ protected override async Task OnGetTradesWebSocketAsync(Func { JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); - foreach(var token in tokens["events"]?[0]?["trades"]) + foreach(var token in tokens[EVENTS]?[0]?["trades"]) { - await callback?.Invoke(new KeyValuePair(token["product_id"].ToStringInvariant(), new ExchangeTrade() + callback?.Invoke(new KeyValuePair(token[PRODUCTID].ToStringInvariant(), new ExchangeTrade() { - Amount = token["size"].ConvertInvariant(), - Price = token["price"].ConvertInvariant(), - IsBuy = token["side"].ToStringInvariant().Equals("buy"), + Amount = token[SIZE].ConvertInvariant(), + Price = token[PRICE].ConvertInvariant(), + IsBuy = token[SIDE].ToStringInvariant().Equals(BUY), Id = token["trade_id"].ToStringInvariant(), Timestamp = token["time"].ConvertInvariant() })); @@ -585,185 +643,183 @@ protected override async Task OnGetTradesWebSocketAsync(Func { string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); - string signature = CryptoUtility.SHA256Sign(timestamp + "market_trades" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + string signature = CryptoUtility.SHA256Sign(timestamp + MARKETTRADES + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); var subscribeRequest = new { - type = "subscribe", + type = SUBSCRIBE, product_ids = marketSymbols, - channel = "market_trades", + channel = MARKETTRADES, api_key = PublicApiKey.ToUnsecureString(), timestamp, signature }; await _socket.SendMessageAsync(subscribeRequest); - }); - } + }); + } - #endregion + #endregion #region PrivateFunctions - private async Task> GetAmounts(bool AvailableOnly) - { - Accounts ??= new Dictionary(); // This function is the only place where Accounts cache is populated + private async Task> GetAmounts(bool AvailableOnly) + { + Accounts ??= new Dictionary(); // This function is the only place where Accounts cache is populated - Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - pagination = PaginationType.V3; - JToken token = await MakeJsonRequestAsync("/accounts"); - while(true) - { - foreach (JToken account in token["accounts"]) - { - Accounts[account[CURRENCY].ToString()] = account["uuid"].ToString(); // populate Accounts cache as we go - decimal amount = AvailableOnly ? account["available_balance"][VALUE].ConvertInvariant() : account["available_balance"][VALUE].ConvertInvariant() + account["hold"][VALUE].ConvertInvariant(); - if (amount > 0.0m) amounts[account[CURRENCY].ToStringInvariant()] = amount; - } - if (cursorNext == null) break; - token = await MakeJsonRequestAsync("/accounts?starting_after=" + cursorNext); - } - pagination = PaginationType.None; - return amounts; - } - - /// - /// Warning: This call uses V2 Transactions - /// - /// - /// - /// - private async Task> GetTx(bool Withdrawals, string currency) - { - if (Accounts == null) await GetAmounts(true); - pagination = PaginationType.V2; - List transfers = new List(); - JToken tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions", BaseURLV2); - while(true) - { - foreach (JToken token in tokens) - { - // A "send" to Coinbase is when someone "sent" you coin - or a receive to the rest of the world - // Likewise, a "receive" is when someone "received" coin from you. In other words, it's back-asswards. - if (!Withdrawals && token["type"].ToStringInvariant().Equals("send")) transfers.Add(ParseTransaction(token)); - else if (Withdrawals && token["type"].ToStringInvariant().Equals("receive")) transfers.Add(ParseTransaction(token)); - - // Legacy Order and other Coinbase Tx Types can be parsed using this V2 code block - //var tmp = ParseOrder(token); - } - if (string.IsNullOrEmpty(cursorNext)) break; - tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions?starting_after={cursorNext}", BaseURLV2); - } - pagination = PaginationType.None; - return transfers; - } - - /// - /// Parse V2 Transaction of type of either "Send" or "Receive" - /// - /// - /// - private ExchangeTransaction ParseTransaction(JToken token) - { - // The Coin Address/TxFee isn't available but can be retrieved using the Network Hash/BlockChainId - return new ExchangeTransaction() - { - PaymentId = token["id"].ToStringInvariant(), // Not sure how this is used elsewhere but here it is the Coinbase TransactionID - BlockchainTxId = token["network"]["hash"].ToStringInvariant(), - Currency = token[AMOUNT][CURRENCY].ToStringInvariant(), - Amount = token[AMOUNT][AMOUNT].ConvertInvariant(), - Timestamp = token["created_at"].ToObject(), - Status = token["status"].ToStringInvariant() == "completed" ? TransactionStatus.Complete : TransactionStatus.Unknown, - Notes = token["description"].ToStringInvariant() - // Address - // AddressTag - // TxFee - }; - } - - - /// - /// Parse both Advanced Trade and Legacy Transactions - /// - /// - /// - private ExchangeOrderResult ParseOrder(JToken result) - { - decimal amount = 0, amountFilled = 0, price = 0, fees = 0; - string marketSymbol = string.Empty; - bool isBuy = true; - - //Debug.WriteLine(result["type"].ToStringInvariant()); - switch(result["type"].ToStringInvariant()) - { - case ADVFILL: - // Buys/Sells have reversed amounts? - - - break; - case "send": - case "receive": - return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; - case "buy": - case "sell": - case "trade": - case "request": - case "transfer": - - case "exchange_deposit": - case "fiat_deposit": - case "fiat_withdrawal": - case "pro_withdrawal": - case "vault_withdrawal": - default: - return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; - } + Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + pagination = PaginationType.V3; + JToken token = await MakeJsonRequestAsync("/accounts"); + while(true) + { + foreach (JToken account in token["accounts"]) + { + Accounts[account[CURRENCY].ToString()] = account["uuid"].ToString(); // populate Accounts cache as we go + decimal amount = AvailableOnly ? account["available_balance"][VALUE].ConvertInvariant() : account["available_balance"][VALUE].ConvertInvariant() + account["hold"][VALUE].ConvertInvariant(); + if (amount > 0.0m) amounts[account[CURRENCY].ToStringInvariant()] = amount; + } + if (string.IsNullOrEmpty(cursorNext)) break; + token = await MakeJsonRequestAsync("/accounts?starting_after=" + cursorNext); + } + pagination = PaginationType.None; + return amounts; + } - amount = result[AMOUNT][AMOUNT].ConvertInvariant(amountFilled); - amountFilled = amount; + /// + /// Warning: This call uses V2 Transactions + /// + /// + /// + /// + private async Task> GetTx(bool Withdrawals, string currency) + { + if (Accounts == null) await GetAmounts(true); + pagination = PaginationType.V2; + List transfers = new List(); + JToken tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions", BaseURLV2); + while(true) + { + foreach (JToken token in tokens) + { + // A "send" to Coinbase is when someone "sent" you coin - or a receive to the rest of the world + // Likewise, a "receive" is when someone "received" coin from you. In other words, it's back-asswards. + if (!Withdrawals && token[TYPE].ToStringInvariant().Equals("send")) transfers.Add(ParseTransaction(token)); + else if (Withdrawals && token[TYPE].ToStringInvariant().Equals("receive")) transfers.Add(ParseTransaction(token)); - price = result[ADVFILL]["fill_price"].ConvertInvariant(); - fees = result[ADVFILL]["commission"].ConvertInvariant(); - marketSymbol = result[ADVFILL][PRODUCTID].ToStringInvariant(result["id"].ToStringInvariant()); - isBuy = (result[ADVFILL]["order_side"].ToStringInvariant() == "buy"); + // Legacy Order and other Coinbase Tx Types can be parsed using this V2 code + //var tmp = ParseOrder(token); + } + if (string.IsNullOrEmpty(cursorNext)) break; + tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions?starting_after={cursorNext}", BaseURLV2); + } + pagination = PaginationType.None; + return transfers; + } - ExchangeOrderResult order = new ExchangeOrderResult() - { - IsBuy = isBuy, - Amount = amount, - AmountFilled = amountFilled, - Price = price, - Fees = fees, - FeesCurrency = result["native_amount"]["currency"].ToStringInvariant(), - OrderDate = result["created_at"].ToDateTimeInvariant(), - CompletedDate = result["updated_at"].ToDateTimeInvariant(), - MarketSymbol = marketSymbol, - OrderId = result["id"].ToStringInvariant(), - Message = result["type"].ToStringInvariant() - }; - - switch (result["status"].ToStringInvariant()) - { - case "completed": - order.Result = ExchangeAPIOrderResult.Filled; - break; - case "waiting_for_clearing": - case "waiting_for_signature": - case "pending": - order.Result = ExchangeAPIOrderResult.PendingOpen; - break; - case "expired": - case "canceled": - order.Result = ExchangeAPIOrderResult.Canceled; - break; - default: - order.Result = ExchangeAPIOrderResult.Unknown; - break; - } - return order; - } + /// + /// Parse V2 Transaction of type of either "Send" or "Receive" + /// + /// + /// + private ExchangeTransaction ParseTransaction(JToken token) + { + // The Coin Address/TxFee isn't available but can be retrieved using the Network Hash/BlockChainId + return new ExchangeTransaction() + { + PaymentId = token["id"].ToStringInvariant(), // Not sure how this is used elsewhere but here it is the Coinbase TransactionID + BlockchainTxId = token["network"]["hash"].ToStringInvariant(), + Currency = token[AMOUNT][CURRENCY].ToStringInvariant(), + Amount = token[AMOUNT][AMOUNT].ConvertInvariant(), + Timestamp = token["created_at"].ToObject(), + Status = token["status"].ToStringInvariant() == "completed" ? TransactionStatus.Complete : TransactionStatus.Unknown, + Notes = token["description"].ToStringInvariant() + // Address + // AddressTag + // TxFee + }; + } - #endregion - } + /// + /// Parse both Advanced Trade and Legacy Transactions + /// + /// + /// + private ExchangeOrderResult ParseOrder(JToken result) + { + decimal amount = 0, amountFilled = 0, price = 0, fees = 0; + string marketSymbol = string.Empty; + bool isBuy = true; - public partial class ExchangeName { public const string Coinbase = "Coinbase"; } + //Debug.WriteLine(result["type"].ToStringInvariant()); + switch(result[TYPE].ToStringInvariant()) + { + case ADVFILL: + // Buys/Sells have reversed amounts? + break; + case BUY: + case "sell": + case "send": + case "receive": + return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; + case "trade": + case "request": + case "transfer": + + case "exchange_deposit": + case "fiat_deposit": + case "fiat_withdrawal": + case "pro_withdrawal": + case "vault_withdrawal": + default: + return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; + } + + amount = result[AMOUNT][AMOUNT].ConvertInvariant(amountFilled); + amountFilled = amount; + + price = result[ADVFILL]["fill_price"].ConvertInvariant(); + fees = result[ADVFILL]["commission"].ConvertInvariant(); + marketSymbol = result[ADVFILL][PRODUCTID].ToStringInvariant(result["id"].ToStringInvariant()); + isBuy = (result[ADVFILL]["order_side"].ToStringInvariant() == BUY); + + ExchangeOrderResult order = new ExchangeOrderResult() + { + IsBuy = isBuy, + Amount = amount, + AmountFilled = amountFilled, + Price = price, + Fees = fees, + FeesCurrency = result["native_amount"][CURRENCY].ToStringInvariant(), + OrderDate = result["created_at"].ToDateTimeInvariant(), + CompletedDate = result["updated_at"].ToDateTimeInvariant(), + MarketSymbol = marketSymbol, + OrderId = result["id"].ToStringInvariant(), + Message = result[TYPE].ToStringInvariant() + }; + + switch (result["status"].ToStringInvariant()) + { + case "completed": + order.Result = ExchangeAPIOrderResult.Filled; + break; + case "waiting_for_clearing": + case "waiting_for_signature": + case "pending": + order.Result = ExchangeAPIOrderResult.PendingOpen; + break; + case "expired": + case "canceled": + order.Result = ExchangeAPIOrderResult.Canceled; + break; + default: + order.Result = ExchangeAPIOrderResult.Unknown; + break; + } + return order; + } + + #endregion + + } } + +public partial class ExchangeName { public const string Coinbase = "Coinbase"; } From ee7f1842b9c326487833af99b25ca8fc290a68a0 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Tue, 5 Dec 2023 05:02:03 -0500 Subject: [PATCH 08/10] Revert "Final WebSockets Updated" This reverts commit f7a888997e7997a144adaaa9e2d03b44f3601e06. --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 1364 ++++++++--------- 1 file changed, 654 insertions(+), 710 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index 41fafc0a..e494523c 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -10,6 +10,7 @@ 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. */ + using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -19,68 +20,59 @@ The above copyright notice and this permission notice shall be included in all c namespace ExchangeSharp { - - /// - /// Warning: This API now uses Coinbase Advanced Trade V2/V3. - /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site. - /// These keys must be set before using the Coinbase API (sorry). - /// + /// + /// Warning: This API now uses Coinbase Advanced Trade V2/V3. + /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site. + /// These keys must be set before using the Coinbase API (sorry). + /// public sealed class ExchangeCoinbaseAPI : ExchangeAPI - { - private const string ADVFILL = "advanced_trade_fill"; - private const string CURRENCY = "currency"; - private const string PRODUCTID = "product_id"; - private const string PRODUCTS = "products"; - private const string PRICEBOOK = "pricebook"; - private const string PRICEBOOKS = "pricebooks"; - private const string ASKS = "asks"; - private const string BIDS = "bids"; - private const string PRICE = "price"; - private const string AMOUNT = "amount"; - private const string VALUE = "value"; - private const string SIZE = "size"; - private const string CURSOR = "cursor"; - private const string TYPE = "type"; - private const string SUBSCRIBE = "subscribe"; - private const string MARKETTRADES = "market_trades"; - private const string TICKER = "ticker"; - private const string EVENTS = "events"; - private const string LEVEL2 = "level2"; - private const string PRICELEVEL = "price_level"; - private const string SIDE = "side"; - private const string BUY = "buy"; - - public override string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage"; - private readonly string BaseURLV2 = "https://api.coinbase.com/v2"; // For Wallet Support - public override string BaseUrlWebSocket { get; set; } = "wss://advanced-trade-ws.coinbase.com"; + { + private const string ADVFILL = "advanced_trade_fill"; + private const string CURRENCY = "currency"; + private const string PRODUCTID = "product_id"; + private const string PRODUCTS = "products"; + private const string PRICEBOOK = "pricebook"; + private const string PRICEBOOKS = "pricebooks"; + private const string ASKS = "asks"; + private const string BIDS = "bids"; + private const string PRICE = "price"; + private const string AMOUNT = "amount"; + private const string VALUE = "value"; + private const string SIZE = "size"; + private const string CURSOR = "cursor"; + + + public override string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage"; + private readonly string BaseURLV2 = "https://api.coinbase.com/v2"; // For Wallet Support + public override string BaseUrlWebSocket { get; set; } = "wss://advanced-trade-ws.coinbase.com"; - private enum PaginationType { None, V2, V3, V3Cursor} - private PaginationType pagination = PaginationType.None; - private string cursorNext; - - private Dictionary Accounts = null; // Cached Account IDs - - private ExchangeCoinbaseAPI() - { - MarketSymbolIsReversed = false; - RequestContentType = "application/json"; - NonceStyle = NonceStyle.None; - WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas; - RateLimit = new RateGate(30, TimeSpan.FromSeconds(1)); - base.RequestMaker.RequestStateChanged = ProcessResponse; - } - - /// - /// This is used to capture Pagination instead of overriding the ProcessResponse - /// because the Pagination info is no longer in the Headers and ProcessResponse does not return the required Content - /// - /// - /// - /// - private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) - { - // We can bypass serialization if we already know the last call isn't paginated - if (state == RequestMakerState.Finished && pagination != PaginationType.None) + private enum PaginationType { None, V2, V3, V3Cursor} + private PaginationType pagination = PaginationType.None; + private string cursorNext; + + private Dictionary Accounts = null; // Cached Account IDs + + private ExchangeCoinbaseAPI() + { + MarketSymbolIsReversed = false; + RequestContentType = "application/json"; + NonceStyle = NonceStyle.None; + WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas; + RateLimit = new RateGate(10, TimeSpan.FromSeconds(1)); + base.RequestMaker.RequestStateChanged = ProcessResponse; + } + + /// + /// This is used to capture Pagination instead of overriding the ProcessResponse + /// because the Pagination info is no longer in the Headers and ProcessResponse does not return the required Content + /// + /// + /// + /// + private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) + { + // We can bypass serialization if we already know the last call isn't paginated + if (state == RequestMakerState.Finished && pagination != PaginationType.None) { cursorNext = null; JToken token = JsonConvert.DeserializeObject((string)response); @@ -92,536 +84,486 @@ private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, ob case PaginationType.V3Cursor: cursorNext = token[CURSOR]?.ToStringInvariant(); break; // Only used for V3 Fills - go figure. } } - } + } + + #region BaseOverrides + + /// + /// Overridden because we no longer need a nonce in the payload and passphrase is no longer used + /// + /// + /// + protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) + { + return (PrivateApiKey != null && PublicApiKey != null); + } + + /// + /// Sometimes the Fiat pairs are reported backwards, but Coinbase requires the fiat to be last of the pair + /// Only three Fiat Currencies are supported + /// + /// + /// + public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) + { + if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) + { + var split = marketSymbol.Split(GlobalMarketSymbolSeparator); + return Task.FromResult(split[1] + GlobalMarketSymbolSeparator + split[0]); + } + else return Task.FromResult(marketSymbol); + } - #region BaseOverrides + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { + if (CanMakeAuthenticatedRequest(payload)) + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); // If you're skittish about the local clock, you may retrieve the timestamp from the Coinbase Site + string body = CryptoUtility.GetJsonForPayload(payload); - /// - /// Overridden because we no longer need a nonce in the payload and passphrase is no longer used - /// - /// - /// - protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) - { - return (PrivateApiKey != null && PublicApiKey != null); - } + // V2 wants PathAndQuery, V3 wants LocalPath for the sig + string path = request.RequestUri.AbsoluteUri.StartsWith(BaseURLV2) ? request.RequestUri.PathAndQuery : request.RequestUri.LocalPath; + string signature = CryptoUtility.SHA256Sign(timestamp + request.Method.ToUpperInvariant() + path + body, PrivateApiKey.ToUnsecureString()); - protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) - { - if (CanMakeAuthenticatedRequest(payload)) - { - string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); // If you're skittish about the local clock, you may retrieve the timestamp from the Coinbase Site - string body = CryptoUtility.GetJsonForPayload(payload); + request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString()); + request.AddHeader("CB-ACCESS-SIGN", signature); + request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp); + if (request.Method == "POST") await CryptoUtility.WriteToRequestAsync(request, body); + } + } - // V2 wants PathAndQuery, V3 wants LocalPath for the sig (I guess they wanted to shave a nano-second or two - silly) - string path = request.RequestUri.AbsoluteUri.StartsWith(BaseURLV2) ? request.RequestUri.PathAndQuery : request.RequestUri.LocalPath; - string signature = CryptoUtility.SHA256Sign(timestamp + request.Method.ToUpperInvariant() + path + body, PrivateApiKey.ToUnsecureString()); + #endregion - request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString()); - request.AddHeader("CB-ACCESS-SIGN", signature); - request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp); - if (request.Method == "POST") await CryptoUtility.WriteToRequestAsync(request, body); - } - } + #region GeneralProductEndpoints - /// - /// Sometimes the Fiat pairs are reported backwards, but Coinbase requires the fiat to be last of the pair - /// Only three Fiat Currencies are supported - /// - /// - /// - public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) - { - if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) - { - var split = marketSymbol.Split(GlobalMarketSymbolSeparator); - marketSymbol = split[1] + GlobalMarketSymbolSeparator + split[0]; - } - return Task.FromResult(marketSymbol); - } + protected internal override async Task> OnGetMarketSymbolsMetadataAsync() + { + var markets = new List(); + JToken products = await MakeJsonRequestAsync("/products"); + foreach (JToken product in products[PRODUCTS]) + { + markets.Add(new ExchangeMarket() + { + MarketSymbol = product[PRODUCTID].ToStringUpperInvariant(), + BaseCurrency = product["base_currency_id"].ToStringUpperInvariant(), + QuoteCurrency = product["quote_currency_id"].ToStringUpperInvariant(), + IsActive = string.Equals(product["status"].ToStringInvariant(), "online", StringComparison.OrdinalIgnoreCase), + MinTradeSize = product["base_min_size"].ConvertInvariant(), + MaxTradeSize = product["base_max_size"].ConvertInvariant(), + PriceStepSize = product["quote_increment"].ConvertInvariant() + }); + } + return markets.OrderBy(market => market.MarketSymbol); // Ordered for Convenience + } - #endregion + protected override async Task> OnGetMarketSymbolsAsync() + { + return (await GetMarketSymbolsMetadataAsync()).Select(market => market.MarketSymbol); + } - #region GeneralProductEndpoints + protected override async Task> OnGetCurrenciesAsync() + { + var currencies = new Dictionary(); // We could order the return (like Market Symbols are) if we populate as a list then sort and select into a dictionary before return, but is it worth the overhead? - protected internal override async Task> OnGetMarketSymbolsMetadataAsync() - { - var markets = new List(); - JToken products = await MakeJsonRequestAsync("/products"); - foreach (JToken product in products[PRODUCTS]) - { - markets.Add(new ExchangeMarket - { - MarketSymbol = product[PRODUCTID].ToStringUpperInvariant(), - BaseCurrency = product["base_currency_id"].ToStringUpperInvariant(), - QuoteCurrency = product["quote_currency_id"].ToStringUpperInvariant(), - IsActive = string.Equals(product["status"].ToStringInvariant(), "online", StringComparison.OrdinalIgnoreCase), - MinTradeSize = product["base_min_size"].ConvertInvariant(), - MaxTradeSize = product["base_max_size"].ConvertInvariant(), - PriceStepSize = product["quote_increment"].ConvertInvariant() - }); - } - return markets.OrderBy(market => market.MarketSymbol); // Ordered for Convenience - } + // We don't have a currencies endpoint, but we can derive the currencies by splitting the products (includes fiat - filter if you wish) + JToken products = await MakeJsonRequestAsync("/products"); + foreach (JToken product in products[PRODUCTS]) + { + var split = product[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + if (!currencies.ContainsKey(split[0])) + { + var currency = new ExchangeCurrency + { + Name = split[0], + FullName = product["base_name"].ToStringInvariant(), + DepositEnabled = true, + WithdrawalEnabled = true + }; + currencies[currency.Name] = currency; + } + if (!currencies.ContainsKey(split[1])) + { + var currency = new ExchangeCurrency + { + Name = split[1], + FullName = product["quote_name"].ToStringInvariant(), + DepositEnabled = true, + WithdrawalEnabled = true + }; + currencies[currency.Name] = currency; + } + } + return currencies; + } + + protected override async Task>> OnGetTickersAsync() + { + var tickers = new List>(); + JToken books = await MakeJsonRequestAsync("/best_bid_ask"); + var Timestamp = CryptoUtility.ParseTimestamp(books["time"], TimestampType.Iso8601UTC); + foreach (JToken book in books[PRICEBOOKS]) + { + var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + // This endpoint does not provide a last or open for the ExchangeTicker. We might get this from the sockets, but this call is extremely fast? + tickers.Add(new KeyValuePair(book[PRODUCTID].ToString(), new ExchangeTicker() + { + MarketSymbol = book[PRODUCTID].ToString(), + Ask = book[ASKS][0][PRICE].ConvertInvariant(), + Bid = book[BIDS][0][PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), + QuoteCurrency = split[1], + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + Timestamp = Timestamp + } + })); + } + return tickers; + } + + protected override async Task OnGetTickerAsync(string marketSymbol) + { + // Again, me might also get this from the sockets, but this seems preferable for now. + JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol.ToUpperInvariant()); + JToken book = ticker[PRICEBOOKS][0]; + var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); + return new ExchangeTicker() + { + MarketSymbol = book[PRODUCTID].ToString(), + Ask = book[ASKS][0][PRICE].ConvertInvariant(), + Bid = book[BIDS][0][PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), + QuoteCurrency = split[1], + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + Timestamp = DateTime.UtcNow + } + }; + } + + protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 50) + { + JToken token = await MakeJsonRequestAsync("/product_book?product_id=" + marketSymbol.ToUpperInvariant() + "&limit=" + maxCount); + ExchangeOrderBook orderBook = new ExchangeOrderBook(); + foreach(JToken bid in token[PRICEBOOK][BIDS]) orderBook.Bids.Add(bid[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = bid[PRICE].ConvertInvariant(), Amount = bid[SIZE].ConvertInvariant() }); + foreach(JToken ask in token[PRICEBOOK][ASKS]) orderBook.Asks.Add(ask[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = ask[PRICE].ConvertInvariant(), Amount = ask[SIZE].ConvertInvariant() }); + return orderBook; + } + + protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = 100) + { + // Limit is required but maxed at 100 with no pagination available. Check Sockets? + limit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; + JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol.ToUpperInvariant() + "/ticker?limit=" + limit); + List tradeList = new List(); + foreach (JToken trade in trades["trades"]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, "side", "time", TimestampType.Iso8601UTC, "trade_id")); + return tradeList; + } + + protected override async Task OnGetHistoricalTradesAsync(Func, bool> callback, string marketSymbol, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) + { + // There is no Historical Trades endpoint. The best we can do is get the last 100 trades and filter. + // Check for this data on the sockets? + var trades = await OnGetRecentTradesAsync(marketSymbol.ToUpperInvariant()); + + if (startDate != null) trades = trades.Where(t => t.Timestamp >= startDate); + if (endDate != null) trades = trades.Where(t => t.Timestamp <= endDate);; + if (limit != null) trades = trades.Take((int)limit); + + callback(trades); + } + + protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) + { + if (endDate == null) endDate = CryptoUtility.UtcNow; + + string granularity = "UNKNOWN_GRANULARITY"; + if (periodSeconds <= 60) { granularity = "ONE_MINUTE"; periodSeconds = 60; } + else if (periodSeconds <= 300) { granularity = "FIVE_MINUTE"; periodSeconds = 300; } + else if (periodSeconds <= 900) { granularity = "FIFTEEN_MINUTE"; periodSeconds = 900; } + else if (periodSeconds <= 1800) { granularity = "THIRTY_MINUTE"; periodSeconds = 1800; } + else if (periodSeconds <= 3600) { granularity = "ONE_HOUR"; periodSeconds = 3600; } + else if (periodSeconds <= 21600) { granularity = "SIX_HOUR"; periodSeconds = 21600; } + else { granularity = "ONE_DAY"; periodSeconds = 86400; } + + // Returned Candle count is restricted to 300 - and they don't paginate this call + // We're going to keep retrieving candles 300 at a time until we get our date range for the granularity + if (startDate == null) startDate = CryptoUtility.UtcNow.AddMinutes(-(periodSeconds * 300)); + if (startDate >= endDate) throw new APIException("Invalid Date Range"); + DateTime RangeStart = (DateTime)startDate, RangeEnd = (DateTime)endDate; + if ((RangeEnd - RangeStart).TotalSeconds / periodSeconds > 300) RangeStart = RangeEnd.AddSeconds(-(periodSeconds * 300)); + + List candles = new List(); + while (true) + { + JToken token = await MakeJsonRequestAsync(string.Format("/products/{0}/candles?start={1}&end={2}&granularity={3}", marketSymbol.ToUpperInvariant(), ((DateTimeOffset)RangeStart).ToUnixTimeSeconds(), ((DateTimeOffset)RangeEnd).ToUnixTimeSeconds(), granularity)); + foreach (JToken candle in token["candles"]) candles.Add(this.ParseCandle(candle, marketSymbol, periodSeconds, "open", "high", "low", "close", "start", TimestampType.UnixSeconds, "volume")); + if (RangeStart > startDate) + { + // For simplicity, we'll go back 300 each iteration and sort/filter date range before return + RangeStart = RangeStart.AddSeconds(-(periodSeconds * 300)); + RangeEnd = RangeEnd.AddSeconds(-(periodSeconds * 300)); + } + else break; + } + return candles.Where(c => c.Timestamp >= startDate).OrderBy(c => c.Timestamp); + } + + + protected override async Task> OnGetFeesAsync() + { + var symbols = await OnGetMarketSymbolsAsync(); + JToken token = await this.MakeJsonRequestAsync("/transaction_summary"); + Dictionary fees = new Dictionary(); + + // We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per symbol. + // Here, we choose taker fee, which is usually higher + decimal makerRate = token["fee_tier"]["taker_fee_rate"].Value(); //percentage between 0 and 1 + + return symbols.Select(symbol => new KeyValuePair(symbol, makerRate)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + + #endregion + + #region AccountSpecificEndpoints + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task OnGetDepositAddressAsync(string symbol, bool forceRegenerate = false) + { + if (Accounts == null) await GetAmounts(true); // Populate Accounts Cache + if (Accounts.ContainsKey(symbol)) + { + JToken accountWalletAddress = await this.MakeJsonRequestAsync($"/accounts/{Accounts[symbol]}/addresses", BaseURLV2); + return new ExchangeDepositDetails { Address = accountWalletAddress[0]["address"].ToStringInvariant(), Currency = symbol }; // We only support a single Wallet/Address (Coinbase is the only Exchange that has multiple) + } + throw new APIException($"Address not found for {symbol}"); + } + + protected override async Task> OnGetAmountsAsync() + { + return await GetAmounts(false); + } + + protected override async Task> OnGetAmountsAvailableToTradeAsync() + { + return await GetAmounts(true); + } + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task> OnGetWithdrawHistoryAsync(string currency) + { + return await GetTx(true, currency); + } + + // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. + protected override async Task> OnGetDepositHistoryAsync(string currency) + { + return await GetTx(false, currency); + } + + /// + /// WARNING: Only Advanced Trade Open Orders are supported. + /// + /// + /// + protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) + { + List orders = new List(); + // Max return count is 1000 with no pagination available + JToken array = await MakeJsonRequestAsync("/orders/historical/batch?order_status=OPEN" + marketSymbol == null || marketSymbol == string.Empty ? string.Empty : "&product_id=" + marketSymbol ); + foreach (JToken order in array) if (order["type"].ToStringInvariant().Equals(ADVFILL)) orders.Add(ParseOrder(order)); + return orders; + } + + /// + /// WARNING: Only Advanced Trade Completed Orders are supported. + /// + /// + /// + /// + protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) + { + // Legacy Orders may be retrieved using V2 (not implemented here - see GetTx in code below) + List orders = new List(); + pagination = PaginationType.V3Cursor; + string startURL = "/orders/historical/fills"; + + if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol.ToStringUpperInvariant(); + if (afterDate != null) startURL += marketSymbol == null ? "?" : "&" + "start_sequence_timestamp=" + ((DateTimeOffset)afterDate).ToUnixTimeSeconds(); + JToken token = await MakeJsonRequestAsync(startURL); + startURL += marketSymbol == null && afterDate == null ? "?" : "&" + "cursor="; + while(true) + { + foreach (JToken fill in token["fills"]) + { + orders.Add(new ExchangeOrderResult() + { + MarketSymbol = fill[PRODUCTID].ToStringInvariant(), + TradeId = fill["trade_id"].ToStringInvariant(), + OrderId = fill["order_id"].ToStringInvariant(), + OrderDate = fill["trade_time"].ToDateTimeInvariant(), + IsBuy = fill["side"].ToStringInvariant() == "buy", + Amount = fill[SIZE].ConvertInvariant(), + AmountFilled = fill[SIZE].ConvertInvariant(), + Price = fill[PRICE].ConvertInvariant(), + Fees = fill["commission"].ConvertInvariant(), + AveragePrice = fill[PRICE].ConvertInvariant() + }); + } + if (string.IsNullOrEmpty(cursorNext)) break; + token = await MakeJsonRequestAsync(startURL + cursorNext); + } + pagination = PaginationType.None; + return orders; + } + + protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { + JToken obj = await MakeJsonRequestAsync("/orders/historical/" + orderId); + return ParseOrder(obj); + } - protected override async Task> OnGetMarketSymbolsAsync() - { - return (await GetMarketSymbolsMetadataAsync()).Select(market => market.MarketSymbol); - } + protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { + Dictionary payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; + await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); + } - protected override async Task> OnGetCurrenciesAsync() + /// + /// This supports two Entries in the Order ExtraParameters: + /// "post_only" : true/false (defaults to false if does not exist) + /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC + /// + /// + /// + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + Dictionary configuration = new Dictionary(); + switch (order.OrderType) { - var currencies = new Dictionary(); - - // We don't have a currencies endpoint, but we can derive the currencies by splitting the products (includes fiat - filter if you wish) - JToken products = await MakeJsonRequestAsync("/products"); - foreach (JToken product in products[PRODUCTS]) - { - var split = product[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - if (!currencies.ContainsKey(split[0])) + case OrderType.Limit: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) { - var currency = new ExchangeCurrency + configuration.Add("limit_limit_gtd", new Dictionary() { - Name = split[0], - FullName = product["base_name"].ToStringInvariant(), - DepositEnabled = true, - WithdrawalEnabled = true - }; - currencies[currency.Name] = currency; + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + }); } - if (!currencies.ContainsKey(split[1])) - { - var currency = new ExchangeCurrency + else + { + configuration.Add("limit_limit_gtc", new Dictionary() { - Name = split[1], - FullName = product["quote_name"].ToStringInvariant(), - DepositEnabled = true, - WithdrawalEnabled = true - }; - currencies[currency.Name] = currency; + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + }); } - } - return currencies; - } - - protected override async Task>> OnGetTickersAsync() - { - var tickers = new List>(); - JToken books = await MakeJsonRequestAsync("/best_bid_ask"); - var Timestamp = CryptoUtility.ParseTimestamp(books["time"], TimestampType.Iso8601UTC); - foreach (JToken book in books[PRICEBOOKS]) - { - var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - // This endpoint does not provide a last or open for the ExchangeTicker - tickers.Add(new KeyValuePair(book[PRODUCTID].ToString(), new ExchangeTicker() + break; + case OrderType.Stop: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) { - MarketSymbol = book[PRODUCTID].ToString(), - Ask = book[ASKS][0][PRICE].ConvertInvariant(), - Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() + configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() { - BaseCurrency = split[0], - BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), - QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), - Timestamp = Timestamp - } - })); - } - return tickers; - } - - protected override async Task OnGetTickerAsync(string marketSymbol) - { - JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol.ToUpperInvariant()); - JToken book = ticker[PRICEBOOKS][0]; - var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - return new ExchangeTicker() - { - MarketSymbol = book[PRODUCTID].ToString(), - Ask = book[ASKS][0][PRICE].ConvertInvariant(), - Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() - { - BaseCurrency = split[0], - BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), - QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), - Timestamp = DateTime.UtcNow + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? + }); } - }; - } - - protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 50) - { - JToken token = await MakeJsonRequestAsync("/product_book?product_id=" + marketSymbol.ToUpperInvariant() + "&limit=" + maxCount); - ExchangeOrderBook orderBook = new ExchangeOrderBook(); - foreach(JToken bid in token[PRICEBOOK][BIDS]) orderBook.Bids.Add(bid[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = bid[PRICE].ConvertInvariant(), Amount = bid[SIZE].ConvertInvariant() }); - foreach(JToken ask in token[PRICEBOOK][ASKS]) orderBook.Asks.Add(ask[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = ask[PRICE].ConvertInvariant(), Amount = ask[SIZE].ConvertInvariant() }); - return orderBook; - } - - protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = 100) - { - // Limit is required but maxed at 100 with no pagination available - limit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; - JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol.ToUpperInvariant() + "/ticker?limit=" + limit); - List tradeList = new List(); - foreach (JToken trade in trades["trades"]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, SIDE, "time", TimestampType.Iso8601UTC, "trade_id")); - return tradeList; - } - - protected override async Task OnGetHistoricalTradesAsync(Func, bool> callback, string marketSymbol, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) - { - // There is no Historical Trades endpoint. The best we can do is get the last 100 trades and filter. - // Check for this data on the sockets? - var trades = await OnGetRecentTradesAsync(marketSymbol.ToUpperInvariant()); - - if (startDate != null) trades = trades.Where(t => t.Timestamp >= startDate); - if (endDate != null) trades = trades.Where(t => t.Timestamp <= endDate);; - if (limit != null) trades = trades.Take((int)limit); - - callback(trades); - } - - protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) - { - if (endDate == null) endDate = CryptoUtility.UtcNow; - - string granularity = "UNKNOWN_GRANULARITY"; - if (periodSeconds <= 60) { granularity = "ONE_MINUTE"; periodSeconds = 60; } - else if (periodSeconds <= 300) { granularity = "FIVE_MINUTE"; periodSeconds = 300; } - else if (periodSeconds <= 900) { granularity = "FIFTEEN_MINUTE"; periodSeconds = 900; } - else if (periodSeconds <= 1800) { granularity = "THIRTY_MINUTE"; periodSeconds = 1800; } - else if (periodSeconds <= 3600) { granularity = "ONE_HOUR"; periodSeconds = 3600; } - else if (periodSeconds <= 21600) { granularity = "SIX_HOUR"; periodSeconds = 21600; } - else { granularity = "ONE_DAY"; periodSeconds = 86400; } - - // Returned Candle count is restricted to 300 and they don't paginate this call - // We're going to keep retrieving candles 300 at a time until we get our date range for the granularity - if (startDate == null) startDate = CryptoUtility.UtcNow.AddMinutes(-(periodSeconds * 300)); - if (startDate >= endDate) throw new APIException("Invalid Date Range"); - DateTime RangeStart = (DateTime)startDate, RangeEnd = (DateTime)endDate; - if ((RangeEnd - RangeStart).TotalSeconds / periodSeconds > 300) RangeStart = RangeEnd.AddSeconds(-(periodSeconds * 300)); - - List candles = new List(); - while (true) - { - JToken token = await MakeJsonRequestAsync(string.Format("/products/{0}/candles?start={1}&end={2}&granularity={3}", marketSymbol.ToUpperInvariant(), ((DateTimeOffset)RangeStart).ToUnixTimeSeconds(), ((DateTimeOffset)RangeEnd).ToUnixTimeSeconds(), granularity)); - foreach (JToken candle in token["candles"]) candles.Add(this.ParseCandle(candle, marketSymbol, periodSeconds, "open", "high", "low", "close", "start", TimestampType.UnixSeconds, "volume")); - if (RangeStart > startDate) + else { - // For simplicity, we'll go back 300 each iteration and sort/filter date range before return - RangeStart = RangeStart.AddSeconds(-(periodSeconds * 300)); - RangeEnd = RangeEnd.AddSeconds(-(periodSeconds * 300)); - } - else break; - } - return candles.Where(c => c.Timestamp >= startDate).OrderBy(c => c.Timestamp); - } - - - protected override async Task> OnGetFeesAsync() - { - var symbols = await OnGetMarketSymbolsAsync(); - JToken token = await this.MakeJsonRequestAsync("/transaction_summary"); - Dictionary fees = new Dictionary(); - - // We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per market symbol. - // Here, we choose taker fee, which is usually higher - decimal makerRate = token["fee_tier"]["taker_fee_rate"].Value(); //percentage between 0 and 1 - - return symbols.Select(symbol => new KeyValuePair(symbol, makerRate)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - - #endregion - - #region AccountSpecificEndpoints - - // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. - protected override async Task OnGetDepositAddressAsync(string symbol, bool forceRegenerate = false) - { - if (Accounts == null) await GetAmounts(true); // Populate Accounts Cache - if (Accounts.ContainsKey(symbol)) - { - JToken accountWalletAddress = await this.MakeJsonRequestAsync($"/accounts/{Accounts[symbol]}/addresses", BaseURLV2); - return new ExchangeDepositDetails { Address = accountWalletAddress[0]["address"].ToStringInvariant(), Currency = symbol }; // We only support a single Wallet/Address (Coinbase is the only Exchange that has multiple) - } - throw new APIException($"Address not found for {symbol}"); - } - - protected override async Task> OnGetAmountsAsync() - { - return await GetAmounts(false); - } - - protected override async Task> OnGetAmountsAvailableToTradeAsync() - { - return await GetAmounts(true); - } - - // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. - protected override async Task> OnGetWithdrawHistoryAsync(string currency) - { - return await GetTx(true, currency); - } - - // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. - protected override async Task> OnGetDepositHistoryAsync(string currency) - { - return await GetTx(false, currency); - } - - /// - /// WARNING: Only Advanced Trade Open Orders are supported. - /// - /// - /// - protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) - { - List orders = new List(); - // Max return count is 1000 with no pagination available - JToken array = await MakeJsonRequestAsync("/orders/historical/batch?order_status=OPEN" + marketSymbol == null || marketSymbol == string.Empty ? string.Empty : "&product_id=" + marketSymbol ); - foreach (JToken order in array) if (order[TYPE].ToStringInvariant().Equals(ADVFILL)) orders.Add(ParseOrder(order)); - return orders; - } - - /// - /// WARNING: Only Advanced Trade Completed Orders are supported. - /// - /// - /// - /// - protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) - { - // Legacy Orders may be retrieved using V2 (not implemented here - see GetTx in code below) - List orders = new List(); - pagination = PaginationType.V3Cursor; - string startURL = "/orders/historical/fills"; - - if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol; - if (afterDate != null) startURL += marketSymbol == null ? "?" : "&" + "start_sequence_timestamp=" + ((DateTimeOffset)afterDate).ToUnixTimeSeconds(); - JToken token = await MakeJsonRequestAsync(startURL); - startURL += marketSymbol == null && afterDate == null ? "?" : "&" + "cursor="; - while(true) - { - foreach (JToken fill in token["fills"]) - { - orders.Add(new ExchangeOrderResult() + configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() { - MarketSymbol = fill[PRODUCTID].ToStringInvariant(), - TradeId = fill["trade_id"].ToStringInvariant(), - OrderId = fill["order_id"].ToStringInvariant(), - OrderDate = fill["trade_time"].ToDateTimeInvariant(), - IsBuy = fill[SIDE].ToStringInvariant() == BUY, - Amount = fill[SIZE].ConvertInvariant(), - AmountFilled = fill[SIZE].ConvertInvariant(), - Price = fill[PRICE].ConvertInvariant(), - Fees = fill["commission"].ConvertInvariant(), - AveragePrice = fill[PRICE].ConvertInvariant() + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? }); } - if (string.IsNullOrEmpty(cursorNext)) break; - token = await MakeJsonRequestAsync(startURL + cursorNext); - } - pagination = PaginationType.None; - return orders; - } - - protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) - { - JToken obj = await MakeJsonRequestAsync("/orders/historical/" + orderId); - return ParseOrder(obj); - } - - /// - /// This supports two Entries in the Order ExtraParameters: - /// "post_only" : true/false (defaults to false if does not exist) - /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC - /// - /// - /// - protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) - { - Dictionary configuration = new Dictionary(); - switch (order.OrderType) - { - case OrderType.Limit: - if (order.ExtraParameters.ContainsKey("gtd_timestamp")) - { - configuration.Add("limit_limit_gtd", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - }); - } - else - { - configuration.Add("limit_limit_gtc", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - }); - } break; - case OrderType.Stop: - if (order.ExtraParameters.ContainsKey("gtd_timestamp")) - { - configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"stop_price", order.StopPrice.ToStringInvariant() }, - {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? - }); - } - else - { - configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"stop_price", order.StopPrice.ToStringInvariant() }, - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? - }); - } + case OrderType.Market: + configuration.Add("market_market_ioc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() } + }); break; - case OrderType.Market: - configuration.Add("market_market_ioc", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() } - }); - break; - } - - Dictionary payload = new Dictionary - { - { "order_configuration", configuration} - }; - - string side = order.IsBuy ? BUY : "sell"; - JToken result = await MakeJsonRequestAsync($"/orders?product_id={order.MarketSymbol.ToUpperInvariant()}&side={side}", payload: payload, requestMethod: "POST"); - - // We don't have the proper return type for a successful POST - will probably require a separate parsing function and return Success/Fail - return ParseOrder(result); } - protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) - { - Dictionary payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; - await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); - } + Dictionary payload = new Dictionary { { "order_configuration", configuration} }; + string side = order.IsBuy ? "buy" : "sell"; + JToken result = await MakeJsonRequestAsync($"/orders?product_id={order.MarketSymbol.ToUpperInvariant()}&side={side}", payload: payload, requestMethod: "POST"); - protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) - { - return base.OnWithdrawAsync(withdrawalRequest); - } + // We don't have the proper return type for the POST - will probably require a separate parsing function and return Success/Fail + return ParseOrder(result); + } + protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) + { + return base.OnWithdrawAsync(withdrawalRequest); + } - #endregion - #region SocketEndpoints + #endregion - protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) - { - return ConnectWebSocketAsync("/", (_socket, msg) => - { - JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); - string type = tokens[EVENTS][0][TYPE].ToStringInvariant(); - if (type.Equals("update") || type.Equals("snapshot")) - { - var book = new ExchangeOrderBook(){ MarketSymbol = tokens[EVENTS][0][PRODUCTID].ToStringInvariant(), LastUpdatedUtc = DateTime.UtcNow, SequenceId = tokens["sequence_num"].ConvertInvariant() }; - int askCount = 0, bidCount = 0; - foreach(var token in tokens[EVENTS][0]["updates"]) - { - if (token[SIDE].ToStringInvariant().Equals("bid")) - { - if (bidCount++ < maxCount) - { - decimal price = token[PRICELEVEL].ConvertInvariant(); - book.Bids.Add( price, new ExchangeOrderPrice(){ Price = price, Amount=token["new_quantity"].ConvertInvariant()} ); - } - } - else - { - if (askCount++ < maxCount) - { - decimal price = token[PRICELEVEL].ConvertInvariant(); - book.Asks.Add( price, new ExchangeOrderPrice(){ Price = price, Amount=token["new_quantity"].ConvertInvariant()} ); - } - } - if (askCount >= maxCount && bidCount >=maxCount) break; - } - callback?.Invoke(book); - } - return Task.CompletedTask; - }, async (_socket) => - { - string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); - string signature = CryptoUtility.SHA256Sign(timestamp + LEVEL2 + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); - var subscribeRequest = new - { - type = SUBSCRIBE, - product_ids = marketSymbols, - channel = LEVEL2, - api_key = PublicApiKey.ToUnsecureString(), - timestamp, - signature - }; - await _socket.SendMessageAsync(subscribeRequest); - }); - } + #region SocketEndpoints - protected override async Task OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) - { + protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) + { + return base.OnGetDeltaOrderBookWebSocketAsync(callback); + } + + protected override async Task OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) + { return await ConnectWebSocketAsync("/", async (_socket, msg) => { JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); var timestamp = tokens["timestamp"].ConvertInvariant(); List> ticks = new List>(); - foreach(var token in tokens[EVENTS]?[0]?["tickers"]) + foreach(var token in tokens["events"]?[0]?["tickers"]) { - string product = token[PRODUCTID].ToStringInvariant(); + string product = token["product_id"].ToStringInvariant(); var split = product.Split(GlobalMarketSymbolSeparator); ticks.Add(new KeyValuePair(product, new ExchangeTicker() { - // We don't have Bid or Ask info on this feed - MarketSymbol = product, - ApiResponse = token, - Exchange = this.Name, - Last = token[PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() - { - BaseCurrency = split[0], - QuoteCurrency = split[1], - BaseCurrencyVolume = token["volume_24_h"].ConvertInvariant(), - Timestamp = timestamp - } + // We don't have Bid or Ask info on this feed + ApiResponse = token, + Last = token["price"].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + QuoteCurrency = split[1], + BaseCurrencyVolume = token["volume_24_h"].ConvertInvariant(), + Timestamp = timestamp + } } )); - } - callback?.Invoke(ticks); - }, async (_socket) => + } + callback?.Invoke(ticks); + }, async (_socket) => + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + "ticker" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new { - string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); - string signature = CryptoUtility.SHA256Sign(timestamp + TICKER + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); - var subscribeRequest = new - { - type = SUBSCRIBE, - product_ids = marketSymbols, - channel = TICKER, - api_key = PublicApiKey.ToUnsecureString(), - timestamp, - signature - }; - await _socket.SendMessageAsync(subscribeRequest); - }); - } + type = "subscribe", + product_ids = marketSymbols, + channel = "ticker", + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); } protected override async Task OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) { @@ -629,13 +571,13 @@ protected override async Task OnGetTradesWebSocketAsync(Func { JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); - foreach(var token in tokens[EVENTS]?[0]?["trades"]) + foreach(var token in tokens["events"]?[0]?["trades"]) { - callback?.Invoke(new KeyValuePair(token[PRODUCTID].ToStringInvariant(), new ExchangeTrade() + await callback?.Invoke(new KeyValuePair(token["product_id"].ToStringInvariant(), new ExchangeTrade() { - Amount = token[SIZE].ConvertInvariant(), - Price = token[PRICE].ConvertInvariant(), - IsBuy = token[SIDE].ToStringInvariant().Equals(BUY), + Amount = token["size"].ConvertInvariant(), + Price = token["price"].ConvertInvariant(), + IsBuy = token["side"].ToStringInvariant().Equals("buy"), Id = token["trade_id"].ToStringInvariant(), Timestamp = token["time"].ConvertInvariant() })); @@ -643,183 +585,185 @@ protected override async Task OnGetTradesWebSocketAsync(Func { string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); - string signature = CryptoUtility.SHA256Sign(timestamp + MARKETTRADES + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + string signature = CryptoUtility.SHA256Sign(timestamp + "market_trades" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); var subscribeRequest = new { - type = SUBSCRIBE, + type = "subscribe", product_ids = marketSymbols, - channel = MARKETTRADES, + channel = "market_trades", api_key = PublicApiKey.ToUnsecureString(), timestamp, signature }; await _socket.SendMessageAsync(subscribeRequest); - }); - } + }); + } - #endregion + #endregion #region PrivateFunctions - private async Task> GetAmounts(bool AvailableOnly) - { - Accounts ??= new Dictionary(); // This function is the only place where Accounts cache is populated - - Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - pagination = PaginationType.V3; - JToken token = await MakeJsonRequestAsync("/accounts"); - while(true) - { - foreach (JToken account in token["accounts"]) - { - Accounts[account[CURRENCY].ToString()] = account["uuid"].ToString(); // populate Accounts cache as we go - decimal amount = AvailableOnly ? account["available_balance"][VALUE].ConvertInvariant() : account["available_balance"][VALUE].ConvertInvariant() + account["hold"][VALUE].ConvertInvariant(); - if (amount > 0.0m) amounts[account[CURRENCY].ToStringInvariant()] = amount; - } - if (string.IsNullOrEmpty(cursorNext)) break; - token = await MakeJsonRequestAsync("/accounts?starting_after=" + cursorNext); - } - pagination = PaginationType.None; - return amounts; - } - - /// - /// Warning: This call uses V2 Transactions - /// - /// - /// - /// - private async Task> GetTx(bool Withdrawals, string currency) - { - if (Accounts == null) await GetAmounts(true); - pagination = PaginationType.V2; - List transfers = new List(); - JToken tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions", BaseURLV2); - while(true) - { - foreach (JToken token in tokens) - { - // A "send" to Coinbase is when someone "sent" you coin - or a receive to the rest of the world - // Likewise, a "receive" is when someone "received" coin from you. In other words, it's back-asswards. - if (!Withdrawals && token[TYPE].ToStringInvariant().Equals("send")) transfers.Add(ParseTransaction(token)); - else if (Withdrawals && token[TYPE].ToStringInvariant().Equals("receive")) transfers.Add(ParseTransaction(token)); + private async Task> GetAmounts(bool AvailableOnly) + { + Accounts ??= new Dictionary(); // This function is the only place where Accounts cache is populated - // Legacy Order and other Coinbase Tx Types can be parsed using this V2 code - //var tmp = ParseOrder(token); - } - if (string.IsNullOrEmpty(cursorNext)) break; - tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions?starting_after={cursorNext}", BaseURLV2); - } - pagination = PaginationType.None; - return transfers; - } - - /// - /// Parse V2 Transaction of type of either "Send" or "Receive" - /// - /// - /// - private ExchangeTransaction ParseTransaction(JToken token) - { - // The Coin Address/TxFee isn't available but can be retrieved using the Network Hash/BlockChainId - return new ExchangeTransaction() - { - PaymentId = token["id"].ToStringInvariant(), // Not sure how this is used elsewhere but here it is the Coinbase TransactionID - BlockchainTxId = token["network"]["hash"].ToStringInvariant(), - Currency = token[AMOUNT][CURRENCY].ToStringInvariant(), - Amount = token[AMOUNT][AMOUNT].ConvertInvariant(), - Timestamp = token["created_at"].ToObject(), - Status = token["status"].ToStringInvariant() == "completed" ? TransactionStatus.Complete : TransactionStatus.Unknown, - Notes = token["description"].ToStringInvariant() - // Address - // AddressTag - // TxFee - }; - } - - - /// - /// Parse both Advanced Trade and Legacy Transactions - /// - /// - /// - private ExchangeOrderResult ParseOrder(JToken result) - { - decimal amount = 0, amountFilled = 0, price = 0, fees = 0; - string marketSymbol = string.Empty; - bool isBuy = true; - - //Debug.WriteLine(result["type"].ToStringInvariant()); - switch(result[TYPE].ToStringInvariant()) - { - case ADVFILL: - // Buys/Sells have reversed amounts? - break; - case BUY: - case "sell": - case "send": - case "receive": - return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; - case "trade": - case "request": - case "transfer": - - case "exchange_deposit": - case "fiat_deposit": - case "fiat_withdrawal": - case "pro_withdrawal": - case "vault_withdrawal": - default: - return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; - } + Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + pagination = PaginationType.V3; + JToken token = await MakeJsonRequestAsync("/accounts"); + while(true) + { + foreach (JToken account in token["accounts"]) + { + Accounts[account[CURRENCY].ToString()] = account["uuid"].ToString(); // populate Accounts cache as we go + decimal amount = AvailableOnly ? account["available_balance"][VALUE].ConvertInvariant() : account["available_balance"][VALUE].ConvertInvariant() + account["hold"][VALUE].ConvertInvariant(); + if (amount > 0.0m) amounts[account[CURRENCY].ToStringInvariant()] = amount; + } + if (cursorNext == null) break; + token = await MakeJsonRequestAsync("/accounts?starting_after=" + cursorNext); + } + pagination = PaginationType.None; + return amounts; + } + + /// + /// Warning: This call uses V2 Transactions + /// + /// + /// + /// + private async Task> GetTx(bool Withdrawals, string currency) + { + if (Accounts == null) await GetAmounts(true); + pagination = PaginationType.V2; + List transfers = new List(); + JToken tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions", BaseURLV2); + while(true) + { + foreach (JToken token in tokens) + { + // A "send" to Coinbase is when someone "sent" you coin - or a receive to the rest of the world + // Likewise, a "receive" is when someone "received" coin from you. In other words, it's back-asswards. + if (!Withdrawals && token["type"].ToStringInvariant().Equals("send")) transfers.Add(ParseTransaction(token)); + else if (Withdrawals && token["type"].ToStringInvariant().Equals("receive")) transfers.Add(ParseTransaction(token)); + + // Legacy Order and other Coinbase Tx Types can be parsed using this V2 code block + //var tmp = ParseOrder(token); + } + if (string.IsNullOrEmpty(cursorNext)) break; + tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions?starting_after={cursorNext}", BaseURLV2); + } + pagination = PaginationType.None; + return transfers; + } + + /// + /// Parse V2 Transaction of type of either "Send" or "Receive" + /// + /// + /// + private ExchangeTransaction ParseTransaction(JToken token) + { + // The Coin Address/TxFee isn't available but can be retrieved using the Network Hash/BlockChainId + return new ExchangeTransaction() + { + PaymentId = token["id"].ToStringInvariant(), // Not sure how this is used elsewhere but here it is the Coinbase TransactionID + BlockchainTxId = token["network"]["hash"].ToStringInvariant(), + Currency = token[AMOUNT][CURRENCY].ToStringInvariant(), + Amount = token[AMOUNT][AMOUNT].ConvertInvariant(), + Timestamp = token["created_at"].ToObject(), + Status = token["status"].ToStringInvariant() == "completed" ? TransactionStatus.Complete : TransactionStatus.Unknown, + Notes = token["description"].ToStringInvariant() + // Address + // AddressTag + // TxFee + }; + } + + + /// + /// Parse both Advanced Trade and Legacy Transactions + /// + /// + /// + private ExchangeOrderResult ParseOrder(JToken result) + { + decimal amount = 0, amountFilled = 0, price = 0, fees = 0; + string marketSymbol = string.Empty; + bool isBuy = true; + + //Debug.WriteLine(result["type"].ToStringInvariant()); + switch(result["type"].ToStringInvariant()) + { + case ADVFILL: + // Buys/Sells have reversed amounts? + + + break; + case "send": + case "receive": + return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; + case "buy": + case "sell": + case "trade": + case "request": + case "transfer": + + case "exchange_deposit": + case "fiat_deposit": + case "fiat_withdrawal": + case "pro_withdrawal": + case "vault_withdrawal": + default: + return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; + } - amount = result[AMOUNT][AMOUNT].ConvertInvariant(amountFilled); - amountFilled = amount; + amount = result[AMOUNT][AMOUNT].ConvertInvariant(amountFilled); + amountFilled = amount; - price = result[ADVFILL]["fill_price"].ConvertInvariant(); - fees = result[ADVFILL]["commission"].ConvertInvariant(); - marketSymbol = result[ADVFILL][PRODUCTID].ToStringInvariant(result["id"].ToStringInvariant()); - isBuy = (result[ADVFILL]["order_side"].ToStringInvariant() == BUY); + price = result[ADVFILL]["fill_price"].ConvertInvariant(); + fees = result[ADVFILL]["commission"].ConvertInvariant(); + marketSymbol = result[ADVFILL][PRODUCTID].ToStringInvariant(result["id"].ToStringInvariant()); + isBuy = (result[ADVFILL]["order_side"].ToStringInvariant() == "buy"); - ExchangeOrderResult order = new ExchangeOrderResult() - { - IsBuy = isBuy, - Amount = amount, - AmountFilled = amountFilled, - Price = price, - Fees = fees, - FeesCurrency = result["native_amount"][CURRENCY].ToStringInvariant(), - OrderDate = result["created_at"].ToDateTimeInvariant(), - CompletedDate = result["updated_at"].ToDateTimeInvariant(), - MarketSymbol = marketSymbol, - OrderId = result["id"].ToStringInvariant(), - Message = result[TYPE].ToStringInvariant() - }; + ExchangeOrderResult order = new ExchangeOrderResult() + { + IsBuy = isBuy, + Amount = amount, + AmountFilled = amountFilled, + Price = price, + Fees = fees, + FeesCurrency = result["native_amount"]["currency"].ToStringInvariant(), + OrderDate = result["created_at"].ToDateTimeInvariant(), + CompletedDate = result["updated_at"].ToDateTimeInvariant(), + MarketSymbol = marketSymbol, + OrderId = result["id"].ToStringInvariant(), + Message = result["type"].ToStringInvariant() + }; + + switch (result["status"].ToStringInvariant()) + { + case "completed": + order.Result = ExchangeAPIOrderResult.Filled; + break; + case "waiting_for_clearing": + case "waiting_for_signature": + case "pending": + order.Result = ExchangeAPIOrderResult.PendingOpen; + break; + case "expired": + case "canceled": + order.Result = ExchangeAPIOrderResult.Canceled; + break; + default: + order.Result = ExchangeAPIOrderResult.Unknown; + break; + } + return order; + } - switch (result["status"].ToStringInvariant()) - { - case "completed": - order.Result = ExchangeAPIOrderResult.Filled; - break; - case "waiting_for_clearing": - case "waiting_for_signature": - case "pending": - order.Result = ExchangeAPIOrderResult.PendingOpen; - break; - case "expired": - case "canceled": - order.Result = ExchangeAPIOrderResult.Canceled; - break; - default: - order.Result = ExchangeAPIOrderResult.Unknown; - break; - } - return order; - } + #endregion - #endregion + } - } + public partial class ExchangeName { public const string Coinbase = "Coinbase"; } } - -public partial class ExchangeName { public const string Coinbase = "Coinbase"; } From f643eee335051b0c8bdf1d04072aec9a5d6aee0f Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Tue, 5 Dec 2023 07:31:44 -0500 Subject: [PATCH 09/10] Final CoinbaseAPI changes. Completed OrderParsing Fully Test PlaceOrder Additional Error Checking on WebSockets Removed Unused functions Separated constants into second file - creating partial class General Code Cleanup --- .../Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 722 ++++++++---------- .../Coinbase/ExchangeCoinbaseAPI_Const.cs | 34 + 2 files changed, 370 insertions(+), 386 deletions(-) create mode 100644 src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI_Const.cs diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index e494523c..452b98f8 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -10,7 +10,6 @@ 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. */ - using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -25,28 +24,13 @@ namespace ExchangeSharp /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site. /// These keys must be set before using the Coinbase API (sorry). /// - public sealed class ExchangeCoinbaseAPI : ExchangeAPI + public sealed partial class ExchangeCoinbaseAPI : ExchangeAPI { - private const string ADVFILL = "advanced_trade_fill"; - private const string CURRENCY = "currency"; - private const string PRODUCTID = "product_id"; - private const string PRODUCTS = "products"; - private const string PRICEBOOK = "pricebook"; - private const string PRICEBOOKS = "pricebooks"; - private const string ASKS = "asks"; - private const string BIDS = "bids"; - private const string PRICE = "price"; - private const string AMOUNT = "amount"; - private const string VALUE = "value"; - private const string SIZE = "size"; - private const string CURSOR = "cursor"; - - public override string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage"; - private readonly string BaseURLV2 = "https://api.coinbase.com/v2"; // For Wallet Support + private readonly string BaseUrlV2 = "https://api.coinbase.com/v2"; // For Wallet Support public override string BaseUrlWebSocket { get; set; } = "wss://advanced-trade-ws.coinbase.com"; - private enum PaginationType { None, V2, V3, V3Cursor} + private enum PaginationType { None, V2, V3} private PaginationType pagination = PaginationType.None; private string cursorNext; @@ -54,11 +38,12 @@ private enum PaginationType { None, V2, V3, V3Cursor} private ExchangeCoinbaseAPI() { + MarketSymbolIsUppercase = true; MarketSymbolIsReversed = false; RequestContentType = "application/json"; NonceStyle = NonceStyle.None; WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas; - RateLimit = new RateGate(10, TimeSpan.FromSeconds(1)); + RateLimit = new RateGate(30, TimeSpan.FromSeconds(1)); base.RequestMaker.RequestStateChanged = ProcessResponse; } @@ -69,60 +54,42 @@ private ExchangeCoinbaseAPI() /// /// /// - private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) - { - // We can bypass serialization if we already know the last call isn't paginated - if (state == RequestMakerState.Finished && pagination != PaginationType.None) - { - cursorNext = null; - JToken token = JsonConvert.DeserializeObject((string)response); - if (token == null) return; - switch(pagination) - { - case PaginationType.V2: cursorNext = token["pagination"]?["next_starting_after"]?.ToStringInvariant(); break; - case PaginationType.V3: cursorNext = token["has_next"].ToStringInvariant().Equals("True") ? token[CURSOR]?.ToStringInvariant() : null; break; - case PaginationType.V3Cursor: cursorNext = token[CURSOR]?.ToStringInvariant(); break; // Only used for V3 Fills - go figure. - } - } + private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response) + { + // We can bypass serialization if we already know the last call isn't paginated + if (state == RequestMakerState.Finished && pagination != PaginationType.None) + { + JToken token = JsonConvert.DeserializeObject((string)response); + if (token == null) return; + switch(pagination) + { + case PaginationType.V2: cursorNext = token["pagination"]?["next_starting_after"]?.ToStringInvariant(); break; + case PaginationType.V3: cursorNext = token[CURSOR]?.ToStringInvariant(); break; + } + } } - #region BaseOverrides + #region BaseOverrides /// /// Overridden because we no longer need a nonce in the payload and passphrase is no longer used /// /// /// - protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) - { - return (PrivateApiKey != null && PublicApiKey != null); - } - - /// - /// Sometimes the Fiat pairs are reported backwards, but Coinbase requires the fiat to be last of the pair - /// Only three Fiat Currencies are supported - /// - /// - /// - public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) - { - if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) - { - var split = marketSymbol.Split(GlobalMarketSymbolSeparator); - return Task.FromResult(split[1] + GlobalMarketSymbolSeparator + split[0]); - } - else return Task.FromResult(marketSymbol); - } + protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) + { + return (PrivateApiKey != null && PublicApiKey != null); + } - protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) - { + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { if (CanMakeAuthenticatedRequest(payload)) - { + { string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); // If you're skittish about the local clock, you may retrieve the timestamp from the Coinbase Site string body = CryptoUtility.GetJsonForPayload(payload); - // V2 wants PathAndQuery, V3 wants LocalPath for the sig - string path = request.RequestUri.AbsoluteUri.StartsWith(BaseURLV2) ? request.RequestUri.PathAndQuery : request.RequestUri.LocalPath; + // V2 wants PathAndQuery, V3 wants LocalPath for the sig (I guess they wanted to shave a nano-second or two - silly) + string path = request.RequestUri.AbsoluteUri.StartsWith(BaseUrlV2) ? request.RequestUri.PathAndQuery : request.RequestUri.LocalPath; string signature = CryptoUtility.SHA256Sign(timestamp + request.Method.ToUpperInvariant() + path + body, PrivateApiKey.ToUnsecureString()); request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString()); @@ -130,11 +97,27 @@ protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dicti request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp); if (request.Method == "POST") await CryptoUtility.WriteToRequestAsync(request, body); } - } + } - #endregion + /// + /// Sometimes the Fiat pairs are reported backwards, but Coinbase requires the fiat to be last of the pair + /// Only three Fiat Currencies are supported + /// + /// + /// + public override Task GlobalMarketSymbolToExchangeMarketSymbolAsync(string marketSymbol) + { + if (marketSymbol.StartsWith("USD-") || marketSymbol.StartsWith("EUR-") || marketSymbol.StartsWith("GRP-")) + { + var split = marketSymbol.Split(GlobalMarketSymbolSeparator); + return Task.FromResult(split[1] + GlobalMarketSymbolSeparator + split[0]); + } + else return Task.FromResult(marketSymbol); + } - #region GeneralProductEndpoints + #endregion + + #region GeneralProductEndpoints protected internal override async Task> OnGetMarketSymbolsMetadataAsync() { @@ -142,12 +125,12 @@ protected internal override async Task> OnGetMarketS JToken products = await MakeJsonRequestAsync("/products"); foreach (JToken product in products[PRODUCTS]) { - markets.Add(new ExchangeMarket() + markets.Add(new ExchangeMarket { MarketSymbol = product[PRODUCTID].ToStringUpperInvariant(), BaseCurrency = product["base_currency_id"].ToStringUpperInvariant(), QuoteCurrency = product["quote_currency_id"].ToStringUpperInvariant(), - IsActive = string.Equals(product["status"].ToStringInvariant(), "online", StringComparison.OrdinalIgnoreCase), + IsActive = string.Equals(product[STATUS].ToStringInvariant(), "online", StringComparison.OrdinalIgnoreCase), MinTradeSize = product["base_min_size"].ConvertInvariant(), MaxTradeSize = product["base_max_size"].ConvertInvariant(), PriceStepSize = product["quote_increment"].ConvertInvariant() @@ -163,7 +146,7 @@ protected override async Task> OnGetMarketSymbolsAsync() protected override async Task> OnGetCurrenciesAsync() { - var currencies = new Dictionary(); // We could order the return (like Market Symbols are) if we populate as a list then sort and select into a dictionary before return, but is it worth the overhead? + var currencies = new Dictionary(); // We don't have a currencies endpoint, but we can derive the currencies by splitting the products (includes fiat - filter if you wish) JToken products = await MakeJsonRequestAsync("/products"); @@ -200,22 +183,22 @@ protected override async Task>> { var tickers = new List>(); JToken books = await MakeJsonRequestAsync("/best_bid_ask"); - var Timestamp = CryptoUtility.ParseTimestamp(books["time"], TimestampType.Iso8601UTC); + var Timestamp = CryptoUtility.ParseTimestamp(books[TIME], TimestampType.Iso8601UTC); foreach (JToken book in books[PRICEBOOKS]) { var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); - // This endpoint does not provide a last or open for the ExchangeTicker. We might get this from the sockets, but this call is extremely fast? + // This endpoint does not provide a last or open for the ExchangeTicker tickers.Add(new KeyValuePair(book[PRODUCTID].ToString(), new ExchangeTicker() { MarketSymbol = book[PRODUCTID].ToString(), Ask = book[ASKS][0][PRICE].ConvertInvariant(), Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() + Volume = new ExchangeVolume() { - BaseCurrency = split[0], - BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), - QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + BaseCurrency = split[0], + BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), + QuoteCurrency = split[1], + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), Timestamp = Timestamp } })); @@ -225,8 +208,7 @@ protected override async Task>> protected override async Task OnGetTickerAsync(string marketSymbol) { - // Again, me might also get this from the sockets, but this seems preferable for now. - JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol.ToUpperInvariant()); + JToken ticker = await MakeJsonRequestAsync("/best_bid_ask?product_ids=" + marketSymbol); JToken book = ticker[PRICEBOOKS][0]; var split = book[PRODUCTID].ToString().Split(GlobalMarketSymbolSeparator); return new ExchangeTicker() @@ -234,12 +216,12 @@ protected override async Task OnGetTickerAsync(string marketSymb MarketSymbol = book[PRODUCTID].ToString(), Ask = book[ASKS][0][PRICE].ConvertInvariant(), Bid = book[BIDS][0][PRICE].ConvertInvariant(), - Volume = new ExchangeVolume() + Volume = new ExchangeVolume() { - BaseCurrency = split[0], + BaseCurrency = split[0], BaseCurrencyVolume = book[BIDS][0][SIZE].ConvertInvariant(), QuoteCurrency = split[1], - QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), + QuoteCurrencyVolume = book[ASKS][0][SIZE].ConvertInvariant(), Timestamp = DateTime.UtcNow } }; @@ -247,7 +229,7 @@ protected override async Task OnGetTickerAsync(string marketSymb protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 50) { - JToken token = await MakeJsonRequestAsync("/product_book?product_id=" + marketSymbol.ToUpperInvariant() + "&limit=" + maxCount); + JToken token = await MakeJsonRequestAsync("/product_book?product_id=" + marketSymbol + "&limit=" + maxCount); ExchangeOrderBook orderBook = new ExchangeOrderBook(); foreach(JToken bid in token[PRICEBOOK][BIDS]) orderBook.Bids.Add(bid[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = bid[PRICE].ConvertInvariant(), Amount = bid[SIZE].ConvertInvariant() }); foreach(JToken ask in token[PRICEBOOK][ASKS]) orderBook.Asks.Add(ask[PRICE].ConvertInvariant(), new ExchangeOrderPrice(){ Price = ask[PRICE].ConvertInvariant(), Amount = ask[SIZE].ConvertInvariant() }); @@ -256,27 +238,14 @@ protected override async Task OnGetOrderBookAsync(string mark protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = 100) { - // Limit is required but maxed at 100 with no pagination available. Check Sockets? + // Limit is required but maxed at 100 with no pagination available limit = (limit == null || limit < 1 || limit > 100) ? 100 : (int)limit; - JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol.ToUpperInvariant() + "/ticker?limit=" + limit); + JToken trades = await MakeJsonRequestAsync("/products/" + marketSymbol + "/ticker?limit=" + limit); List tradeList = new List(); - foreach (JToken trade in trades["trades"]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, "side", "time", TimestampType.Iso8601UTC, "trade_id")); + foreach (JToken trade in trades[TRADES]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, SIDE, TIME, TimestampType.Iso8601UTC, TRADEID)); return tradeList; } - protected override async Task OnGetHistoricalTradesAsync(Func, bool> callback, string marketSymbol, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) - { - // There is no Historical Trades endpoint. The best we can do is get the last 100 trades and filter. - // Check for this data on the sockets? - var trades = await OnGetRecentTradesAsync(marketSymbol.ToUpperInvariant()); - - if (startDate != null) trades = trades.Where(t => t.Timestamp >= startDate); - if (endDate != null) trades = trades.Where(t => t.Timestamp <= endDate);; - if (limit != null) trades = trades.Take((int)limit); - - callback(trades); - } - protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) { if (endDate == null) endDate = CryptoUtility.UtcNow; @@ -290,7 +259,7 @@ protected override async Task> OnGetCandlesAsync(strin else if (periodSeconds <= 21600) { granularity = "SIX_HOUR"; periodSeconds = 21600; } else { granularity = "ONE_DAY"; periodSeconds = 86400; } - // Returned Candle count is restricted to 300 - and they don't paginate this call + // Returned Candle count is restricted to 300 and they don't paginate this call // We're going to keep retrieving candles 300 at a time until we get our date range for the granularity if (startDate == null) startDate = CryptoUtility.UtcNow.AddMinutes(-(periodSeconds * 300)); if (startDate >= endDate) throw new APIException("Invalid Date Range"); @@ -300,7 +269,7 @@ protected override async Task> OnGetCandlesAsync(strin List candles = new List(); while (true) { - JToken token = await MakeJsonRequestAsync(string.Format("/products/{0}/candles?start={1}&end={2}&granularity={3}", marketSymbol.ToUpperInvariant(), ((DateTimeOffset)RangeStart).ToUnixTimeSeconds(), ((DateTimeOffset)RangeEnd).ToUnixTimeSeconds(), granularity)); + JToken token = await MakeJsonRequestAsync(string.Format("/products/{0}/candles?start={1}&end={2}&granularity={3}", marketSymbol, ((DateTimeOffset)RangeStart).ToUnixTimeSeconds(), ((DateTimeOffset)RangeEnd).ToUnixTimeSeconds(), granularity)); foreach (JToken candle in token["candles"]) candles.Add(this.ParseCandle(candle, marketSymbol, periodSeconds, "open", "high", "low", "close", "start", TimestampType.UnixSeconds, "volume")); if (RangeStart > startDate) { @@ -320,7 +289,7 @@ protected override async Task> OnGetFeesAsync() JToken token = await this.MakeJsonRequestAsync("/transaction_summary"); Dictionary fees = new Dictionary(); - // We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per symbol. + // We can chose between maker and taker fee, but currently ExchangeSharp only supports 1 fee rate per market symbol. // Here, we choose taker fee, which is usually higher decimal makerRate = token["fee_tier"]["taker_fee_rate"].Value(); //percentage between 0 and 1 @@ -330,7 +299,7 @@ protected override async Task> OnGetFeesAsync() #endregion - #region AccountSpecificEndpoints + #region AccountSpecificEndpoints // WARNING: Currently V3 doesn't support Coinbase Wallet APIs, so we are reverting to V2 for this call. protected override async Task OnGetDepositAddressAsync(string symbol, bool forceRegenerate = false) @@ -338,7 +307,7 @@ protected override async Task OnGetDepositAddressAsync(s if (Accounts == null) await GetAmounts(true); // Populate Accounts Cache if (Accounts.ContainsKey(symbol)) { - JToken accountWalletAddress = await this.MakeJsonRequestAsync($"/accounts/{Accounts[symbol]}/addresses", BaseURLV2); + JToken accountWalletAddress = await this.MakeJsonRequestAsync($"/accounts/{Accounts[symbol]}/addresses", BaseUrlV2); return new ExchangeDepositDetails { Address = accountWalletAddress[0]["address"].ToStringInvariant(), Currency = symbol }; // We only support a single Wallet/Address (Coinbase is the only Exchange that has multiple) } throw new APIException($"Address not found for {symbol}"); @@ -366,242 +335,283 @@ protected override async Task> OnGetDepositHist return await GetTx(false, currency); } - /// - /// WARNING: Only Advanced Trade Open Orders are supported. - /// - /// - /// + + // Warning: Max Open orders returned is 1000, which shouldn't be a problem. If it is (yikes), this can be replaced with the WebSocket User Channel. protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) { List orders = new List(); - // Max return count is 1000 with no pagination available - JToken array = await MakeJsonRequestAsync("/orders/historical/batch?order_status=OPEN" + marketSymbol == null || marketSymbol == string.Empty ? string.Empty : "&product_id=" + marketSymbol ); - foreach (JToken order in array) if (order["type"].ToStringInvariant().Equals(ADVFILL)) orders.Add(ParseOrder(order)); + pagination = PaginationType.V3; + string uri = string.IsNullOrEmpty(marketSymbol) ? "/orders/historical/batch?order_status=OPEN" : $"/orders/historical/batch?product_id={marketSymbol}&order_status=OPEN"; // Parameter order is critical + JToken token = await MakeJsonRequestAsync(uri); + while(true) + { + foreach (JToken order in token[ORDERS]) if (order[TYPE].ToStringInvariant().Equals(ADVFILL)) orders.Add(ParseOrder(order)); + if (string.IsNullOrEmpty(cursorNext)) break; + token = await MakeJsonRequestAsync(uri + "&cursor=" + cursorNext); + } + pagination = PaginationType.None; return orders; } - /// - /// WARNING: Only Advanced Trade Completed Orders are supported. - /// - /// - /// - /// protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) { - // Legacy Orders may be retrieved using V2 (not implemented here - see GetTx in code below) List orders = new List(); - pagination = PaginationType.V3Cursor; - string startURL = "/orders/historical/fills"; - - if (!string.IsNullOrEmpty(marketSymbol)) startURL += "?product_id=" + marketSymbol.ToStringUpperInvariant(); - if (afterDate != null) startURL += marketSymbol == null ? "?" : "&" + "start_sequence_timestamp=" + ((DateTimeOffset)afterDate).ToUnixTimeSeconds(); - JToken token = await MakeJsonRequestAsync(startURL); - startURL += marketSymbol == null && afterDate == null ? "?" : "&" + "cursor="; + pagination = PaginationType.V3; + string uri = string.IsNullOrEmpty(marketSymbol) ? "/orders/historical/batch?order_status=FILLED" : $"/orders/historical/batch?product_id={marketSymbol}&order_status=OPEN"; // Parameter order is critical + JToken token = await MakeJsonRequestAsync(uri); while(true) { - foreach (JToken fill in token["fills"]) - { - orders.Add(new ExchangeOrderResult() - { - MarketSymbol = fill[PRODUCTID].ToStringInvariant(), - TradeId = fill["trade_id"].ToStringInvariant(), - OrderId = fill["order_id"].ToStringInvariant(), - OrderDate = fill["trade_time"].ToDateTimeInvariant(), - IsBuy = fill["side"].ToStringInvariant() == "buy", - Amount = fill[SIZE].ConvertInvariant(), - AmountFilled = fill[SIZE].ConvertInvariant(), - Price = fill[PRICE].ConvertInvariant(), - Fees = fill["commission"].ConvertInvariant(), - AveragePrice = fill[PRICE].ConvertInvariant() - }); - } + foreach (JToken order in token[ORDERS]) orders.Add(ParseOrder(order)); if (string.IsNullOrEmpty(cursorNext)) break; - token = await MakeJsonRequestAsync(startURL + cursorNext); - } + token = await MakeJsonRequestAsync(uri + "&cursor=" + cursorNext); + } pagination = PaginationType.None; return orders; } - protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) - { + protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { JToken obj = await MakeJsonRequestAsync("/orders/historical/" + orderId); - return ParseOrder(obj); - } + return ParseOrder(obj["order"]); + } + + /// + /// This supports two Entries in the Order ExtraParameters: + /// "post_only" : bool (defaults to false if does not exist) + /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC + /// + /// + /// + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + Dictionary payload = new Dictionary(); - protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) - { - Dictionary payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; - await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); - } + // According to the V3 Docs, a Unique Client OrderId is required. Currently this doesn't seem to be enforced by the API, but... + // If not set by the client give them one instead of throwing an exception. Uncomment below if you would rather not. + //if (string.IsNullOrEmpty(order.ClientOrderId)) throw new ApplicationException("Client Order Id is required"); + if (string.IsNullOrEmpty(order.ClientOrderId)) { order.ClientOrderId = Guid.NewGuid().ToString(); } - /// - /// This supports two Entries in the Order ExtraParameters: - /// "post_only" : true/false (defaults to false if does not exist) - /// "gtd_timestamp : datetime (determines GTD order type if exists, otherwise GTC - /// - /// - /// - protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) - { - Dictionary configuration = new Dictionary(); - switch (order.OrderType) - { - case OrderType.Limit: - if (order.ExtraParameters.ContainsKey("gtd_timestamp")) - { - configuration.Add("limit_limit_gtd", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - }); - } - else - { - configuration.Add("limit_limit_gtc", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - }); - } - break; - case OrderType.Stop: - if (order.ExtraParameters.ContainsKey("gtd_timestamp")) - { - configuration.Add("stop_limit_stop_limit_gtd", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"stop_price", order.StopPrice.ToStringInvariant() }, - {"end_time", ((DateTimeOffset)order.ExtraParameters["gtd_timestamp"].ToDateTimeInvariant()).ToUnixTimeSeconds().ToString() }, // This is a bit convoluted? Is this the right format? - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? - }); - } - else - { - configuration.Add("stop_limit_stop_limit_gtc", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() }, - {"limit_price", order.Price.ToStringInvariant() }, - {"stop_price", order.StopPrice.ToStringInvariant() }, - {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } - //{"stop_direction", "UNKNOWN_STOP_DIRECTION" } // set stop direction? - }); - } - break; - case OrderType.Market: - configuration.Add("market_market_ioc", new Dictionary() - { - {"base_size", order.Amount.ToStringInvariant() } - }); - break; - } + payload["client_order_id"] = order.ClientOrderId; + payload["product_id"] = order.MarketSymbol; + payload["side"] = order.IsBuy ? BUY : "SELL"; - Dictionary payload = new Dictionary { { "order_configuration", configuration} }; - string side = order.IsBuy ? "buy" : "sell"; - JToken result = await MakeJsonRequestAsync($"/orders?product_id={order.MarketSymbol.ToUpperInvariant()}&side={side}", payload: payload, requestMethod: "POST"); + Dictionary orderConfig = new Dictionary(); + switch (order.OrderType) + { + case OrderType.Limit: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + { + orderConfig.Add("limit_limit_gtd", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"end_time", order.ExtraParameters["gtd_timestamp"] }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", false) } + }); + } + else + { + orderConfig.Add("limit_limit_gtc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"post_only", order.ExtraParameters.TryGetValueOrDefault( "post_only", "false") } + }); + } + break; + case OrderType.Stop: + if (order.ExtraParameters.ContainsKey("gtd_timestamp")) + { + orderConfig.Add("stop_limit_stop_limit_gtd", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + {"end_time", order.ExtraParameters["gtd_timestamp"] }, + }); + } + else + { + orderConfig.Add("stop_limit_stop_limit_gtc", new Dictionary() + { + {"base_size", order.Amount.ToStringInvariant() }, + {"limit_price", order.Price.ToStringInvariant() }, + {"stop_price", order.StopPrice.ToStringInvariant() }, + }); + } + break; + case OrderType.Market: + if (order.IsBuy) orderConfig.Add("market_market_ioc", new Dictionary() { { "quote_size", order.Amount.ToStringInvariant() }}); + else orderConfig.Add("market_market_ioc", new Dictionary() { { "base_size", order.Amount.ToStringInvariant() }}); + break; + } - // We don't have the proper return type for the POST - will probably require a separate parsing function and return Success/Fail - return ParseOrder(result); - } - protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) - { - return base.OnWithdrawAsync(withdrawalRequest); - } + payload.Add("order_configuration", orderConfig); + + try + { + JToken result = await MakeJsonRequestAsync($"/orders", payload: payload, requestMethod: "POST" ); + // The Post doesn't return with any status, just a new OrderId. To get the Order Details we have to reQuery. + return await OnGetOrderDetailsAsync(result[ORDERID].ToStringInvariant()); + } + catch (Exception ex) // All fails come back with an exception. + { + var token = JToken.Parse(ex.Message); + return new ExchangeOrderResult(){ Result = ExchangeAPIOrderResult.Rejected, ClientOrderId = order.ClientOrderId, ResultCode = token["error_response"]["error"].ToStringInvariant() }; + } + } + protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { + Dictionary payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; + await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); + } - #endregion + protected override Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) + { + return base.OnWithdrawAsync(withdrawalRequest); + } + + #endregion #region SocketEndpoints protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) { - return base.OnGetDeltaOrderBookWebSocketAsync(callback); + return ConnectWebSocketAsync("/", (_socket, msg) => + { + JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); + if (tokens[EVENTS][0][TYPE] == null || tokens[EVENTS][0]["updates"] == null ) return Task.CompletedTask; + + string type = tokens[EVENTS][0][TYPE].ToStringInvariant(); + if (type.Equals("update") || type.Equals("snapshot")) + { + var book = new ExchangeOrderBook(){ MarketSymbol = tokens[EVENTS][0][PRODUCTID].ToStringInvariant(), LastUpdatedUtc = DateTime.UtcNow, SequenceId = tokens["sequence_num"].ConvertInvariant() }; + int askCount = 0, bidCount = 0; + foreach(var token in tokens[EVENTS][0]["updates"]) + { + if (token[SIDE].ToStringInvariant().Equals("bid")) + { + if (bidCount++ < maxCount) + { + decimal price = token[PRICELEVEL].ConvertInvariant(); + book.Bids.Add( price, new ExchangeOrderPrice(){ Price = price, Amount=token["new_quantity"].ConvertInvariant()} ); + } + } + else if (token[SIDE].ToStringInvariant().Equals("offer")) // One would think this would be 'ask' but no... + { + if (askCount++ < maxCount) + { + decimal price = token[PRICELEVEL].ConvertInvariant(); + book.Asks.Add( price, new ExchangeOrderPrice(){ Price = price, Amount=token["new_quantity"].ConvertInvariant()} ); + } + } + if (askCount >= maxCount && bidCount >=maxCount) break; + } + callback?.Invoke(book); + } + return Task.CompletedTask; + }, async (_socket) => + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + LEVEL2 + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new + { + type = SUBSCRIBE, + product_ids = marketSymbols, + channel = LEVEL2, + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); } protected override async Task OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) { - return await ConnectWebSocketAsync("/", async (_socket, msg) => - { - JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); + return await ConnectWebSocketAsync("/", async (_socket, msg) => + { + JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); - var timestamp = tokens["timestamp"].ConvertInvariant(); - List> ticks = new List>(); - foreach(var token in tokens["events"]?[0]?["tickers"]) - { - string product = token["product_id"].ToStringInvariant(); - var split = product.Split(GlobalMarketSymbolSeparator); - ticks.Add(new KeyValuePair(product, new ExchangeTicker() - { - // We don't have Bid or Ask info on this feed - ApiResponse = token, - Last = token["price"].ConvertInvariant(), - Volume = new ExchangeVolume() - { - BaseCurrency = split[0], - QuoteCurrency = split[1], - BaseCurrencyVolume = token["volume_24_h"].ConvertInvariant(), - Timestamp = timestamp - } - } )); - } - callback?.Invoke(ticks); - }, async (_socket) => - { - string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); - string signature = CryptoUtility.SHA256Sign(timestamp + "ticker" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); - var subscribeRequest = new - { - type = "subscribe", - product_ids = marketSymbols, - channel = "ticker", - api_key = PublicApiKey.ToUnsecureString(), - timestamp, - signature - }; - await _socket.SendMessageAsync(subscribeRequest); - }); } + var timestamp = tokens["timestamp"].ConvertInvariant(); + List> ticks = new List>(); + foreach(var token in tokens[EVENTS]?[0]?["tickers"]) + { + string product = token[PRODUCTID].ToStringInvariant(); + var split = product.Split(GlobalMarketSymbolSeparator); + + ticks.Add(new KeyValuePair(product, new ExchangeTicker() + { + // We don't have Bid or Ask info on this feed + ApiResponse = token, + Last = token[PRICE].ConvertInvariant(), + Volume = new ExchangeVolume() + { + BaseCurrency = split[0], + QuoteCurrency = split[1], + BaseCurrencyVolume = token["volume_24_h"].ConvertInvariant(), + Timestamp = timestamp + } + } )); + } + callback?.Invoke(ticks); + }, async (_socket) => + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + TICKER + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new + { + type = SUBSCRIBE, + product_ids = marketSymbols, + channel = TICKER, + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); + } protected override async Task OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) { - if (marketSymbols == null || marketSymbols.Length == 0) marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); - return await ConnectWebSocketAsync("/", async (_socket, msg) => - { - JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); - foreach(var token in tokens["events"]?[0]?["trades"]) - { - await callback?.Invoke(new KeyValuePair(token["product_id"].ToStringInvariant(), new ExchangeTrade() - { - Amount = token["size"].ConvertInvariant(), - Price = token["price"].ConvertInvariant(), - IsBuy = token["side"].ToStringInvariant().Equals("buy"), - Id = token["trade_id"].ToStringInvariant(), - Timestamp = token["time"].ConvertInvariant() - })); - } - }, async (_socket) => - { - string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); - string signature = CryptoUtility.SHA256Sign(timestamp + "market_trades" + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); - var subscribeRequest = new - { - type = "subscribe", - product_ids = marketSymbols, - channel = "market_trades", - api_key = PublicApiKey.ToUnsecureString(), - timestamp, - signature - }; - await _socket.SendMessageAsync(subscribeRequest); - }); - } + if (marketSymbols == null || marketSymbols.Length == 0) marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); + return await ConnectWebSocketAsync("/", async (_socket, msg) => + { + JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); + if (tokens[EVENTS][0][TRADES] == null) return; // This is most likely a subscription confirmation (they don't document this) + foreach(var token in tokens[EVENTS]?[0]?[TRADES]) + { + if (token[TRADEID] == null) continue; + callback?.Invoke(new KeyValuePair(token[PRODUCTID].ToStringInvariant(), new ExchangeTrade() + { + Amount = token[SIZE].ConvertInvariant(), + Price = token[PRICE].ConvertInvariant(), + IsBuy = token[SIDE].ToStringInvariant().Equals(BUY), + Id = token[TRADEID].ToStringInvariant(), + Timestamp = token[TIME].ConvertInvariant() + })); + } + }, async (_socket) => + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + string signature = CryptoUtility.SHA256Sign(timestamp + MARKETTRADES + string.Join(",", marketSymbols), PrivateApiKey.ToUnsecureString()); + var subscribeRequest = new + { + type = SUBSCRIBE, + product_ids = marketSymbols, + channel = MARKETTRADES, + api_key = PublicApiKey.ToUnsecureString(), + timestamp, + signature + }; + await _socket.SendMessageAsync(subscribeRequest); + }); + } - #endregion + #endregion - #region PrivateFunctions + #region PrivateFunctions private async Task> GetAmounts(bool AvailableOnly) { @@ -618,7 +628,7 @@ private async Task> GetAmounts(bool AvailableOnly) decimal amount = AvailableOnly ? account["available_balance"][VALUE].ConvertInvariant() : account["available_balance"][VALUE].ConvertInvariant() + account["hold"][VALUE].ConvertInvariant(); if (amount > 0.0m) amounts[account[CURRENCY].ToStringInvariant()] = amount; } - if (cursorNext == null) break; + if (string.IsNullOrEmpty(cursorNext)) break; token = await MakeJsonRequestAsync("/accounts?starting_after=" + cursorNext); } pagination = PaginationType.None; @@ -636,21 +646,18 @@ private async Task> GetTx(bool Withdrawals, string cur if (Accounts == null) await GetAmounts(true); pagination = PaginationType.V2; List transfers = new List(); - JToken tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions", BaseURLV2); + JToken tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions", BaseUrlV2); while(true) { foreach (JToken token in tokens) { // A "send" to Coinbase is when someone "sent" you coin - or a receive to the rest of the world // Likewise, a "receive" is when someone "received" coin from you. In other words, it's back-asswards. - if (!Withdrawals && token["type"].ToStringInvariant().Equals("send")) transfers.Add(ParseTransaction(token)); - else if (Withdrawals && token["type"].ToStringInvariant().Equals("receive")) transfers.Add(ParseTransaction(token)); - - // Legacy Order and other Coinbase Tx Types can be parsed using this V2 code block - //var tmp = ParseOrder(token); + if (!Withdrawals && token[TYPE].ToStringInvariant().Equals("send")) transfers.Add(ParseTransaction(token)); + else if (Withdrawals && token[TYPE].ToStringInvariant().Equals("receive")) transfers.Add(ParseTransaction(token)); } if (string.IsNullOrEmpty(cursorNext)) break; - tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions?starting_after={cursorNext}", BaseURLV2); + tokens = await MakeJsonRequestAsync($"accounts/{Accounts[currency]}/transactions?starting_after={cursorNext}", BaseUrlV2); } pagination = PaginationType.None; return transfers; @@ -671,7 +678,7 @@ private ExchangeTransaction ParseTransaction(JToken token) Currency = token[AMOUNT][CURRENCY].ToStringInvariant(), Amount = token[AMOUNT][AMOUNT].ConvertInvariant(), Timestamp = token["created_at"].ToObject(), - Status = token["status"].ToStringInvariant() == "completed" ? TransactionStatus.Complete : TransactionStatus.Unknown, + Status = token[STATUS].ToStringInvariant() == "completed" ? TransactionStatus.Complete : TransactionStatus.Unknown, Notes = token["description"].ToStringInvariant() // Address // AddressTag @@ -679,86 +686,29 @@ private ExchangeTransaction ParseTransaction(JToken token) }; } - - /// - /// Parse both Advanced Trade and Legacy Transactions - /// - /// - /// private ExchangeOrderResult ParseOrder(JToken result) { - decimal amount = 0, amountFilled = 0, price = 0, fees = 0; - string marketSymbol = string.Empty; - bool isBuy = true; - - //Debug.WriteLine(result["type"].ToStringInvariant()); - switch(result["type"].ToStringInvariant()) - { - case ADVFILL: - // Buys/Sells have reversed amounts? - - - break; - case "send": - case "receive": - return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; - case "buy": - case "sell": - case "trade": - case "request": - case "transfer": - - case "exchange_deposit": - case "fiat_deposit": - case "fiat_withdrawal": - case "pro_withdrawal": - case "vault_withdrawal": - default: - return new ExchangeOrderResult {OrderId = result["id"].ToStringInvariant(), Message = result["type"].ToStringInvariant(), }; - } - - amount = result[AMOUNT][AMOUNT].ConvertInvariant(amountFilled); - amountFilled = amount; - - price = result[ADVFILL]["fill_price"].ConvertInvariant(); - fees = result[ADVFILL]["commission"].ConvertInvariant(); - marketSymbol = result[ADVFILL][PRODUCTID].ToStringInvariant(result["id"].ToStringInvariant()); - isBuy = (result[ADVFILL]["order_side"].ToStringInvariant() == "buy"); - - ExchangeOrderResult order = new ExchangeOrderResult() - { - IsBuy = isBuy, - Amount = amount, - AmountFilled = amountFilled, - Price = price, - Fees = fees, - FeesCurrency = result["native_amount"]["currency"].ToStringInvariant(), - OrderDate = result["created_at"].ToDateTimeInvariant(), - CompletedDate = result["updated_at"].ToDateTimeInvariant(), - MarketSymbol = marketSymbol, - OrderId = result["id"].ToStringInvariant(), - Message = result["type"].ToStringInvariant() - }; - - switch (result["status"].ToStringInvariant()) - { - case "completed": - order.Result = ExchangeAPIOrderResult.Filled; - break; - case "waiting_for_clearing": - case "waiting_for_signature": - case "pending": - order.Result = ExchangeAPIOrderResult.PendingOpen; - break; - case "expired": - case "canceled": - order.Result = ExchangeAPIOrderResult.Canceled; - break; - default: - order.Result = ExchangeAPIOrderResult.Unknown; - break; - } - return order; + return new ExchangeOrderResult + { + OrderId = result[ORDERID].ToStringInvariant(), + ClientOrderId = result["client_order_id"].ToStringInvariant(), + MarketSymbol = result[PRODUCTID].ToStringInvariant(), + Fees = result["total_fees"].ConvertInvariant(), + OrderDate = result["created_time"].ToDateTimeInvariant(), + CompletedDate = result["last_fill_time"].ToDateTimeInvariant(), + AmountFilled = result["filled_size"].ConvertInvariant(), + AveragePrice = result["average_filled_price"].ConvertInvariant(), + IsBuy = result[SIDE].ToStringInvariant() == BUY, + Result = result[STATUS].ToStringInvariant() switch + { + "FILLED" => ExchangeAPIOrderResult.Filled, + "OPEN" => ExchangeAPIOrderResult.Open, + "CANCELLED" => ExchangeAPIOrderResult.Canceled, + "EXPIRED" => ExchangeAPIOrderResult.Expired, + "FAILED" => ExchangeAPIOrderResult.Rejected, + _ => ExchangeAPIOrderResult.Unknown, + } + }; } #endregion diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI_Const.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI_Const.cs new file mode 100644 index 00000000..fecffefd --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI_Const.cs @@ -0,0 +1,34 @@ +namespace ExchangeSharp +{ + public sealed partial class ExchangeCoinbaseAPI + { + private const string ADVFILL = "advanced_trade_fill"; + private const string AMOUNT = "amount"; + private const string ASKS = "asks"; + private const string BIDS = "bids"; + private const string BUY = "BUY"; + private const string CURRENCY = "currency"; + private const string CURSOR = "cursor"; + private const string EVENTS = "events"; + private const string LEVEL2 = "level2"; + private const string MARKETTRADES = "market_trades"; + private const string ORDERID = "order_id"; + private const string ORDERS = "orders"; + private const string PRICE = "price"; + private const string PRICEBOOK = "pricebook"; + private const string PRICEBOOKS = "pricebooks"; + private const string PRICELEVEL = "price_level"; + private const string PRODUCTID = "product_id"; + private const string PRODUCTS = "products"; + private const string SIDE = "side"; + private const string SIZE = "size"; + private const string STATUS = "status"; + private const string SUBSCRIBE = "subscribe"; + private const string TICKER = "ticker"; + private const string TIME = "time"; + private const string TRADEID = "trade_id"; + private const string TRADES = "trades"; + private const string TYPE = "type"; + private const string VALUE = "value"; + } +} From 34a0426739e2232f7df8d3e1e7280c4f22cd6554 Mon Sep 17 00:00:00 2001 From: Bob DeCuir Date: Tue, 5 Dec 2023 14:14:51 -0500 Subject: [PATCH 10/10] Fixed Socket URL on Connect to new format --- .../API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index 452b98f8..81ba7ac3 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -478,7 +478,7 @@ protected override Task OnWithdrawAsync(ExchangeWith protected override Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 100, params string[] marketSymbols) { - return ConnectWebSocketAsync("/", (_socket, msg) => + return ConnectWebSocketAsync(BaseUrlWebSocket, (_socket, msg) => { JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); if (tokens[EVENTS][0][TYPE] == null || tokens[EVENTS][0]["updates"] == null ) return Task.CompletedTask; @@ -530,7 +530,7 @@ protected override Task OnGetDeltaOrderBookWebSocketAsync(Action OnGetTickersWebSocketAsync(Action>> callback, params string[] marketSymbols) { - return await ConnectWebSocketAsync("/", async (_socket, msg) => + return await ConnectWebSocketAsync(BaseUrlWebSocket, async (_socket, msg) => { JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); @@ -576,7 +576,7 @@ protected override async Task OnGetTickersWebSocketAsync(Action OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) { if (marketSymbols == null || marketSymbols.Length == 0) marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); - return await ConnectWebSocketAsync("/", async (_socket, msg) => + return await ConnectWebSocketAsync(BaseUrlWebSocket, async (_socket, msg) => { JToken tokens = JToken.Parse(msg.ToStringFromUTF8()); if (tokens[EVENTS][0][TRADES] == null) return; // This is most likely a subscription confirmation (they don't document this)