diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs index bc9a7e6d..81ba7ac3 100644 --- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs @@ -10,1171 +10,710 @@ 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; +using System.Collections.Generic; +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 - { - 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 - ); - } - - 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 - ) + /// + /// 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 partial class ExchangeCoinbaseAPI : ExchangeAPI + { + 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} + private PaginationType pagination = PaginationType.None; + private string cursorNext; + + private Dictionary Accounts = null; // Cached Account IDs + + private ExchangeCoinbaseAPI() + { + MarketSymbolIsUppercase = true; + 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) + { + 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 + + /// + /// 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); + } + + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) { - if (marketSymbols == null || marketSymbols.Length == 0) + if (CanMakeAuthenticatedRequest(payload)) { - 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" - ); - } + 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); - 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(); - } + // 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()); - 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; + 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 OnGetOrderBookAsync( - string marketSymbol, - int maxCount = 50 - ) + /// + /// 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) { - 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 (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); + } + + #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); + 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 + "&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 + "/ticker?limit=" + limit); + List tradeList = new List(); + foreach (JToken trade in trades[TRADES]) tradeList.Add(trade.ParseTrade(SIZE, PRICE, SIDE, TIME, TimestampType.Iso8601UTC, TRADEID)); + return tradeList; + } + + 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, ((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 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: 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(); + 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; + } + + protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) + { + List orders = new List(); + 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 order in token[ORDERS]) orders.Add(ParseOrder(order)); + if (string.IsNullOrEmpty(cursorNext)) break; + token = await MakeJsonRequestAsync(uri + "&cursor=" + cursorNext); + } + pagination = PaginationType.None; + return orders; + } + + protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) { - 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() + JToken obj = await MakeJsonRequestAsync("/orders/historical/" + orderId); + 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(); + + // 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(); } + + payload["client_order_id"] = order.ClientOrderId; + payload["product_id"] = order.MarketSymbol; + payload["side"] = order.IsBuy ? BUY : "SELL"; + + 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; + } + + 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 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 payload = new Dictionary() {{ "order_ids", new [] { orderId } } }; + await MakeJsonRequestAsync("/orders/batch_cancel", payload: payload, requestMethod: "POST"); + } + + 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 ConnectWebSocketAsync(BaseUrlWebSocket, (_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(BaseUrlWebSocket, 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[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) { - Dictionary amounts = new Dictionary( - StringComparer.OrdinalIgnoreCase - ); - JArray array = await MakeJsonRequestAsync( - "/accounts", - null, - await GetNoncePayloadAsync(), - "GET" - ); - foreach (JToken token in array) + if (marketSymbols == null || marketSymbols.Length == 0) marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); + 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) + 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 + + #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)); + } + 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 + }; + } + + private ExchangeOrderResult ParseOrder(JToken result) + { + return new ExchangeOrderResult { - decimal amount = token["available"].ConvertInvariant(); - if (amount > 0m) + 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 { - amounts[token["currency"].ToStringInvariant()] = amount; + "FILLED" => ExchangeAPIOrderResult.Filled, + "OPEN" => ExchangeAPIOrderResult.Open, + "CANCELLED" => ExchangeAPIOrderResult.Canceled, + "EXPIRED" => ExchangeAPIOrderResult.Expired, + "FAILED" => ExchangeAPIOrderResult.Rejected, + _ => ExchangeAPIOrderResult.Unknown, } - } - return amounts; - } - - 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 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 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; - } - - 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; - } - - 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; - } - - 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; - } - } - } + #endregion - protected override async Task OnCancelOrderAsync( - string orderId, - string marketSymbol = null, - bool isClientOrderId = false - ) - { - 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"; - } + public partial class ExchangeName { public const string Coinbase = "Coinbase"; } } 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"; + } +} 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, 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); }