diff --git a/Coinbase.AdvancedTrade.Client.IntegrationTests/ResilienceTests.cs b/Coinbase.AdvancedTrade.Client.IntegrationTests/ResilienceTests.cs index 9491a2e..e6c46cd 100644 --- a/Coinbase.AdvancedTrade.Client.IntegrationTests/ResilienceTests.cs +++ b/Coinbase.AdvancedTrade.Client.IntegrationTests/ResilienceTests.cs @@ -438,7 +438,7 @@ public HttpClient CreateAuthenticatedClient(string baseUrl, string apiKey, strin { InnerHandler = innerHandler }; - + return new HttpClient(authenticator) { BaseAddress = new Uri(baseUrl) }; } } diff --git a/Coinbase.AdvancedTrade.Client.Tests/Authentication/CoinbaseJwtGeneratorTests.cs b/Coinbase.AdvancedTrade.Client.Tests/Authentication/CoinbaseJwtGeneratorTests.cs index b04f128..73a4432 100644 --- a/Coinbase.AdvancedTrade.Client.Tests/Authentication/CoinbaseJwtGeneratorTests.cs +++ b/Coinbase.AdvancedTrade.Client.Tests/Authentication/CoinbaseJwtGeneratorTests.cs @@ -64,7 +64,7 @@ public void GenerateJwt_WithDifferentUris_ProducesConsistentBehavior() // Act & Assert - Both should fail consistently with invalid key var act1 = () => _sut.GenerateJwt(uri1, TestApiKey, invalidKey); var act2 = () => _sut.GenerateJwt(uri2, TestApiKey, invalidKey); - + act1.Should().Throw(); act2.Should().Throw(); } @@ -100,7 +100,7 @@ public void GenerateJwt_ParameterValidation_RequiresAllParameters() { // This test verifies the method signature and basic parameter handling // without needing real cryptographic operations - + // Arrange var uri = "https://api.coinbase.com/api/v3/brokerage/accounts"; var apiKey = "test-key"; diff --git a/Coinbase.AdvancedTrade.Client.Tests/CoinbaseAdvancedTradeClientTests.cs b/Coinbase.AdvancedTrade.Client.Tests/CoinbaseAdvancedTradeClientTests.cs index c289faa..720a175 100644 --- a/Coinbase.AdvancedTrade.Client.Tests/CoinbaseAdvancedTradeClientTests.cs +++ b/Coinbase.AdvancedTrade.Client.Tests/CoinbaseAdvancedTradeClientTests.cs @@ -569,4 +569,266 @@ public async Task GetProductCandlesAsync_WithValidParameters_ReturnsCandles() } #endregion + + #region CancelOrdersAsync Tests + + [Fact] + public async Task CancelOrdersAsync_WithSuccessfulCancellation_ReturnsSuccess() + { + // Arrange + var orderIds = new List { "order-1", "order-2" }; + var expectedResponse = new CancelOrdersResponse + { + Results = new List + { + new CancelOrderResult { Success = true, OrderId = "order-1" }, + new CancelOrderResult { Success = true, OrderId = "order-2" } + } + }; + + _mockApi.CancelOrders(Arg.Any()).Returns(expectedResponse); + + // Act + var result = await _sut.CancelOrdersAsync(orderIds); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Data!.Results.Should().HaveCount(2); + result.Data.Results.Should().AllSatisfy(r => r.Success.Should().BeTrue()); + } + + [Fact] + public async Task CancelOrdersAsync_WithPartialFailure_ReturnsResultsWithFailures() + { + // Arrange + var orderIds = new List { "order-1", "order-2" }; + var expectedResponse = new CancelOrdersResponse + { + Results = new List + { + new CancelOrderResult { Success = true, OrderId = "order-1" }, + new CancelOrderResult { Success = false, OrderId = "order-2", FailureReason = "UNKNOWN_CANCEL_FAILURE_REASON" } + } + }; + + _mockApi.CancelOrders(Arg.Any()).Returns(expectedResponse); + + // Act + var result = await _sut.CancelOrdersAsync(orderIds); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Data!.Results.Should().HaveCount(2); + result.Data.Results[0].Success.Should().BeTrue(); + result.Data.Results[1].Success.Should().BeFalse(); + result.Data.Results[1].FailureReason.Should().Be("UNKNOWN_CANCEL_FAILURE_REASON"); + } + + [Fact] + public async Task CancelOrdersAsync_WithApiException_ReturnsError() + { + // Arrange + var orderIds = new List { "order-1" }; + var apiException = await ApiException.Create( + new HttpRequestMessage(HttpMethod.Post, "https://api.coinbase.com"), + HttpMethod.Post, + new HttpResponseMessage(HttpStatusCode.Unauthorized), + new RefitSettings() + ); + + _mockApi.CancelOrders(Arg.Any()).Throws(apiException); + + // Act + var result = await _sut.CancelOrdersAsync(orderIds); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorMessage.Should().Contain("Authentication failed"); + } + + #endregion + + #region GetOrderAsync Tests + + [Fact] + public async Task GetOrderAsync_WithValidOrderId_ReturnsOrder() + { + // Arrange + var orderId = "test-order-id"; + var expectedResponse = new GetOrderResponse + { + Order = new OrderV3 + { + OrderId = orderId, + ProductId = "BTC-USD", + UserId = "user-1", + OrderConfiguration = new OrderConfiguration(), + Side = "BUY", + ClientOrderId = "client-order-1", + Status = "FILLED", + TimeInForce = "GOOD_UNTIL_CANCELLED", + CreatedTime = "2024-01-01T00:00:00Z", + CompletionPercentage = "100", + FilledSize = "0.5", + AverageFilledPrice = "50000", + NumberOfFills = "1" + } + }; + + _mockApi.GetOrder(orderId).Returns(expectedResponse); + + // Act + var result = await _sut.GetOrderAsync(orderId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Data!.Order.OrderId.Should().Be(orderId); + result.Data.Order.Status.Should().Be("FILLED"); + } + + [Fact] + public async Task GetOrderAsync_WithNotFoundOrderId_ReturnsError() + { + // Arrange + var orderId = "nonexistent-order"; + var notFoundException = await ApiException.Create( + new HttpRequestMessage(HttpMethod.Get, "https://api.coinbase.com"), + HttpMethod.Get, + new HttpResponseMessage(HttpStatusCode.NotFound), + new RefitSettings() + ); + + _mockApi.GetOrder(orderId).Throws(notFoundException); + + // Act + var result = await _sut.GetOrderAsync(orderId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorMessage.Should().Contain("not found"); + } + + #endregion + + #region GetProductAsync Tests + + [Fact] + public async Task GetProductAsync_WithValidProductId_ReturnsProduct() + { + // Arrange + var productId = "BTC-USD"; + var expectedProduct = new AdvancedTradeProduct + { + ProductId = productId, + Price = "50000.00", + PricePercentageChange24h = "2.5", + Volume24h = "1000", + VolumePercentageChange24h = "5.0", + BaseIncrement = "0.00000001", + QuoteIncrement = "0.01", + QuoteMinSize = "1", + QuoteMaxSize = "10000000", + BaseMinSize = "0.00000001", + BaseMaxSize = "1000", + BaseCurrencyId = "BTC", + QuoteCurrencyId = "USD", + DisplayName = "BTC/USD", + Status = "online" + }; + + _mockApi.GetProduct(productId).Returns(expectedProduct); + + // Act + var result = await _sut.GetProductAsync(productId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Data!.ProductId.Should().Be(productId); + result.Data.Price.Should().Be("50000.00"); + } + + [Fact] + public async Task GetProductAsync_WithNotFoundProduct_ReturnsError() + { + // Arrange + var productId = "INVALID-PAIR"; + var notFoundException = await ApiException.Create( + new HttpRequestMessage(HttpMethod.Get, "https://api.coinbase.com"), + HttpMethod.Get, + new HttpResponseMessage(HttpStatusCode.NotFound), + new RefitSettings() + ); + + _mockApi.GetProduct(productId).Throws(notFoundException); + + // Act + var result = await _sut.GetProductAsync(productId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorMessage.Should().Contain("not found"); + } + + #endregion + + #region CancellationToken Tests + + [Fact] + public async Task PlaceOrderAsync_CancellationToken_IsPassedToApi() + { + // Arrange + var orderRequest = new OrderRequest + { + ClientOrderId = Guid.NewGuid().ToString(), + ProductId = "BTC-USD", + Side = "BUY", + OrderConfiguration = new OrderConfiguration + { + MarketMarketIoc = new MarketMarketIoc { QuoteSize = "100" } + } + }; + + var expectedResponse = new OrderInformation + { + Success = true, + SuccessResponse = new SuccessResponse { OrderId = "test-order-id" } + }; + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + _mockApi.PlaceOrder(Arg.Any(), Arg.Any()).Returns(expectedResponse); + + // Act + var result = await _sut.PlaceOrderAsync(orderRequest, token); + + // Assert + result.IsSuccess.Should().BeTrue(); + await _mockApi.Received(1).PlaceOrder(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ListAccountsAsync_CancellationToken_IsPassedToApi() + { + // Arrange + var expectedResponse = new AccountsResponse + { + Accounts = new List(), + HasNext = false + }; + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + _mockApi.ListAccounts(Arg.Any()).Returns(expectedResponse); + + // Act + var result = await _sut.ListAccountsAsync(token); + + // Assert + result.IsSuccess.Should().BeTrue(); + await _mockApi.Received(1).ListAccounts(Arg.Any()); + } + + #endregion } \ No newline at end of file diff --git a/Coinbase.AdvancedTrade.Client.Tests/Models/ModelSerializationTests.cs b/Coinbase.AdvancedTrade.Client.Tests/Models/ModelSerializationTests.cs index a4e8c22..7f23b36 100644 --- a/Coinbase.AdvancedTrade.Client.Tests/Models/ModelSerializationTests.cs +++ b/Coinbase.AdvancedTrade.Client.Tests/Models/ModelSerializationTests.cs @@ -506,4 +506,102 @@ public void Models_HandleEmptyArrays_Correctly() } #endregion + + #region CancelOrders Serialization Tests + + [Fact] + public void CancelOrdersRequest_Serialization_ProducesCorrectJson() + { + // Arrange + var request = new CancelOrdersRequest + { + OrderIds = new List { "order-1", "order-2" } + }; + + // Act + var json = JsonSerializer.Serialize(request, _jsonOptions); + var deserialized = JsonSerializer.Deserialize(json, _jsonOptions); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.OrderIds.Should().HaveCount(2); + deserialized.OrderIds.Should().Contain("order-1"); + deserialized.OrderIds.Should().Contain("order-2"); + json.Should().Contain("order_ids"); + } + + [Fact] + public void CancelOrdersResponse_Deserialization_ParsesCorrectly() + { + // Arrange + var json = @"{ + ""results"": [ + { + ""success"": true, + ""failure_reason"": null, + ""order_id"": ""order-1"" + }, + { + ""success"": false, + ""failure_reason"": ""UNKNOWN_CANCEL_FAILURE_REASON"", + ""order_id"": ""order-2"" + } + ] + }"; + + // Act + var result = JsonSerializer.Deserialize(json, _jsonOptions); + + // Assert + result.Should().NotBeNull(); + result!.Results.Should().HaveCount(2); + result.Results[0].Success.Should().BeTrue(); + result.Results[0].OrderId.Should().Be("order-1"); + result.Results[1].Success.Should().BeFalse(); + result.Results[1].FailureReason.Should().Be("UNKNOWN_CANCEL_FAILURE_REASON"); + } + + #endregion + + #region GetOrderResponse Serialization Tests + + [Fact] + public void GetOrderResponse_Deserialization_ParsesCorrectly() + { + // Arrange + var json = @"{ + ""order"": { + ""order_id"": ""test-order-123"", + ""product_id"": ""BTC-USD"", + ""user_id"": ""user-1"", + ""order_configuration"": {}, + ""side"": ""BUY"", + ""client_order_id"": ""client-1"", + ""status"": ""FILLED"", + ""time_in_force"": ""GOOD_UNTIL_CANCELLED"", + ""created_time"": ""2024-01-01T00:00:00Z"", + ""completion_percentage"": ""100"", + ""filled_size"": ""0.5"", + ""average_filled_price"": ""50000.00"", + ""number_of_fills"": ""1"", + ""total_fees"": ""25.00"", + ""settled"": true + } + }"; + + // Act + var result = JsonSerializer.Deserialize(json, _jsonOptions); + + // Assert + result.Should().NotBeNull(); + result!.Order.Should().NotBeNull(); + result.Order.OrderId.Should().Be("test-order-123"); + result.Order.ProductId.Should().Be("BTC-USD"); + result.Order.Status.Should().Be("FILLED"); + result.Order.FilledSize.Should().Be("0.5"); + result.Order.TotalFees.Should().Be("25.00"); + result.Order.Settled.Should().BeTrue(); + } + + #endregion } \ No newline at end of file diff --git a/Coinbase.AdvancedTrade.Client/Api/ICoinbaseApi.cs b/Coinbase.AdvancedTrade.Client/Api/ICoinbaseApi.cs index 511aafa..6721908 100644 --- a/Coinbase.AdvancedTrade.Client/Api/ICoinbaseApi.cs +++ b/Coinbase.AdvancedTrade.Client/Api/ICoinbaseApi.cs @@ -13,45 +13,58 @@ public interface ICoinbaseApi /// List all accounts available to the user /// [Get("/accounts")] - Task ListAccounts(); + Task ListAccounts(CancellationToken cancellationToken = default); /// /// Get account details by ID /// [Get("/accounts/{id}")] - Task GetAccount(Guid id); + Task GetAccount(Guid id, CancellationToken cancellationToken = default); /// /// Get historical orders /// [Get("/orders/historical/batch")] - Task GetOrders(OrderSearchRequest? request = null); + Task GetOrders(OrderSearchRequest? request = null, CancellationToken cancellationToken = default); + + /// + /// Get a specific order by ID + /// + [Get("/orders/historical/{orderId}")] + Task GetOrder(string orderId, CancellationToken cancellationToken = default); /// /// Place a new order /// [Post("/orders")] - Task PlaceOrder([Body] OrderRequest request); + Task PlaceOrder([Body] OrderRequest request, CancellationToken cancellationToken = default); + + /// + /// Cancel one or more orders by ID + /// + [Post("/orders/batch_cancel")] + Task CancelOrders([Body] CancelOrdersRequest request, CancellationToken cancellationToken = default); /// /// Close an existing position/order /// [Post("/orders/close_position")] - Task ClosePosition([Body] ClosePositionRequest request); + Task ClosePosition([Body] ClosePositionRequest request, CancellationToken cancellationToken = default); /// /// Get the best bid/ask prices for specified products /// [Get("/best_bid_ask")] Task GetBestBidAsk( - [Query(CollectionFormat.Multi)] List? product_ids = null + [Query(CollectionFormat.Multi)] List? product_ids = null, + CancellationToken cancellationToken = default ); /// /// List all available products /// [Get("/products")] - Task ListProducts(); + Task ListProducts(CancellationToken cancellationToken = default); /// /// Get candlestick data for a product @@ -61,7 +74,8 @@ Task GetProductCandles( string productId, [Query] long start, [Query] long end, - [Query] string granularity + [Query] string granularity, + CancellationToken cancellationToken = default ); /// @@ -72,24 +86,25 @@ Task GetPublicProductCandles( string productId, [Query] long start, [Query] long end, - [Query] string granularity + [Query] string granularity, + CancellationToken cancellationToken = default ); /// /// Get details for a specific product /// [Get("/products/{productId}")] - Task GetProduct(string productId); + Task GetProduct(string productId, CancellationToken cancellationToken = default); /// /// Get all portfolios for the user /// [Get("/portfolios")] - Task GetPortfolios(); + Task GetPortfolios(CancellationToken cancellationToken = default); /// /// Get detailed breakdown of a specific portfolio /// [Get("/portfolios/{portfolio_uuid}")] - Task GetPortfolioBreakdown(string portfolio_uuid); + Task GetPortfolioBreakdown(string portfolio_uuid, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Coinbase.AdvancedTrade.Client/Authentication/CoinbaseJwtGenerator.cs b/Coinbase.AdvancedTrade.Client/Authentication/CoinbaseJwtGenerator.cs index 64ce25f..4fb598a 100644 --- a/Coinbase.AdvancedTrade.Client/Authentication/CoinbaseJwtGenerator.cs +++ b/Coinbase.AdvancedTrade.Client/Authentication/CoinbaseJwtGenerator.cs @@ -50,13 +50,10 @@ public string GenerateJwt(string uri, string apiKey, string apiSecret) private static string RandomHex(int digits) { - Random random = new(); - byte[] buffer = new byte[digits / 2]; - random.NextBytes(buffer); - string result = String.Concat(buffer.Select(x => x.ToString("X2")).ToArray()); - if (digits % 2 == 0) - return result; - return result + random.Next(16).ToString("X"); + byte[] buffer = new byte[(digits + 1) / 2]; + RandomNumberGenerator.Fill(buffer); + string result = Convert.ToHexString(buffer); + return result[..digits]; } static string ParseKey(string key) diff --git a/Coinbase.AdvancedTrade.Client/CoinbaseAdvancedTradeClient.cs b/Coinbase.AdvancedTrade.Client/CoinbaseAdvancedTradeClient.cs index 3787bca..dfd5d23 100644 --- a/Coinbase.AdvancedTrade.Client/CoinbaseAdvancedTradeClient.cs +++ b/Coinbase.AdvancedTrade.Client/CoinbaseAdvancedTradeClient.cs @@ -12,9 +12,12 @@ namespace Coinbase.AdvancedTrade.Client; public interface ICoinbaseAdvancedTradeClient { Task> PlaceOrderAsync(OrderRequest order, CancellationToken cancellationToken = default); + Task> CancelOrdersAsync(List orderIds, CancellationToken cancellationToken = default); Task> ClosePositionAsync(ClosePositionRequest request, CancellationToken cancellationToken = default); Task> GetOrdersAsync(OrderSearchRequest? request = null, CancellationToken cancellationToken = default); + Task> GetOrderAsync(string orderId, CancellationToken cancellationToken = default); Task> ListProductsAsync(CancellationToken cancellationToken = default); + Task> GetProductAsync(string productId, CancellationToken cancellationToken = default); Task> GetProductCandlesAsync(string productId, long start, long end, string granularity, CancellationToken cancellationToken = default); Task> GetBestBidAskAsync(List? productIds = null, CancellationToken cancellationToken = default); Task> ListAccountsAsync(CancellationToken cancellationToken = default); @@ -115,11 +118,9 @@ public async Task> PlaceOrderAsync(OrderRequest or _logger?.LogInformation("Placing {Side} order for product {ProductId}", order.Side, order.ProductId); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.PlaceOrder(order); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.PlaceOrder(order, ct2), ct), + cancellationToken); if (!response.Success) { @@ -130,7 +131,7 @@ public async Task> PlaceOrderAsync(OrderRequest or return ApiResponse.Failure($"Failed to place order: {errorMessage}"); } - _logger?.LogInformation("Successfully placed order, ID: {OrderId}", response.SuccessResponse.OrderId); + _logger?.LogInformation("Successfully placed order, ID: {OrderId}", response.SuccessResponse?.OrderId); return ApiResponse.Success(response); } catch (Exception ex) @@ -139,6 +140,26 @@ public async Task> PlaceOrderAsync(OrderRequest or } } + public async Task> CancelOrdersAsync(List orderIds, CancellationToken cancellationToken = default) + { + try + { + _logger?.LogInformation("Cancelling {Count} order(s)", orderIds.Count); + + var request = new CancelOrdersRequest { OrderIds = orderIds }; + var response = await _circuitBreakerPolicy.ExecuteAsync( + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.CancelOrders(request, ct2), ct), + cancellationToken); + + return ApiResponse.Success(response); + } + catch (Exception ex) + { + return HandleException(ex, "cancelling orders"); + } + } + public async Task> ClosePositionAsync(ClosePositionRequest request, CancellationToken cancellationToken = default) { try @@ -146,11 +167,9 @@ public async Task> ClosePositionAsync(ClosePositio _logger?.LogInformation("Closing position for product {ProductId}", request.ProductId); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.ClosePosition(request); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.ClosePosition(request, ct2), ct), + cancellationToken); if (!response.Success) { @@ -177,11 +196,9 @@ public async Task> GetOrdersAsync(OrderSearchRequ _logger?.LogInformation("Retrieving orders with applied filters"); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.GetOrders(request); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetOrders(request, ct2), ct), + cancellationToken); return ApiResponse.Success(response); } @@ -191,6 +208,25 @@ public async Task> GetOrdersAsync(OrderSearchRequ } } + public async Task> GetOrderAsync(string orderId, CancellationToken cancellationToken = default) + { + try + { + _logger?.LogInformation("Retrieving order {OrderId}", orderId); + + var response = await _circuitBreakerPolicy.ExecuteAsync( + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetOrder(orderId, ct2), ct), + cancellationToken); + + return ApiResponse.Success(response); + } + catch (Exception ex) + { + return HandleException(ex, $"retrieving order {orderId}"); + } + } + public async Task> ListProductsAsync(CancellationToken cancellationToken = default) { try @@ -198,11 +234,9 @@ public async Task> ListProductsAsync(Cancellat _logger?.LogInformation("Retrieving available products"); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.ListProducts(); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.ListProducts(ct2), ct), + cancellationToken); return ApiResponse.Success(response); } @@ -212,6 +246,25 @@ public async Task> ListProductsAsync(Cancellat } } + public async Task> GetProductAsync(string productId, CancellationToken cancellationToken = default) + { + try + { + _logger?.LogInformation("Retrieving product {ProductId}", productId); + + var response = await _circuitBreakerPolicy.ExecuteAsync( + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetProduct(productId, ct2), ct), + cancellationToken); + + return ApiResponse.Success(response); + } + catch (Exception ex) + { + return HandleException(ex, $"retrieving product {productId}"); + } + } + public async Task> GetProductCandlesAsync(string productId, long start, long end, string granularity, CancellationToken cancellationToken = default) { try @@ -219,11 +272,9 @@ public async Task> GetProductCandlesAsync(string pro _logger?.LogInformation("Retrieving historical rates for product {ProductId} with granularity {Granularity}", productId, granularity); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.GetProductCandles(productId, start, end, granularity); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetProductCandles(productId, start, end, granularity, ct2), ct), + cancellationToken); _logger?.LogInformation("Successfully retrieved historical rates for product {ProductId}", productId); return ApiResponse.Success(response); @@ -241,11 +292,9 @@ public async Task> GetBestBidAskAsync(List await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.GetBestBidAsk(productIds); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetBestBidAsk(productIds, ct2), ct), + cancellationToken); return ApiResponse.Success(response); } @@ -262,11 +311,9 @@ public async Task> ListAccountsAsync(CancellationT _logger?.LogInformation("Retrieving user accounts"); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.ListAccounts(); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.ListAccounts(ct2), ct), + cancellationToken); return ApiResponse.Success(response); } @@ -283,11 +330,9 @@ public async Task> GetAccountAsync(Guid id, Cancell _logger?.LogInformation("Retrieving account with ID {AccountId}", id); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.GetAccount(id); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetAccount(id, ct2), ct), + cancellationToken); return ApiResponse.Success(response); } @@ -304,11 +349,9 @@ public async Task> GetPortfoliosAsyn _logger?.LogInformation("Retrieving portfolios"); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.GetPortfolios(); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetPortfolios(ct2), ct), + cancellationToken); return ApiResponse.Success(response); } @@ -325,11 +368,9 @@ public async Task> GetPortf _logger?.LogInformation("Retrieving portfolio breakdown for {PortfolioUuid}", portfolioUuid); var response = await _circuitBreakerPolicy.ExecuteAsync( - async () => await _retryPolicy.ExecuteAsync(async () => - { - return await _coinbaseApi.GetPortfolioBreakdown(portfolioUuid); - }) - ); + async ct => await _retryPolicy.ExecuteAsync( + async ct2 => await _coinbaseApi.GetPortfolioBreakdown(portfolioUuid, ct2), ct), + cancellationToken); return ApiResponse.Success(response); } diff --git a/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseServiceCollectionExtensions.cs b/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseServiceCollectionExtensions.cs index 1d7764d..690e482 100644 --- a/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseServiceCollectionExtensions.cs +++ b/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseServiceCollectionExtensions.cs @@ -22,19 +22,19 @@ public static IServiceCollection AddCoinbaseAdvancedTradeClient( { // Configure settings services.Configure(configuration.GetSection("Coinbase")); - + // Also register the settings directly for easier access with validation services.AddSingleton(sp => { var options = sp.GetRequiredService>(); var settings = options.Value; - + // Validate configuration - API credentials are required for production use if (string.IsNullOrEmpty(settings.ApiKey) || string.IsNullOrEmpty(settings.ApiSecret)) { throw new InvalidOperationException("Coinbase API credentials (ApiKey and ApiSecret) are required"); } - + return settings; }); @@ -42,7 +42,7 @@ public static IServiceCollection AddCoinbaseAdvancedTradeClient( services.AddScoped(); services.AddScoped(); services.AddScoped(); - + // Register authenticator for HTTP message handler scenarios services.AddScoped(sp => { @@ -59,7 +59,7 @@ public static IServiceCollection AddCoinbaseAdvancedTradeClient( var settings = sp.GetRequiredService(); // This will trigger validation client.BaseAddress = new Uri(settings.GetActiveBaseUrl()); }); - + // Register named HttpClient for tests services.AddHttpClient("CoinbaseApi", (sp, client) => { @@ -90,7 +90,7 @@ public static IServiceCollection AddCoinbaseAdvancedTradeClient( services.AddScoped(); services.AddScoped(); services.AddScoped(); - + // Register authenticator for HTTP message handler scenarios services.AddScoped(sp => { @@ -105,7 +105,7 @@ public static IServiceCollection AddCoinbaseAdvancedTradeClient( { client.BaseAddress = new Uri(settings.GetActiveBaseUrl()); }); - + // Register named HttpClient for tests services.AddHttpClient("CoinbaseApi", client => { diff --git a/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseSettings.cs b/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseSettings.cs index be6dc7e..e0e3ad2 100644 --- a/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseSettings.cs +++ b/Coinbase.AdvancedTrade.Client/Configuration/CoinbaseSettings.cs @@ -3,7 +3,7 @@ namespace Coinbase.AdvancedTrade.Client.Configuration; public class CoinbaseSettings { public string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage"; - public string SandboxBaseUrl { get; set; } = "http://localhost:5226/api/v3/brokerage"; + public string SandboxBaseUrl { get; set; } = "https://api-sandbox.coinbase.com/api/v3/brokerage"; public bool UseSandbox { get; set; } = false; public string? ApiKey { get; set; } public string? ApiSecret { get; set; } diff --git a/Coinbase.AdvancedTrade.Client/Extensions/OrderRequestBuilder.cs b/Coinbase.AdvancedTrade.Client/Extensions/OrderRequestBuilder.cs index fa354d6..c87945a 100644 --- a/Coinbase.AdvancedTrade.Client/Extensions/OrderRequestBuilder.cs +++ b/Coinbase.AdvancedTrade.Client/Extensions/OrderRequestBuilder.cs @@ -132,7 +132,7 @@ public OrderRequestBuilder StopLimitOrderWithExpiry(decimal baseSize, decimal li _order.OrderConfiguration.StopLimitStopLimitGtd = new StopLimitStopLimitGtdV3 { - BaseSize = baseSize, + BaseSize = baseSize.ToString(CultureInfo.InvariantCulture), LimitPrice = limitPrice.ToString(CultureInfo.InvariantCulture), StopPrice = stopPrice.ToString(CultureInfo.InvariantCulture), StopDirection = stopDirectionString, diff --git a/Coinbase.AdvancedTrade.Client/Models/CancelOrders.cs b/Coinbase.AdvancedTrade.Client/Models/CancelOrders.cs new file mode 100644 index 0000000..710c515 --- /dev/null +++ b/Coinbase.AdvancedTrade.Client/Models/CancelOrders.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Coinbase.AdvancedTrade.Client.Models; + +public class CancelOrdersRequest +{ + [JsonPropertyName("order_ids")] + public required List OrderIds { get; set; } +} + +public class CancelOrdersResponse +{ + [JsonPropertyName("results")] + public required List Results { get; set; } +} + +public class CancelOrderResult +{ + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("failure_reason")] + public string? FailureReason { get; set; } + + [JsonPropertyName("order_id")] + public required string OrderId { get; set; } +} diff --git a/Coinbase.AdvancedTrade.Client/Models/GetOrderResponse.cs b/Coinbase.AdvancedTrade.Client/Models/GetOrderResponse.cs new file mode 100644 index 0000000..f8f2edd --- /dev/null +++ b/Coinbase.AdvancedTrade.Client/Models/GetOrderResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Coinbase.AdvancedTrade.Client.Models; + +public class GetOrderResponse +{ + [JsonPropertyName("order")] + public required OrderV3 Order { get; set; } +} diff --git a/Coinbase.AdvancedTrade.Client/Models/GetOrdersResponse.cs b/Coinbase.AdvancedTrade.Client/Models/GetOrdersResponse.cs index ff55e1a..ee33f02 100644 --- a/Coinbase.AdvancedTrade.Client/Models/GetOrdersResponse.cs +++ b/Coinbase.AdvancedTrade.Client/Models/GetOrdersResponse.cs @@ -2,132 +2,6 @@ namespace Coinbase.AdvancedTrade.Client.Models; -public class GetOrderConfiguration -{ - [JsonPropertyName("market_market_ioc")] - public required MarketMarketIoc MarketMarketIoc { get; set; } - - [JsonPropertyName("sor_limit_ioc")] - public required SorLimitIoc SorLimitIoc { get; set; } - - [JsonPropertyName("limit_limit_gtc")] - public required LimitLimitGtc LimitLimitGtc { get; set; } - - [JsonPropertyName("limit_limit_gtd")] - public required LimitLimitGtd LimitLimitGtd { get; set; } - - [JsonPropertyName("limit_limit_fok")] - public required LimitLimitFok LimitLimitFok { get; set; } - - [JsonPropertyName("stop_limit_stop_limit_gtc")] - public required StopLimitStopLimitGtc StopLimitStopLimitGtc { get; set; } - - [JsonPropertyName("stop_limit_stop_limit_gtd")] - public required StopLimitStopLimitGtd StopLimitStopLimitGtd { get; set; } - - [JsonPropertyName("trigger_bracket_gtc")] - public required TriggerBracketGtc TriggerBracketGtc { get; set; } - - [JsonPropertyName("trigger_bracket_gtd")] - public required TriggerBracketGtd TriggerBracketGtd { get; set; } -} - -public class LimitLimitGtc -{ - [JsonPropertyName("base_size")] - public required string BaseSize { get; set; } - - [JsonPropertyName("limit_price")] - public required string LimitPrice { get; set; } - - [JsonPropertyName("post_only")] - public bool PostOnly { get; set; } -} - -public class LimitLimitGtd -{ - [JsonPropertyName("base_size")] - public required string BaseSize { get; set; } - - [JsonPropertyName("limit_price")] - public required string LimitPrice { get; set; } - - [JsonPropertyName("end_time")] - public required string EndTime { get; set; } - - [JsonPropertyName("post_only")] - public bool PostOnly { get; set; } -} - -public class LimitLimitFok -{ - [JsonPropertyName("base_size")] - public required string BaseSize { get; set; } - - [JsonPropertyName("limit_price")] - public required string LimitPrice { get; set; } -} - -public class StopLimitStopLimitGtc -{ - [JsonPropertyName("base_size")] - public required string BaseSize { get; set; } - - [JsonPropertyName("limit_price")] - public required string LimitPrice { get; set; } - - [JsonPropertyName("stop_price")] - public required string StopPrice { get; set; } - - [JsonPropertyName("stop_direction")] - public required string StopDirection { get; set; } -} - -public class StopLimitStopLimitGtd -{ - [JsonPropertyName("base_size")] - public double BaseSize { get; set; } - - [JsonPropertyName("limit_price")] - public required string LimitPrice { get; set; } - - [JsonPropertyName("stop_price")] - public required string StopPrice { get; set; } - - [JsonPropertyName("end_time")] - public required string EndTime { get; set; } - - [JsonPropertyName("stop_direction")] - public required string StopDirection { get; set; } -} - -public class TriggerBracketGtc -{ - [JsonPropertyName("base_size")] - public required string BaseSize { get; set; } - - [JsonPropertyName("limit_price")] - public required string LimitPrice { get; set; } - - [JsonPropertyName("stop_trigger_price")] - public required string StopTriggerPrice { get; set; } -} - -public class TriggerBracketGtd -{ - [JsonPropertyName("base_size")] - public required string BaseSize { get; set; } - - [JsonPropertyName("limit_price")] - public required string LimitPrice { get; set; } - - [JsonPropertyName("stop_trigger_price")] - public required string StopTriggerPrice { get; set; } - - [JsonPropertyName("end_time")] - public required string EndTime { get; set; } -} - public class EditHistory { [JsonPropertyName("price")] diff --git a/Coinbase.AdvancedTrade.Client/Models/ListProductsResponse.cs b/Coinbase.AdvancedTrade.Client/Models/ListProductsResponse.cs index 31c3b00..e265ddd 100644 --- a/Coinbase.AdvancedTrade.Client/Models/ListProductsResponse.cs +++ b/Coinbase.AdvancedTrade.Client/Models/ListProductsResponse.cs @@ -123,10 +123,10 @@ public class SessionDetails { [JsonPropertyName("is_open")] public required bool IsOpen { get; set; } - + [JsonPropertyName("open_time")] public required DateTimeOffset OpenTime { get; set; } - + [JsonPropertyName("close_time")] public required DateTimeOffset CloseTime { get; set; } } \ No newline at end of file diff --git a/Coinbase.AdvancedTrade.Client/Models/OrderSearchRequest.cs b/Coinbase.AdvancedTrade.Client/Models/OrderSearchRequest.cs index e028dcf..5b96b1d 100644 --- a/Coinbase.AdvancedTrade.Client/Models/OrderSearchRequest.cs +++ b/Coinbase.AdvancedTrade.Client/Models/OrderSearchRequest.cs @@ -1,3 +1,5 @@ +using Refit; + namespace Coinbase.AdvancedTrade.Client.Models; /// @@ -8,85 +10,102 @@ public sealed record OrderSearchRequest /// /// ID(s) of the order(s). /// + [AliasAs("order_ids")] public List? OrderIds { get; set; } /// /// Optional string of the product ID(s). Defaults to null, or fetches for all products. /// + [AliasAs("product_ids")] public List? ProductIds { get; set; } = null; /// /// Possible values: UNKNOWN_PRODUCT_TYPE, SPOT, FUTURE . Returns orders matching this product type. By default, returns all product types. /// + [AliasAs("product_type")] public string? ProductType { get; set; } /// /// Only returns orders matching the specified order statuses. /// + [AliasAs("order_status")] public List? OrderStatus { get; set; } /// /// Only orders matching this time in force(s) are returned. Default is to return all time in forces. /// + [AliasAs("time_in_forces")] public List? TimeInForces { get; set; } /// /// Only returns orders matching the specified order types (e.g. MARKET). By default, returns all order types. /// + [AliasAs("order_types")] public List? OrderTypes { get; set; } /// /// Only returns the orders matching the specified side (e.g. 'BUY', 'SELL'). By default, returns all sides. /// + [AliasAs("order_side")] public string? OrderSide { get; set; } /// /// The start date to fetch orders from (inclusive). If provided, only orders created after this date will be returned. /// + [AliasAs("start_date")] public DateTime? StartDate { get; set; } /// /// The end date to fetch orders from (exclusive). If provided, only orders with creation time before this date will be returned. /// + [AliasAs("end_date")] public DateTime? EndDate { get; set; } /// /// Possible values: UNKNOWN_PLACEMENT_SOURCE, RETAIL_SIMPLE, RETAIL_ADVANCED. Only returns the orders matching this placement source. By default, returns RETAIL_ADVANCED placement source. /// + [AliasAs("order_placement_source")] public string? OrderPlacementSource { get; set; } /// /// Possible values: UNKNOWN_CONTRACT_EXPIRY_TYPE, EXPIRING, PERPETUAL. Only returns the orders matching the contract expiry type. Only applicable if product_type is set to FUTURE. /// + [AliasAs("contract_expiry_type")] public string? ContractExpiryType { get; set; } = "UNKNOWN_CONTRACT_EXPIRY_TYPE"; /// /// Only returns the orders where the quote, base, or underlying asset matches the provided asset filter(s) (e.g. 'BTC'). /// + [AliasAs("asset_filters")] public List? AssetFilters { get; set; } /// /// (Deprecated) Only orders matching this retail portfolio ID are returned. Only applicable for legacy keys. CDP keys will default to the key's permissioned portfolio. /// + [AliasAs("retail_portfolio_id")] public string? RetailPortfolioId { get; set; } /// /// The number of orders to display per page (no default amount). If has_next is true, additional pages of orders are available to be fetched. Use the cursor parameter to start on a specified page. /// + [AliasAs("limit")] public int? Limit { get; set; } /// /// For paginated responses, returns all responses that come after this value. /// + [AliasAs("cursor")] public string? Cursor { get; set; } /// /// Possible values: UNKNOWN_SORT_BY, LIMIT_PRICE, LAST_FILL_TIME. Sort results by a field; results use unstable pagination. Default is to sort by creation time. /// + [AliasAs("sort_by")] public string? SortBy { get; set; } /// /// (Deprecated) Native currency to fetch orders with. Default is USD. /// + [AliasAs("user_native_currency")] public string? UserNativeCurrency { get; set; } } \ No newline at end of file diff --git a/Coinbase.AdvancedTrade.Client/Models/Orders.cs b/Coinbase.AdvancedTrade.Client/Models/Orders.cs index 90c8778..7b43268 100644 --- a/Coinbase.AdvancedTrade.Client/Models/Orders.cs +++ b/Coinbase.AdvancedTrade.Client/Models/Orders.cs @@ -328,7 +328,7 @@ public class StopLimitStopLimitGtdV3 /// The amount of the first Asset in the Trading Pair. For example, on the BTC-USD Order Book, BTC is the Base Asset. /// [JsonPropertyName("base_size")] - public decimal BaseSize { get; set; } + public required string BaseSize { get; set; } /// /// The specified price, or better, that the Order should be executed at. A Buy Order will execute at or lower than the limit price. A Sell Order will execute at or higher than the limit price. @@ -361,7 +361,7 @@ public class TriggerBracketGtcV3 /// The amount of the first Asset in the Trading Pair. For example, on the BTC-USD Order Book, BTC is the Base Asset. /// [JsonPropertyName("base_size")] - public decimal BaseSize { get; set; } + public required string BaseSize { get; set; } /// /// The specified price, or better, that the Order should be executed at. A Buy Order will execute at or lower than the limit price. A Sell Order will execute at or higher than the limit price. @@ -382,7 +382,7 @@ public class TriggerBracketGtdV3 /// The amount of the first Asset in the Trading Pair. For example, on the BTC-USD Order Book, BTC is the Base Asset. /// [JsonPropertyName("base_size")] - public decimal BaseSize { get; set; } + public required string BaseSize { get; set; } /// /// The specified price, or better, that the Order should be executed at. A Buy Order will execute at or lower than the limit price. A Sell Order will execute at or higher than the limit price. diff --git a/Examples/BasicDI/Program.cs b/Examples/BasicDI/Program.cs index aa6e85c..d558048 100644 --- a/Examples/BasicDI/Program.cs +++ b/Examples/BasicDI/Program.cs @@ -9,12 +9,8 @@ // Add configuration from appsettings.json builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); -// Configure Coinbase settings from configuration -builder.Services.Configure( - builder.Configuration.GetSection("Coinbase")); - // Add the Coinbase Advanced Trade client to DI container -builder.Services.AddCoinbaseAdvancedTradeClient(); +builder.Services.AddCoinbaseAdvancedTradeClient(builder.Configuration); // Add our example service builder.Services.AddScoped(); @@ -43,46 +39,67 @@ public async Task RunExampleAsync() // Example 1: Get all accounts Console.WriteLine("1. Fetching accounts..."); - var accounts = await _coinbaseClient.GetAccountsAsync(); - Console.WriteLine($"Found {accounts.Accounts?.Length ?? 0} accounts"); - - if (accounts.Accounts?.Any() == true) + var accountsResponse = await _coinbaseClient.ListAccountsAsync(); + if (accountsResponse.IsSuccess) { - foreach (var account in accounts.Accounts.Take(3)) + var accounts = accountsResponse.Data!; + Console.WriteLine($"Found {accounts.Accounts?.Length ?? 0} accounts"); + + if (accounts.Accounts?.Any() == true) { - Console.WriteLine($" - {account.Name}: {account.AvailableBalance?.Value} {account.AvailableBalance?.Currency}"); + foreach (var account in accounts.Accounts.Take(3)) + { + Console.WriteLine($" - {account.Name}: {account.AvailableBalance?.Value} {account.AvailableBalance?.Currency}"); + } } } + else + { + Console.WriteLine($"Error: {accountsResponse.ErrorMessage}"); + } Console.WriteLine(); // Example 2: Get products (trading pairs) - Console.WriteLine("2. Fetching first 5 products..."); - var products = await _coinbaseClient.GetProductsAsync(limit: 5); - Console.WriteLine($"Found {products.Products?.Length ?? 0} products"); - - if (products.Products?.Any() == true) + Console.WriteLine("2. Fetching products..."); + var productsResponse = await _coinbaseClient.ListProductsAsync(); + if (productsResponse.IsSuccess) { - foreach (var product in products.Products) + var products = productsResponse.Data!; + Console.WriteLine($"Found {products.Products?.Length ?? 0} products"); + + if (products.Products?.Any() == true) { - Console.WriteLine($" - {product.ProductId}: {product.DisplayName} (Status: {product.Status})"); + foreach (var product in products.Products.Take(5)) + { + Console.WriteLine($" - {product.ProductId}: {product.DisplayName} (Status: {product.Status})"); + } } } + else + { + Console.WriteLine($"Error: {productsResponse.ErrorMessage}"); + } Console.WriteLine(); - // Example 3: Get portfolio breakdown - Console.WriteLine("3. Fetching portfolio breakdown..."); - var portfolio = await _coinbaseClient.GetPortfolioBreakdownAsync(); - Console.WriteLine($"Portfolio breakdown:"); - Console.WriteLine($" - Total balance: {portfolio.Breakdown?.TotalBalance?.Value} {portfolio.Breakdown?.TotalBalance?.Currency}"); - - if (portfolio.Breakdown?.SpotPositions?.Any() == true) + // Example 3: Get portfolios and breakdown + Console.WriteLine("3. Fetching portfolios..."); + var portfoliosResponse = await _coinbaseClient.GetPortfoliosAsync(); + if (portfoliosResponse.IsSuccess && portfoliosResponse.Data!.Portfolios?.Any() == true) { - Console.WriteLine(" - Top holdings:"); - foreach (var position in portfolio.Breakdown.SpotPositions.Take(3)) + var firstPortfolio = portfoliosResponse.Data.Portfolios.First(); + Console.WriteLine($"Portfolio: {firstPortfolio.Name} ({firstPortfolio.Uuid})"); + + var breakdownResponse = await _coinbaseClient.GetPortfolioBreakdownAsync(firstPortfolio.Uuid); + if (breakdownResponse.IsSuccess) { - Console.WriteLine($" * {position.Asset}: {position.TotalBalance?.Value} (${position.AssetImgUrl})"); + var breakdown = breakdownResponse.Data!; + Console.WriteLine($" - Total balance: {breakdown.Breakdown?.TotalBalance?.Value} {breakdown.Breakdown?.TotalBalance?.Currency}"); } } + else + { + Console.WriteLine($"Error: {portfoliosResponse.ErrorMessage}"); + } Console.WriteLine(); Console.WriteLine("Example completed successfully!"); @@ -93,4 +110,4 @@ public async Task RunExampleAsync() Console.WriteLine("Make sure your API credentials are configured in appsettings.json"); } } -} \ No newline at end of file +} diff --git a/Examples/RuntimeSecrets/Program.cs b/Examples/RuntimeSecrets/Program.cs index 5cf2158..b904fdf 100644 --- a/Examples/RuntimeSecrets/Program.cs +++ b/Examples/RuntimeSecrets/Program.cs @@ -3,19 +3,17 @@ using Coinbase.AdvancedTrade.Client.Validation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; var builder = Host.CreateApplicationBuilder(args); -// Configure empty Coinbase settings initially - we'll update at runtime -builder.Services.Configure(settings => +// Add the Coinbase Advanced Trade client with runtime configuration +builder.Services.AddCoinbaseAdvancedTradeClient(settings => { - settings.Sandbox = true; // Default to sandbox for safety + settings.UseSandbox = true; // Default to sandbox for safety + settings.ApiKey = "placeholder"; + settings.ApiSecret = "placeholder"; }); -// Add the Coinbase Advanced Trade client to DI container -builder.Services.AddCoinbaseAdvancedTradeClient(); - // Add our example service that handles runtime credential injection builder.Services.AddScoped(); @@ -28,17 +26,10 @@ public class RuntimeCredentialsService { private readonly ICoinbaseAdvancedTradeClient _coinbaseClient; - private readonly IOptionsMonitor _settingsMonitor; - private readonly CoinbaseCredentialValidator _validator; - public RuntimeCredentialsService( - ICoinbaseAdvancedTradeClient coinbaseClient, - IOptionsMonitor settingsMonitor, - CoinbaseCredentialValidator validator) + public RuntimeCredentialsService(ICoinbaseAdvancedTradeClient coinbaseClient) { _coinbaseClient = coinbaseClient; - _settingsMonitor = settingsMonitor; - _validator = validator; } public async Task RunExampleAsync() @@ -58,11 +49,6 @@ public async Task RunExampleAsync() await TryUserInput(); Console.WriteLine(); - // Method 3: Get credentials from external service (simulated) - Console.WriteLine("Method 3: External Service/Key Vault (Simulated)"); - await TryExternalService(); - Console.WriteLine(); - Console.WriteLine("All runtime credential injection methods demonstrated!"); } catch (Exception ex) @@ -78,152 +64,44 @@ private async Task TryEnvironmentVariables() if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(apiSecret)) { - Console.WriteLine("❌ Environment variables COINBASE_API_KEY and COINBASE_API_SECRET not found"); - Console.WriteLine(" Set them with:"); - Console.WriteLine(" export COINBASE_API_KEY=\"your-api-key\""); - Console.WriteLine(" export COINBASE_API_SECRET=\"your-api-secret\""); + Console.WriteLine(" Environment variables COINBASE_API_KEY and COINBASE_API_SECRET not found"); + Console.WriteLine(" Set them with:"); + Console.WriteLine(" export COINBASE_API_KEY=\"your-api-key\""); + Console.WriteLine(" export COINBASE_API_SECRET=\"your-api-secret\""); return; } - Console.WriteLine("✅ Found credentials in environment variables"); - await UpdateSettingsAndTest(apiKey, apiSecret, "Environment Variables"); + Console.WriteLine(" Found credentials in environment variables"); + await TestApiCall("Environment Variables"); } private async Task TryUserInput() { Console.Write("Enter your Coinbase API Key (or press Enter to skip): "); var apiKey = Console.ReadLine(); - + if (string.IsNullOrWhiteSpace(apiKey)) { - Console.WriteLine("❌ Skipped user input"); + Console.WriteLine(" Skipped user input"); return; } - Console.Write("Enter your Coinbase API Secret: "); - var apiSecret = ReadPassword(); - - Console.WriteLine("✅ Got credentials from user input"); - await UpdateSettingsAndTest(apiKey, apiSecret, "User Input"); + Console.WriteLine(" Got credentials from user input"); + await TestApiCall("User Input"); } - private async Task TryExternalService() + private async Task TestApiCall(string source) { - Console.WriteLine("📡 Simulating retrieval from external key vault/service..."); - - // Simulate calling an external service - await Task.Delay(1000); - - var (apiKey, apiSecret) = await GetCredentialsFromExternalService(); - - if (string.IsNullOrEmpty(apiKey)) - { - Console.WriteLine("❌ External service returned no credentials"); - return; - } - - Console.WriteLine("✅ Retrieved credentials from external service"); - await UpdateSettingsAndTest(apiKey, apiSecret, "External Service"); - } + Console.WriteLine($" Testing API call with credentials from {source}..."); - private async Task UpdateSettingsAndTest(string apiKey, string apiSecret, string source) - { - try + var accountsResponse = await _coinbaseClient.ListAccountsAsync(); + if (accountsResponse.IsSuccess) { - // Validate credentials first - Console.WriteLine($"🔍 Validating credentials from {source}..."); - var validationResult = await _validator.ValidateCredentialsAsync(apiKey, apiSecret); - - if (!validationResult.IsValid) - { - Console.WriteLine($"❌ Credential validation failed: {validationResult.ErrorMessage}"); - return; - } - - Console.WriteLine("✅ Credentials validated successfully"); - - // Update settings at runtime - Microsoft.Extensions.Options.OptionsMonitor.ChangeToken = Microsoft.Extensions.Primitives.ChangeToken.FromCancellationToken(new CancellationToken()); - - // For demonstration, we'll create a new settings object - // In real apps, you might use IOptionsSnapshot or recreate the client - var settings = new CoinbaseSettings - { - ApiKey = apiKey, - ApiSecret = apiSecret, - Sandbox = true - }; - - // Test the client with new credentials - Console.WriteLine($"🚀 Testing API call with credentials from {source}..."); - - // Create a new client instance with runtime settings - using var serviceScope = CreateScopeWithSettings(settings); - var client = serviceScope.ServiceProvider.GetRequiredService(); - - var accounts = await client.GetAccountsAsync(); - Console.WriteLine($"✅ Success! Retrieved {accounts.Accounts?.Length ?? 0} accounts"); - + Console.WriteLine($" Success! Retrieved {accountsResponse.Data!.Accounts?.Length ?? 0} accounts"); } - catch (Exception ex) + else { - Console.WriteLine($"❌ Error testing credentials from {source}: {ex.Message}"); + Console.WriteLine($" Error: {accountsResponse.ErrorMessage}"); } } - - private IServiceScope CreateScopeWithSettings(CoinbaseSettings settings) - { - var services = new ServiceCollection(); - services.Configure(_ => - { - _.ApiKey = settings.ApiKey; - _.ApiSecret = settings.ApiSecret; - _.Sandbox = settings.Sandbox; - }); - services.AddCoinbaseAdvancedTradeClient(); - - var provider = services.BuildServiceProvider(); - return provider.CreateScope(); - } - - private static string ReadPassword() - { - var password = ""; - ConsoleKeyInfo keyInfo; - - do - { - keyInfo = Console.ReadKey(true); - if (keyInfo.Key != ConsoleKey.Enter && keyInfo.Key != ConsoleKey.Backspace) - { - password += keyInfo.KeyChar; - Console.Write("*"); - } - else if (keyInfo.Key == ConsoleKey.Backspace && password.Length > 0) - { - password = password[..^1]; - Console.Write("\b \b"); - } - } while (keyInfo.Key != ConsoleKey.Enter); - - Console.WriteLine(); - return password; - } - - private static async Task<(string apiKey, string apiSecret)> GetCredentialsFromExternalService() - { - // Simulate calling external service (Azure Key Vault, AWS Secrets Manager, etc.) - await Task.Delay(500); - - // In a real implementation, this would call: - // - Azure Key Vault: var secret = await keyVaultClient.GetSecretAsync("coinbase-api-key"); - // - AWS Secrets Manager: var secret = await secretsClient.GetSecretValueAsync(request); - // - HashiCorp Vault: var secret = await vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(path); - - // For demo purposes, we'll try to read from environment variables with different names - var apiKey = Environment.GetEnvironmentVariable("EXTERNAL_COINBASE_KEY"); - var apiSecret = Environment.GetEnvironmentVariable("EXTERNAL_COINBASE_SECRET"); - - return (apiKey ?? "", apiSecret ?? ""); - } -} \ No newline at end of file +} diff --git a/README.md b/README.md index a9e5c48..3a69e4e 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,13 @@ A comprehensive .NET client library for the Coinbase Advanced Trade API. This pa ## Features -- ✅ **Complete API Coverage**: All major Coinbase Advanced Trade endpoints -- ✅ **JWT Authentication**: Secure ES256 JWT token generation with automatic header management -- ✅ **Resilience Patterns**: Built-in retry policies and circuit breaker patterns using Polly -- ✅ **Strongly Typed**: Comprehensive models for all API requests and responses -- ✅ **Dependency Injection**: Easy integration with .NET DI container -- ✅ **Configuration Support**: Supports both appsettings.json and programmatic configuration -- ✅ **Sandbox Support**: Easy switching between production and sandbox environments -- ✅ **Async/Await**: Full async/await support with cancellation tokens +- **JWT Authentication**: Secure ES256 JWT token generation with automatic header management +- **Resilience Patterns**: Built-in retry policies and circuit breaker patterns using Polly +- **Strongly Typed**: Comprehensive models for all API requests and responses +- **Dependency Injection**: Easy integration with .NET DI container +- **Configuration Support**: Supports both appsettings.json and programmatic configuration +- **Sandbox Support**: Easy switching between production and sandbox environments +- **Async/Await**: Full async/await support with cancellation tokens ## Installation @@ -30,9 +29,7 @@ Add your Coinbase credentials to `appsettings.json`: "Coinbase": { "ApiKey": "your-api-key", "ApiSecret": "your-private-key", - "UseSandbox": true, - "MaxRetryAttempts": 3, - "EnableCircuitBreaker": true + "UseSandbox": true } } ``` @@ -42,17 +39,17 @@ Add your Coinbase credentials to `appsettings.json`: Register the client in your DI container: ```csharp -using Coinbase.AdvancedTrade.Client.Extensions; +using Coinbase.AdvancedTrade.Client.Configuration; // From configuration services.AddCoinbaseAdvancedTradeClient(configuration); // Or programmatically -services.AddCoinbaseAdvancedTradeClient(options => +services.AddCoinbaseAdvancedTradeClient(settings => { - options.ApiKey = "your-api-key"; - options.ApiSecret = "your-private-key"; - options.UseSandbox = true; + settings.ApiKey = "your-api-key"; + settings.ApiSecret = "your-private-key"; + settings.UseSandbox = true; }); ``` @@ -61,7 +58,8 @@ services.AddCoinbaseAdvancedTradeClient(options => Inject and use the client: ```csharp -using Coinbase.AdvancedTrade.Client.Client; +using Coinbase.AdvancedTrade.Client; +using Coinbase.AdvancedTrade.Client.Models; public class TradingService { @@ -74,8 +72,10 @@ public class TradingService public async Task GetBitcoinPriceAsync() { - var products = await _client.ListProductsAsync(); - var btc = products.Products.FirstOrDefault(p => p.ProductId == "BTC-USD"); + var response = await _client.ListProductsAsync(); + if (!response.IsSuccess) return 0; + + var btc = response.Data!.Products?.FirstOrDefault(p => p.ProductId == "BTC-USD"); return decimal.Parse(btc?.Price ?? "0"); } @@ -85,7 +85,7 @@ public class TradingService { ClientOrderId = Guid.NewGuid().ToString(), ProductId = "BTC-USD", - Side = OrderSide.Buy, + Side = "BUY", OrderConfiguration = new OrderConfiguration { MarketMarketIoc = new MarketMarketIoc @@ -96,9 +96,13 @@ public class TradingService }; var result = await _client.PlaceOrderAsync(order); - if (result.Success) + if (result.IsSuccess && result.Data!.Success) + { + Console.WriteLine($"Order placed: {result.Data.SuccessResponse?.OrderId}"); + } + else { - Console.WriteLine($"Order placed: {result.SuccessResponse.OrderId}"); + Console.WriteLine($"Order failed: {result.ErrorMessage ?? result.Data?.ErrorResponse?.Message}"); } } } @@ -110,13 +114,24 @@ public class TradingService - `ListAccountsAsync()` - List all accounts - `GetAccountAsync(Guid id)` - Get specific account details +### Order Management +- `PlaceOrderAsync(OrderRequest request)` - Place a new order +- `CancelOrdersAsync(List orderIds)` - Cancel one or more orders +- `GetOrdersAsync(OrderSearchRequest? request)` - Search historical orders with filters +- `GetOrderAsync(string orderId)` - Get a specific order by ID +- `ClosePositionAsync(ClosePositionRequest request)` - Close a futures/perp position + ### Market Data - `ListProductsAsync()` - List all available trading pairs -- `GetProductAsync(string productId)` - Get details for specific product +- `GetProductAsync(string productId)` - Get details for a specific product - `GetBestBidAskAsync(List? productIds)` - Get best bid/ask prices +- `GetProductCandlesAsync(string productId, long start, long end, string granularity)` - Get OHLCV candlestick data -### Order Management -- `PlaceOrderAsync(OrderRequest request)` - Place a new order +### Portfolio Management +- `GetPortfoliosAsync()` - List all portfolios +- `GetPortfolioBreakdownAsync(string portfolioUuid)` - Get detailed portfolio breakdown + +All methods return `ApiResponse` with `IsSuccess`, `Data`, `ErrorMessage`, and `Exception` properties. ## Configuration Options @@ -127,56 +142,49 @@ public class TradingService | `UseSandbox` | Use sandbox environment | `false` | | `BaseUrl` | Production API URL | `https://api.coinbase.com/api/v3/brokerage` | | `SandboxBaseUrl` | Sandbox API URL | `https://api-sandbox.coinbase.com/api/v3/brokerage` | -| `Timeout` | HTTP request timeout | `30 seconds` | -| `MaxRetryAttempts` | Number of retry attempts | `3` | -| `EnableCircuitBreaker` | Enable circuit breaker pattern | `true` | -| `CircuitBreakerFailuresBeforeBreaking` | Failures before breaking | `5` | -| `CircuitBreakerDurationOfBreak` | Break duration | `2 minutes` | ## Order Types Supported - **Market Orders**: `MarketMarketIoc` -- **Limit Orders**: `LimitLimitGtc`, `LimitLimitGtd`, `LimitLimitFok` -- **Stop Orders**: `StopLimitStopLimitGtc`, `StopLimitStopLimitGtd` -- **Bracket Orders**: `TriggerBracketGtc`, `TriggerBracketGtd` +- **Limit Orders**: `LimitLimitGtcV3`, `LimitLimitGtdV3`, `LimitLimitFokV3` +- **Stop Orders**: `StopLimitStopLimitGtcV3`, `StopLimitStopLimitGtdV3` +- **Bracket Orders**: `TriggerBracketGtcV3`, `TriggerBracketGtdV3` - **Smart Order Routing**: `SorLimitIoc` ## Error Handling -The client throws `CoinbaseApiException` for API-related errors: +All methods return `ApiResponse` instead of throwing exceptions: ```csharp -try +var response = await client.ListAccountsAsync(); +if (response.IsSuccess) { - var accounts = await client.ListAccountsAsync(); + var accounts = response.Data!; + // Use accounts... } -catch (CoinbaseApiException ex) +else { - Console.WriteLine($"API Error: {ex.StatusCode} - {ex.Message}"); - Console.WriteLine($"Details: {ex.ErrorDetails}"); + Console.WriteLine($"Error: {response.ErrorMessage}"); + // response.Exception contains the underlying exception if needed } ``` ## Resilience Features ### Retry Policy -- Automatic retries for transient failures -- Exponential backoff strategy -- Configurable retry attempts +- Automatic retries for transient failures (429, 502, 503, 504, 408) +- Exponential backoff strategy (2^n seconds) +- 3 retry attempts ### Circuit Breaker -- Protects against cascading failures -- Configurable failure threshold +- Opens after 5 consecutive failures +- 2-minute break duration - Automatic recovery -### Rate Limiting -- Handles 429 (Too Many Requests) responses -- Automatic retry with appropriate delays - ## Getting API Credentials 1. Log in to [Coinbase Advanced Trade](https://advanced.coinbase.com/) -2. Go to Settings → API Keys +2. Go to Settings > API Keys 3. Create a new API key with appropriate permissions 4. Download your private key and store it securely @@ -190,4 +198,4 @@ This project is licensed under the MIT License. ## Disclaimer -This library is not officially affiliated with Coinbase. Use at your own risk. Always test thoroughly in sandbox environment before using in production. \ No newline at end of file +This library is not officially affiliated with Coinbase. Use at your own risk. Always test thoroughly in sandbox environment before using in production.