From 18fc676c1b022f7a542ea740d0eca8c99800bb93 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 17:31:00 +0100 Subject: [PATCH 01/25] Refactor market depth project structure --- .../Abstracts/IMarketDepthPublisher.cs | 2 +- .../{Utility => Core}/DescDecimalComparer.cs | 2 +- .../{Core => Domain}/MarketDepth.cs | 5 +- .../{Core => Domain}/MarketDepthPair.cs | 2 +- .../{Core => Domain}/Quote.cs | 2 +- src/BinanceBot.Market/Domain/Symbol.cs | 53 +++++++++++++++++++ .../QuoteExtensions.cs | 4 +- src/BinanceBot.Market/MarketDepthManager.cs | 2 +- src/BinanceBot.Market/MarketMakerBot.cs | 2 +- .../Strategies/NaiveMarketMakerStrategy.cs | 2 +- .../Program.cs | 2 +- .../Core/MarketDepthTests.cs | 1 + .../MarketDepthManagerTests.cs | 1 + 13 files changed, 68 insertions(+), 12 deletions(-) rename src/BinanceBot.Market/{Utility => Core}/DescDecimalComparer.cs (87%) rename src/BinanceBot.Market/{Core => Domain}/MarketDepth.cs (97%) rename src/BinanceBot.Market/{Core => Domain}/MarketDepthPair.cs (97%) rename src/BinanceBot.Market/{Core => Domain}/Quote.cs (95%) create mode 100644 src/BinanceBot.Market/Domain/Symbol.cs rename src/BinanceBot.Market/{Utility => Extensions}/QuoteExtensions.cs (82%) diff --git a/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs b/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs index 6159738..46b1fe1 100644 --- a/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs +++ b/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; namespace BinanceBot.Market; diff --git a/src/BinanceBot.Market/Utility/DescDecimalComparer.cs b/src/BinanceBot.Market/Core/DescDecimalComparer.cs similarity index 87% rename from src/BinanceBot.Market/Utility/DescDecimalComparer.cs rename to src/BinanceBot.Market/Core/DescDecimalComparer.cs index 60e3287..c52a769 100644 --- a/src/BinanceBot.Market/Utility/DescDecimalComparer.cs +++ b/src/BinanceBot.Market/Core/DescDecimalComparer.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace BinanceBot.Market.Utility; +namespace BinanceBot.Market.Core; /// /// Descending decimal comparer diff --git a/src/BinanceBot.Market/Core/MarketDepth.cs b/src/BinanceBot.Market/Domain/MarketDepth.cs similarity index 97% rename from src/BinanceBot.Market/Core/MarketDepth.cs rename to src/BinanceBot.Market/Domain/MarketDepth.cs index 1e2c556..28901e9 100644 --- a/src/BinanceBot.Market/Core/MarketDepth.cs +++ b/src/BinanceBot.Market/Domain/MarketDepth.cs @@ -3,9 +3,10 @@ using System.Linq; using Binance.Net.Enums; using Binance.Net.Objects.Models; -using BinanceBot.Market.Utility; +using BinanceBot.Market.Core; +using BinanceBot.Market.Extensions; -namespace BinanceBot.Market.Core; +namespace BinanceBot.Market.Domain; /// /// Order book diff --git a/src/BinanceBot.Market/Core/MarketDepthPair.cs b/src/BinanceBot.Market/Domain/MarketDepthPair.cs similarity index 97% rename from src/BinanceBot.Market/Core/MarketDepthPair.cs rename to src/BinanceBot.Market/Domain/MarketDepthPair.cs index 67e65b5..6f99b7d 100644 --- a/src/BinanceBot.Market/Core/MarketDepthPair.cs +++ b/src/BinanceBot.Market/Domain/MarketDepthPair.cs @@ -1,6 +1,6 @@ using System; -namespace BinanceBot.Market.Core; +namespace BinanceBot.Market.Domain; /// /// Order book's ask-bid pair diff --git a/src/BinanceBot.Market/Core/Quote.cs b/src/BinanceBot.Market/Domain/Quote.cs similarity index 95% rename from src/BinanceBot.Market/Core/Quote.cs rename to src/BinanceBot.Market/Domain/Quote.cs index cbe8623..8bfa8e3 100644 --- a/src/BinanceBot.Market/Core/Quote.cs +++ b/src/BinanceBot.Market/Domain/Quote.cs @@ -1,7 +1,7 @@ using System; using Binance.Net.Enums; -namespace BinanceBot.Market.Core; +namespace BinanceBot.Market.Domain; /// /// quote representing bid or ask diff --git a/src/BinanceBot.Market/Domain/Symbol.cs b/src/BinanceBot.Market/Domain/Symbol.cs new file mode 100644 index 0000000..2584b62 --- /dev/null +++ b/src/BinanceBot.Market/Domain/Symbol.cs @@ -0,0 +1,53 @@ + +using System; + +namespace BinanceBot.Market.Domain; + +/// +/// A symbol representation based on a Base and Quote asset +/// +public record MarketSymbol +{ + public MarketSymbol(string baseAsset, string quoteAsset, ContractType contractType) + { + BaseAsset = baseAsset ?? throw new ArgumentNullException(nameof(baseAsset)); + QuoteAsset = quoteAsset ?? throw new ArgumentNullException(nameof(quoteAsset)); + ContractType = contractType; + } + + /// + /// The base asset of the symbol + /// + public string BaseAsset { get; init; } + + /// + /// The quote asset of the symbol + /// + public string QuoteAsset { get; init; } + + /// + /// The symbol name, can be used to overwrite the default formatted name + /// + public string FullName => $"{BaseAsset}{QuoteAsset}"; + + /// + /// The Contract type of the symbol + /// + public ContractType ContractType { get; init; } +} + + +/// +/// Contract type +/// +public enum ContractType +{ + /// + /// Spot market + /// + Spot, + /// + /// Perpetual Futures contract + /// + Perpetual +} \ No newline at end of file diff --git a/src/BinanceBot.Market/Utility/QuoteExtensions.cs b/src/BinanceBot.Market/Extensions/QuoteExtensions.cs similarity index 82% rename from src/BinanceBot.Market/Utility/QuoteExtensions.cs rename to src/BinanceBot.Market/Extensions/QuoteExtensions.cs index 12eee45..c1bb290 100644 --- a/src/BinanceBot.Market/Utility/QuoteExtensions.cs +++ b/src/BinanceBot.Market/Extensions/QuoteExtensions.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.Linq; using Binance.Net.Enums; -using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; -namespace BinanceBot.Market.Utility; +namespace BinanceBot.Market.Extensions; internal static class QuoteExtensions { diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index c6ed1af..11b96ae 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -6,7 +6,7 @@ using Binance.Net.Interfaces; using Binance.Net.Interfaces.Clients; using Binance.Net.Objects.Models.Spot; -using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets; using NLog; diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index a7d0d61..004c5b5 100644 --- a/src/BinanceBot.Market/MarketMakerBot.cs +++ b/src/BinanceBot.Market/MarketMakerBot.cs @@ -6,7 +6,7 @@ using Binance.Net.Enums; using Binance.Net.Interfaces.Clients; using Binance.Net.Objects.Models.Spot; -using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; using BinanceBot.Market.Strategies; using CryptoExchange.Net.Objects; using NLog; diff --git a/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs b/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs index be8fdb8..7a6984d 100644 --- a/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs +++ b/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs @@ -1,7 +1,7 @@ using System; using Binance.Net.Enums; using BinanceBot.Market.Configurations; -using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; using NLog; namespace BinanceBot.Market.Strategies; diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index 1a8e583..48e4b28 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -8,7 +8,7 @@ using Binance.Net.Interfaces.Clients; using Binance.Net.Objects; using BinanceBot.Market; -using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; using dotenv.net; using Spectre.Console; diff --git a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs index c5571e6..ad47cd6 100644 --- a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs +++ b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs @@ -1,6 +1,7 @@ using Binance.Net.Enums; using Binance.Net.Objects.Models; using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; namespace BinanceBot.Market.Tests.Core; diff --git a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs index 7ca6165..093a207 100644 --- a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs +++ b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Binance.Net.Interfaces.Clients; using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; using Moq; using NLog; From 21dc1b461f1004530dd0d5150a2c564ce4f644c1 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 18:06:21 +0100 Subject: [PATCH 02/25] Inject MarketSymbol entity --- .../Abstracts/BaseMarketBot.cs | 5 ++- src/BinanceBot.Market/Abstracts/IMarketBot.cs | 3 +- src/BinanceBot.Market/Domain/MarketDepth.cs | 7 ++-- src/BinanceBot.Market/Domain/Symbol.cs | 2 + src/BinanceBot.Market/MarketMakerBot.cs | 6 +-- .../Program.cs | 2 +- .../Core/MarketDepthTests.cs | 41 ++++++++++--------- .../MarketDepthManagerTests.cs | 11 +++-- 8 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs b/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs index 86c13c4..4314265 100644 --- a/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs +++ b/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Binance.Net.Objects.Models.Spot; +using BinanceBot.Market.Domain; using NLog; namespace BinanceBot.Market; @@ -19,7 +20,7 @@ public abstract class BaseMarketBot : protected readonly TStrategy MarketStrategy; - protected BaseMarketBot(string symbol, TStrategy marketStrategy, Logger logger) + protected BaseMarketBot(MarketSymbol symbol, TStrategy marketStrategy, Logger logger) { Symbol = symbol ?? throw new ArgumentNullException(nameof(symbol)); MarketStrategy = marketStrategy ?? throw new ArgumentNullException(nameof(marketStrategy)); @@ -27,7 +28,7 @@ protected BaseMarketBot(string symbol, TStrategy marketStrategy, Logger logger) } - public string Symbol { get; } + public MarketSymbol Symbol { get; } public abstract Task RunAsync(); diff --git a/src/BinanceBot.Market/Abstracts/IMarketBot.cs b/src/BinanceBot.Market/Abstracts/IMarketBot.cs index cf5793a..225ede7 100644 --- a/src/BinanceBot.Market/Abstracts/IMarketBot.cs +++ b/src/BinanceBot.Market/Abstracts/IMarketBot.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Binance.Net.Objects.Models.Spot; +using BinanceBot.Market.Domain; namespace BinanceBot.Market; @@ -13,7 +14,7 @@ public interface IMarketBot /// /// Symbol /// - string Symbol { get; } + MarketSymbol Symbol { get; } /// diff --git a/src/BinanceBot.Market/Domain/MarketDepth.cs b/src/BinanceBot.Market/Domain/MarketDepth.cs index 28901e9..73f56a3 100644 --- a/src/BinanceBot.Market/Domain/MarketDepth.cs +++ b/src/BinanceBot.Market/Domain/MarketDepth.cs @@ -13,17 +13,16 @@ namespace BinanceBot.Market.Domain; /// public class MarketDepth : IMarketDepthPublisher { - public MarketDepth(string symbol) + public MarketDepth(MarketSymbol symbol) { - if (string.IsNullOrEmpty(symbol)) + if (symbol == null) throw new ArgumentException("Invalid symbol value", nameof(symbol)); Symbol = symbol; } - public string Symbol { get; } - + public MarketSymbol Symbol { get; } #region Ask section private readonly IDictionary _asks = new SortedDictionary(); diff --git a/src/BinanceBot.Market/Domain/Symbol.cs b/src/BinanceBot.Market/Domain/Symbol.cs index 2584b62..f53f480 100644 --- a/src/BinanceBot.Market/Domain/Symbol.cs +++ b/src/BinanceBot.Market/Domain/Symbol.cs @@ -34,6 +34,8 @@ public MarketSymbol(string baseAsset, string quoteAsset, ContractType contractTy /// The Contract type of the symbol /// public ContractType ContractType { get; init; } + + override public string ToString() => $"{BaseAsset}/{QuoteAsset} ({ContractType})"; } diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index 004c5b5..fcb7aa6 100644 --- a/src/BinanceBot.Market/MarketMakerBot.cs +++ b/src/BinanceBot.Market/MarketMakerBot.cs @@ -34,7 +34,7 @@ public class MarketMakerBot : BaseMarketBot /// cannot be /// cannot be public MarketMakerBot( - string symbol, + MarketSymbol symbol, NaiveMarketMakerStrategy marketStrategy, IBinanceClient binanceRestClient, IBinanceSocketClient webSocketClient, @@ -152,7 +152,7 @@ private async Task OnMarketBestPairChanged(object sender, MarketBestPairChangedE throw new ArgumentNullException(nameof(e)); // get current opened orders by token - var openOrdersResponse = await GetOpenedOrdersAsync(Symbol); + var openOrdersResponse = await GetOpenedOrdersAsync(Symbol.FullName); // cancel already opened orders (if necessary) if (openOrdersResponse != null) await CancelOrdersAsync(openOrdersResponse); @@ -165,7 +165,7 @@ private async Task OnMarketBestPairChanged(object sender, MarketBestPairChangedE var newOrderRequest = new CreateOrderRequest { // general - Symbol = Symbol, + Symbol = Symbol.FullName, Side = q.Direction, OrderType = SpotOrderType.Limit, // price-quantity diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index 48e4b28..46fd15a 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -21,7 +21,7 @@ internal static class Program { #region Bot Settings // WARN: Set necessary token here - private const string Symbol = "BNBUSDT"; + private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", ContractType.Spot); private const int OrderBookDepth = 10; private static readonly TimeSpan OrderBookUpdateInterval = TimeSpan.FromMilliseconds(100); #endregion diff --git a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs index ad47cd6..f837330 100644 --- a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs +++ b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs @@ -1,38 +1,41 @@ -using Binance.Net.Enums; using Binance.Net.Objects.Models; -using BinanceBot.Market.Core; using BinanceBot.Market.Domain; +using ContractType = BinanceBot.Market.Domain.ContractType; namespace BinanceBot.Market.Tests.Core; public class MarketDepthTests { + private static MarketDepth CreateTestMarketDepth() => + new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Spot)); [Fact] public void Constructor_WithValidSymbol_CreatesInstance() { - // Arrange & Act - var marketDepth = new MarketDepth("BTCUSDT"); + // Arrange + var symbol = new MarketSymbol("BTC", "USDT", ContractType.Spot); + + // Act + var marketDepth = new MarketDepth(symbol); // Assert - Assert.Equal("BTCUSDT", marketDepth.Symbol); + Assert.Equal(symbol, marketDepth.Symbol); Assert.Null(marketDepth.LastUpdateTime); Assert.Empty(marketDepth.Asks); Assert.Empty(marketDepth.Bids); } - [Theory] - [InlineData("")] - public void Constructor_WithInvalidSymbol_ThrowsArgumentException(string symbol) + [Fact] + public void Constructor_WithNullSymbol_ThrowsArgumentException() { // Act & Assert - Assert.Throws(() => new MarketDepth(symbol)); + Assert.Throws(() => new MarketDepth(null)); } [Fact] public void UpdateDepth_WithValidData_UpdatesOrderBook() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var asks = new List { new() { Price = 50000m, Quantity = 1.5m }, @@ -59,7 +62,7 @@ public void UpdateDepth_WithValidData_UpdatesOrderBook() public void UpdateDepth_WithOldUpdateTime_IgnoresUpdate() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var asks = new List { new() { Price = 50000m, Quantity = 1.5m } @@ -86,7 +89,7 @@ public void UpdateDepth_WithOldUpdateTime_IgnoresUpdate() public void UpdateDepth_RemovesPriceLevelWithZeroQuantity() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var asks = new List { new() { Price = 50000m, Quantity = 1.5m }, @@ -114,7 +117,7 @@ public void UpdateDepth_RemovesPriceLevelWithZeroQuantity() public void BestPair_WhenOrderBookIsEmpty_ReturnsNull() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); // Act & Assert Assert.Null(marketDepth.BestPair); @@ -124,7 +127,7 @@ public void BestPair_WhenOrderBookIsEmpty_ReturnsNull() public void BestPair_WhenOrderBookHasData_ReturnsPair() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var asks = new List { new() { Price = 50000m, Quantity = 1.5m } @@ -149,7 +152,7 @@ public void BestPair_WhenOrderBookHasData_ReturnsPair() public void MarketDepthChanged_RaisesEvent_WhenDepthUpdated() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); MarketDepthChangedEventArgs? eventArgs = null; marketDepth.MarketDepthChanged += (sender, e) => eventArgs = e; @@ -175,7 +178,7 @@ public void MarketDepthChanged_RaisesEvent_WhenDepthUpdated() public void MarketBestPairChanged_RaisesEvent_WhenBestPairChanges() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var eventRaised = false; marketDepth.MarketBestPairChanged += (sender, e) => eventRaised = true; @@ -199,7 +202,7 @@ public void MarketBestPairChanged_RaisesEvent_WhenBestPairChanges() public void Asks_AreSortedAscending() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var asks = new List { new() { Price = 50100m, Quantity = 2.0m }, @@ -223,7 +226,7 @@ public void Asks_AreSortedAscending() public void Bids_AreSortedDescending() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var asks = new List { new() { Price = 50000m, Quantity = 1.0m } @@ -247,7 +250,7 @@ public void Bids_AreSortedDescending() public void UpdateDepth_WithZeroOrNegativeUpdateTime_ThrowsArgumentOutOfRangeException() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); var asks = new List { new() { Price = 50000m, Quantity = 1.5m } diff --git a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs index 093a207..8cd3378 100644 --- a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs +++ b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs @@ -21,6 +21,9 @@ public MarketDepthManagerTests() _mockLogger = new Mock(); } + private static MarketDepth CreateTestMarketDepth() => + new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Spot)); + [Fact] public void Constructor_WithNullRestClient_ThrowsArgumentNullException() { @@ -71,7 +74,7 @@ public async Task BuildAsync_WithZeroOrderBookDepth_ThrowsArgumentOutOfRangeExce { // Arrange var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); // Act & Assert await Assert.ThrowsAsync(() => @@ -83,7 +86,7 @@ public async Task BuildAsync_WithNegativeOrderBookDepth_ThrowsArgumentOutOfRange { // Arrange var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); // Act & Assert await Assert.ThrowsAsync(() => @@ -95,7 +98,7 @@ public async Task BuildAsync_WithNegativeUpdateInterval_ThrowsArgumentOutOfRange { // Arrange var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); // Act & Assert await Assert.ThrowsAsync(() => @@ -107,7 +110,7 @@ public async Task BuildAsync_WithZeroUpdateInterval_ThrowsArgumentOutOfRangeExce { // Arrange var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); // Act & Assert await Assert.ThrowsAsync(() => From c548df5eebe99c3e2d144f428b723dc6f82ab3b8 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 18:44:32 +0100 Subject: [PATCH 03/25] Add support for Perpetual contract --- src/BinanceBot.Market/MarketDepthManager.cs | 136 ++++++++++++++---- .../Core/MarketDepthTests.cs | 67 ++++++++- 2 files changed, 174 insertions(+), 29 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 11b96ae..73f719a 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -6,6 +6,7 @@ using Binance.Net.Interfaces; using Binance.Net.Interfaces.Clients; using Binance.Net.Objects.Models.Spot; +using Binance.Net.Objects.Models.Futures; using BinanceBot.Market.Domain; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets; @@ -80,11 +81,28 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, _logger.Debug($"1: Opening WebSocket stream for {marketDepth.Symbol}"); var updateIntervalMs = updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds; - var subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol, updateIntervalMs, - data => OnDepthUpdate(marketDepth, data), - ct) - .ConfigureAwait(false); + + CallResult subscriptionResult; + + switch (marketDepth.Symbol.ContractType) + { + case ContractType.Spot: + subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + marketDepth.Symbol.FullName, updateIntervalMs, + data => OnDepthUpdateSpot(marketDepth, data), + ct) + .ConfigureAwait(false); + break; + case ContractType.Perpetual: + subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( + marketDepth.Symbol.FullName, updateIntervalMs, + data => OnDepthUpdatePerp(marketDepth, data), + ct) + .ConfigureAwait(false); + break; + default: + throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); + } if (!subscriptionResult.Success || subscriptionResult.Data == null) throw new InvalidOperationException($"Failed to subscribe to order book updates: {subscriptionResult.Error?.Message}"); @@ -92,25 +110,49 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, _subscription = subscriptionResult.Data; // Step 2: Wait a bit to buffer some events - _logger.Debug($"2: Buffering events for {updateIntervalMs * 2}ms"); - await Task.Delay(updateIntervalMs * 2, ct).ConfigureAwait(false); + // Use longer buffer time to ensure we have enough events before snapshot + var bufferTimeMs = Math.Max(updateIntervalMs * 5, 500); + _logger.Debug($"2: Buffering events for {bufferTimeMs}ms"); + await Task.Delay(bufferTimeMs, ct).ConfigureAwait(false); - _logger.Debug($"3: Getting order book snapshot for {marketDepth.Symbol}"); // Step 3: Get depth snapshot - WebCallResult response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, orderBookDepth, ct).ConfigureAwait(false); + _logger.Debug($"3: Getting order book snapshot for {marketDepth.Symbol}"); + + (bool Success, IBinanceOrderBook Data, Error Error) response; + + switch (marketDepth.Symbol.ContractType) + { + case ContractType.Spot: + WebCallResult spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync( + marketDepth.Symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); + break; + case ContractType.Perpetual: + WebCallResult perpResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( + marketDepth.Symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (perpResponse.Success, perpResponse.Data, perpResponse.Error); + break; + default: + throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); + } + if (!response.Success || response.Data == null) throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); - BinanceOrderBook snapshot = response.Data; + IBinanceOrderBook snapshot = response.Data; _logger.Debug($"Snapshot received: LastUpdateId={snapshot.LastUpdateId}"); // Step 4: Check if snapshot is valid // If buffered events exist and snapshot's lastUpdateId is strictly less than first event's U, retry - BinanceEventOrderBook firstEvent = null; + IBinanceEventOrderBook firstEvent = null; lock (_eventBuffer) { if (_eventBuffer.Count > 0) - firstEvent = _eventBuffer.Peek() as BinanceEventOrderBook; + firstEvent = _eventBuffer.Peek(); } if (firstEvent != null) @@ -122,7 +164,26 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, { _logger.Warn($"Snapshot too old: LastUpdateId={snapshot.LastUpdateId} < FirstEvent.U={firstEvent.FirstUpdateId}. Retrying..."); // Snapshot is too old, need to get a new one - response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, orderBookDepth, ct).ConfigureAwait(false); + switch (marketDepth.Symbol.ContractType) + { + case ContractType.Spot: + WebCallResult spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync( + marketDepth.Symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); + break; + case ContractType.Perpetual: + WebCallResult perpResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( + marketDepth.Symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (perpResponse.Success, perpResponse.Data, perpResponse.Error); + break; + default: + throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); + } + if (!response.Success || response.Data == null) throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); snapshot = response.Data; @@ -130,7 +191,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, lock (_eventBuffer) { - firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() as BinanceEventOrderBook : null; + firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() : null; } } @@ -155,13 +216,12 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, int appliedCount = 0; while (_eventBuffer.Any()) { - var bufferedEvent = _eventBuffer.Peek() as BinanceEventOrderBook; + var bufferedEvent = _eventBuffer.Dequeue(); if (bufferedEvent != null) { ApplyDepthUpdate(marketDepth, bufferedEvent); appliedCount++; } - _eventBuffer.Dequeue(); } _logger.Debug($"7: Applied {appliedCount} buffered events"); } @@ -182,11 +242,30 @@ public async Task StreamUpdatesAsync(MarketDepth marketDepth, TimeSpan? updateIn throw new ArgumentNullException(nameof(marketDepth)); // Step 1 & 2: Open WebSocket and buffer events - var subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol, - updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, - data => OnDepthUpdate(marketDepth, data), - ct); + _logger.Debug($"1 & 2: Streaming updates: Opening WebSocket stream for {marketDepth.Symbol}"); + CallResult subscriptionResult; + + switch (marketDepth.Symbol.ContractType) + { + case ContractType.Spot: + subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + marketDepth.Symbol.FullName, + updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, + data => OnDepthUpdateSpot(marketDepth, data), + ct) + .ConfigureAwait(false); + break; + case ContractType.Perpetual: + subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( + marketDepth.Symbol.FullName, + updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, + data => OnDepthUpdatePerp(marketDepth, data), + ct) + .ConfigureAwait(false); + break; + default: + throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); + } _subscription = subscriptionResult.Data; } @@ -201,9 +280,14 @@ public async Task StopStreamingAsync(CancellationToken ct = default) } } - private void OnDepthUpdate(MarketDepth marketDepth, DataEvent dataEvent) + private void OnDepthUpdateSpot(MarketDepth marketDepth, DataEvent dataEvent) => + OnDepthUpdate(marketDepth, dataEvent.Data); + + private void OnDepthUpdatePerp(MarketDepth marketDepth, DataEvent dataEvent) => + OnDepthUpdate(marketDepth, dataEvent.Data); + + private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook data) { - var data = dataEvent.Data as BinanceEventOrderBook; if (data == null) return; lock (_eventBuffer) @@ -211,7 +295,7 @@ private void OnDepthUpdate(MarketDepth marketDepth, DataEvent new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Spot)); - [Fact] - public void Constructor_WithValidSymbol_CreatesInstance() + + private static MarketDepth CreateTestMarketDepthPerpetual() => + new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Perpetual)); + + [Theory] + [InlineData(ContractType.Spot)] + [InlineData(ContractType.Perpetual)] + public void Constructor_WithValidSymbol_CreatesInstance(ContractType contractType) { // Arrange - var symbol = new MarketSymbol("BTC", "USDT", ContractType.Spot); + var symbol = new MarketSymbol("BTC", "USDT", contractType); // Act var marketDepth = new MarketDepth(symbol); // Assert Assert.Equal(symbol, marketDepth.Symbol); + Assert.Equal(contractType, marketDepth.Symbol.ContractType); Assert.Null(marketDepth.LastUpdateTime); Assert.Empty(marketDepth.Asks); Assert.Empty(marketDepth.Bids); @@ -58,6 +65,34 @@ public void UpdateDepth_WithValidData_UpdatesOrderBook() Assert.Equal(49900m, marketDepth.BestBid.Price); } + [Fact] + public void UpdateDepth_WithValidData_UpdatesOrderBook_Perpetual() + { + // Arrange + var marketDepth = CreateTestMarketDepthPerpetual(); + var asks = new List + { + new() { Price = 50000m, Quantity = 1.5m }, + new() { Price = 50100m, Quantity = 2.0m } + }; + var bids = new List + { + new() { Price = 49900m, Quantity = 1.0m }, + new() { Price = 49800m, Quantity = 0.5m } + }; + + // Act + marketDepth.UpdateDepth(asks, bids, 123456); + + // Assert + Assert.Equal(ContractType.Perpetual, marketDepth.Symbol.ContractType); + Assert.Equal(123456, marketDepth.LastUpdateTime); + Assert.Equal(2, marketDepth.Asks.Count()); + Assert.Equal(2, marketDepth.Bids.Count()); + Assert.Equal(50000m, marketDepth.BestAsk.Price); + Assert.Equal(49900m, marketDepth.BestBid.Price); + } + [Fact] public void UpdateDepth_WithOldUpdateTime_IgnoresUpdate() { @@ -148,6 +183,32 @@ public void BestPair_WhenOrderBookHasData_ReturnsPair() Assert.Equal(100m, bestPair.PriceSpread); } + [Fact] + public void BestPair_WhenOrderBookHasData_ReturnsPair_Perpetual() + { + // Arrange + var marketDepth = CreateTestMarketDepthPerpetual(); + var asks = new List + { + new() { Price = 50000m, Quantity = 1.5m } + }; + var bids = new List + { + new() { Price = 49900m, Quantity = 1.0m } + }; + marketDepth.UpdateDepth(asks, bids, 123456); + + // Act + var bestPair = marketDepth.BestPair; + + // Assert + Assert.NotNull(bestPair); + Assert.Equal(50000m, bestPair.Ask.Price); + Assert.Equal(49900m, bestPair.Bid.Price); + Assert.Equal(100m, bestPair.PriceSpread); + Assert.Equal(ContractType.Perpetual, marketDepth.Symbol.ContractType); + } + [Fact] public void MarketDepthChanged_RaisesEvent_WhenDepthUpdated() { From 5776c30daa4b9717e4d4c5ddc83937e5de5b8daf Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 18:45:14 +0100 Subject: [PATCH 04/25] Inject `MarketSymbol` --- src/BinanceBot.MarketBot.Console/Program.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/BinanceBot.MarketBot.Console/Program.cs b/src/BinanceBot.MarketBot.Console/Program.cs index 1f23b3d..fd19892 100644 --- a/src/BinanceBot.MarketBot.Console/Program.cs +++ b/src/BinanceBot.MarketBot.Console/Program.cs @@ -7,6 +7,7 @@ using Binance.Net.Objects; using BinanceBot.Market; using BinanceBot.Market.Configurations; +using BinanceBot.Market.Domain; using BinanceBot.Market.Strategies; using dotenv.net; @@ -19,7 +20,7 @@ internal static class Program { #region Bot Settings // WARN: set necessary token here - private const string Symbol = "BNBUSDT"; + private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", BinanceBot.Market.Domain.ContractType.Spot); private static readonly TimeSpan ReceiveWindow = TimeSpan.FromMilliseconds(100); #endregion @@ -63,10 +64,10 @@ static async Task Main(string[] args) // 3. set bot strategy config - var exchangeInfoResult = binanceRestClient.SpotApi.ExchangeData.GetExchangeInfoAsync(Symbol); + var exchangeInfoResult = binanceRestClient.SpotApi.ExchangeData.GetExchangeInfoAsync(Symbol.FullName); var symbolInfo = exchangeInfoResult.Result.Data.Symbols - .Single(s => s.Name.Equals(Symbol, StringComparison.InvariantCultureIgnoreCase)); + .Single(s => s.Name.Equals(Symbol.FullName, StringComparison.InvariantCultureIgnoreCase)); if (!(symbolInfo.Status == SymbolStatus.Trading && symbolInfo.OrderTypes.Contains(SpotOrderType.Market))) { From ab5ae81eb7fb814e1ad1c6519ffee56f5a95fae0 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Thu, 20 Nov 2025 18:27:21 +0100 Subject: [PATCH 05/25] Updates and improve event buffering --- src/BinanceBot.Market/MarketDepthManager.cs | 38 ++++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 73f719a..bd39063 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -11,6 +11,7 @@ using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets; using NLog; +using Binance.Net.Objects.Models.Futures.Socket; namespace BinanceBot.Market; @@ -111,7 +112,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, // Step 2: Wait a bit to buffer some events // Use longer buffer time to ensure we have enough events before snapshot - var bufferTimeMs = Math.Max(updateIntervalMs * 5, 500); + var bufferTimeMs = Math.Max(updateIntervalMs * 5, 1000); _logger.Debug($"2: Buffering events for {bufferTimeMs}ms"); await Task.Delay(bufferTimeMs, ct).ConfigureAwait(false); @@ -151,8 +152,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, IBinanceEventOrderBook firstEvent = null; lock (_eventBuffer) { - if (_eventBuffer.Count > 0) - firstEvent = _eventBuffer.Peek(); + firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() : null; } if (firstEvent != null) @@ -229,13 +229,11 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, /// - /// Stream updates - /// - /// Market depth /// Stream updates asynchronously. /// /// Market depth /// Update interval (100ms or 1000ms) + /// Cancellation token public async Task StreamUpdatesAsync(MarketDepth marketDepth, TimeSpan? updateInterval = default, CancellationToken ct = default) { if (marketDepth == null) @@ -269,6 +267,8 @@ public async Task StreamUpdatesAsync(MarketDepth marketDepth, TimeSpan? updateIn _subscription = subscriptionResult.Data; } + + /// /// Stop streaming updates and unsubscribe /// public async Task StopStreamingAsync(CancellationToken ct = default) @@ -308,16 +308,28 @@ private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook data) private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData) { // Step 7: Apply update procedure - + long lastUpdateId; + switch (marketDepth.Symbol.ContractType) + { + case ContractType.Spot: + lastUpdateId = eventData.LastUpdateId; + break; + case ContractType.Perpetual: + lastUpdateId = (eventData as BinanceFuturesStreamOrderBookDepth).LastUpdateIdStream; + break; + default: + throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); + } + // 1. Decide whether the update event can be applied - if (eventData.LastUpdateId <= _localOrderBookUpdateId) + if (lastUpdateId <= _localOrderBookUpdateId) { // Event is older than local order book, ignore - _logger.Debug($"Ignoring old event: u={eventData.LastUpdateId} <= local={_localOrderBookUpdateId}"); + _logger.Debug($"Ignoring old event: u={lastUpdateId} <= local={_localOrderBookUpdateId}"); return; } - if (eventData.FirstUpdateId > _localOrderBookUpdateId + 1) + if (lastUpdateId > _localOrderBookUpdateId + 1) { // Missed some events - need to restart _logger.Error($"Missed updates! Expected U <= {_localOrderBookUpdateId + 1}, got U={eventData.FirstUpdateId}"); @@ -331,10 +343,10 @@ private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook ev // 2. Update price levels if (_localOrderBookUpdateId % 100 == 0) // Log every 100th update to avoid flooding - _logger.Debug($"Applying update: U={eventData.FirstUpdateId}, u={eventData.LastUpdateId}, Asks={eventData.Asks.Count()}, Bids={eventData.Bids.Count()}"); - marketDepth.UpdateDepth(eventData.Asks, eventData.Bids, eventData.LastUpdateId); + _logger.Debug($"Applying update: U={eventData.FirstUpdateId}, u={lastUpdateId}, Asks={eventData.Asks.Count()}, Bids={eventData.Bids.Count()}"); + marketDepth.UpdateDepth(eventData.Asks, eventData.Bids, lastUpdateId); // 3. Set order book update ID - _localOrderBookUpdateId = eventData.LastUpdateId; + _localOrderBookUpdateId = lastUpdateId; } } From af6761ae57b91f2860d67202ef5cae05f052b70a Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Thu, 20 Nov 2025 19:59:54 +0100 Subject: [PATCH 06/25] More consistent naming --- src/BinanceBot.Market/Domain/MarketDepth.cs | 18 +++++++++--------- .../Core/MarketDepthTests.cs | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/BinanceBot.Market/Domain/MarketDepth.cs b/src/BinanceBot.Market/Domain/MarketDepth.cs index 73f56a3..3a8403c 100644 --- a/src/BinanceBot.Market/Domain/MarketDepth.cs +++ b/src/BinanceBot.Market/Domain/MarketDepth.cs @@ -59,14 +59,14 @@ public MarketDepth(MarketSymbol symbol) /// /// The best pair. If the order book is empty, will be returned . /// - public MarketDepthPair BestPair => LastUpdateTime.HasValue - ? new MarketDepthPair(BestAsk, BestBid, LastUpdateTime.Value) + public MarketDepthPair BestPair => LastUpdateId.HasValue + ? new MarketDepthPair(BestAsk, BestBid, LastUpdateId.Value) : null; /// /// Last update of market depth /// - public long? LastUpdateTime { get; private set; } + public long? LastUpdateId { get; private set; } @@ -77,13 +77,13 @@ public MarketDepth(MarketSymbol symbol) /// Update market depth /// /// - public void UpdateDepth(IEnumerable asks, IEnumerable bids, long updateTime) + public void UpdateDepth(IEnumerable asks, IEnumerable bids, long updateId) { - if (updateTime <= 0) - throw new ArgumentOutOfRangeException(nameof(updateTime)); + if (updateId <= 0) + throw new ArgumentOutOfRangeException(nameof(updateId)); // if nothing was changed then return - if (updateTime <= LastUpdateTime) return; + if (updateId <= LastUpdateId) return; if (asks == null && bids == null) return; @@ -111,10 +111,10 @@ static void UpdateOrderBook(IEnumerable updates, IDiction UpdateOrderBook(asks, _asks); UpdateOrderBook(bids, _bids); // set new update time - LastUpdateTime = updateTime; + LastUpdateId = updateId; // raise events - OnMarketDepthChanged(new MarketDepthChangedEventArgs(Asks, Bids, LastUpdateTime.Value)); + OnMarketDepthChanged(new MarketDepthChangedEventArgs(Asks, Bids, LastUpdateId.Value)); if(!BestPair.Equals(prevBestPair)) OnMarketBestPairChanged(new MarketBestPairChangedEventArgs(BestPair)); } diff --git a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs index b01ec7a..fa3fe3a 100644 --- a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs +++ b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs @@ -26,7 +26,7 @@ public void Constructor_WithValidSymbol_CreatesInstance(ContractType contractTyp // Assert Assert.Equal(symbol, marketDepth.Symbol); Assert.Equal(contractType, marketDepth.Symbol.ContractType); - Assert.Null(marketDepth.LastUpdateTime); + Assert.Null(marketDepth.LastUpdateId); Assert.Empty(marketDepth.Asks); Assert.Empty(marketDepth.Bids); } @@ -58,7 +58,7 @@ public void UpdateDepth_WithValidData_UpdatesOrderBook() marketDepth.UpdateDepth(asks, bids, 123456); // Assert - Assert.Equal(123456, marketDepth.LastUpdateTime); + Assert.Equal(123456, marketDepth.LastUpdateId); Assert.Equal(2, marketDepth.Asks.Count()); Assert.Equal(2, marketDepth.Bids.Count()); Assert.Equal(50000m, marketDepth.BestAsk.Price); @@ -86,7 +86,7 @@ public void UpdateDepth_WithValidData_UpdatesOrderBook_Perpetual() // Assert Assert.Equal(ContractType.Perpetual, marketDepth.Symbol.ContractType); - Assert.Equal(123456, marketDepth.LastUpdateTime); + Assert.Equal(123456, marketDepth.LastUpdateId); Assert.Equal(2, marketDepth.Asks.Count()); Assert.Equal(2, marketDepth.Bids.Count()); Assert.Equal(50000m, marketDepth.BestAsk.Price); @@ -116,7 +116,7 @@ public void UpdateDepth_WithOldUpdateTime_IgnoresUpdate() marketDepth.UpdateDepth(newAsks, bids, 123400); // Assert - should still have old data - Assert.Equal(123456, marketDepth.LastUpdateTime); + Assert.Equal(123456, marketDepth.LastUpdateId); Assert.Equal(50000m, marketDepth.BestAsk.Price); } From eb7e60dd214c1c485085500aae7ff98a353d7f13 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Thu, 20 Nov 2025 20:03:32 +0100 Subject: [PATCH 07/25] Improve output format --- src/BinanceBot.MarketViewer.Console/Program.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index 46fd15a..e571b01 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -21,7 +21,7 @@ internal static class Program { #region Bot Settings // WARN: Set necessary token here - private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", ContractType.Spot); + private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", ContractType.Perpetual); private const int OrderBookDepth = 10; private static readonly TimeSpan OrderBookUpdateInterval = TimeSpan.FromMilliseconds(100); #endregion @@ -85,7 +85,6 @@ await AnsiConsole.Status() var asks = e.Asks.OrderBy(q => q.Price).Take(OrderBookDepth).ToImmutableArray(); var bids = e.Bids.OrderByDescending(q => q.Price).Take(OrderBookDepth).ToImmutableArray(); - orderBookTable.Rows.Clear(); foreach (var row in GetValues(asks)) @@ -95,8 +94,8 @@ await AnsiConsole.Status() orderBookTable.AddRow(String.Empty, $"[green]{row.price}[/]", row.volume); orderBookTable.Caption = new TableTitle( - $"Spread: {e.Asks.Select(q => q.Price).Min() - e.Bids.Select(q => q.Price).Max()}. " + - $"Last updated as {DateTimeOffset.FromUnixTimeSeconds(e.UpdateTime):T}\n" + $"Spread: {e.Asks.Select(q => q.Price).Min() - e.Bids.Select(q => q.Price).Max()}\n" + + $"Update Id: {e.UpdateTime} at {DateTimeOffset.UtcNow:HH:mm:ss.FFF} (UTC+0)\n" ); var dominanceChart = new BreakdownChart() @@ -109,6 +108,8 @@ await AnsiConsole.Status() AnsiConsole.Write(orderBookTable); AnsiConsole.Write(dominanceChart); + + WriteLine("Press Enter to exit..."); }; @@ -117,10 +118,7 @@ await AnsiConsole.Status() await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth, OrderBookUpdateInterval, ct); Logger.Info("Order book ready and streaming updates..."); - - WriteLine("Press Enter to exit..."); ReadLine(); - Logger.Info("Order book viewer stopped"); } } From d6fc89fe35344a8342782f03e9fea719fca21d85 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Thu, 20 Nov 2025 20:12:20 +0100 Subject: [PATCH 08/25] More comprehensive naming --- src/BinanceBot.Market/Domain/Symbol.cs | 4 +- src/BinanceBot.Market/MarketDepthManager.cs | 76 +++++++------------ .../Program.cs | 2 +- .../Core/MarketDepthTests.cs | 8 +- 4 files changed, 34 insertions(+), 56 deletions(-) diff --git a/src/BinanceBot.Market/Domain/Symbol.cs b/src/BinanceBot.Market/Domain/Symbol.cs index f53f480..ddf1058 100644 --- a/src/BinanceBot.Market/Domain/Symbol.cs +++ b/src/BinanceBot.Market/Domain/Symbol.cs @@ -49,7 +49,7 @@ public enum ContractType /// Spot, /// - /// Perpetual Futures contract + /// Futures contract /// - Perpetual + Futures } \ No newline at end of file diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index bd39063..7d10a53 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -90,14 +90,14 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, case ContractType.Spot: subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( marketDepth.Symbol.FullName, updateIntervalMs, - data => OnDepthUpdateSpot(marketDepth, data), + data => OnSpotOrderBookUpdate(marketDepth, data), ct) .ConfigureAwait(false); break; - case ContractType.Perpetual: + case ContractType.Futures: subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( marketDepth.Symbol.FullName, updateIntervalMs, - data => OnDepthUpdatePerp(marketDepth, data), + data => OnFuturesOrderBookUpdate(marketDepth, data), ct) .ConfigureAwait(false); break; @@ -130,12 +130,12 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); break; - case ContractType.Perpetual: - WebCallResult perpResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( + case ContractType.Futures: + WebCallResult futuresResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( marketDepth.Symbol.FullName, orderBookDepth, ct) .ConfigureAwait(false); - response = (perpResponse.Success, perpResponse.Data, perpResponse.Error); + response = (futuresResponse.Success, futuresResponse.Data, futuresResponse.Error); break; default: throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); @@ -173,12 +173,12 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); break; - case ContractType.Perpetual: - WebCallResult perpResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( + case ContractType.Futures: + WebCallResult futuresResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( marketDepth.Symbol.FullName, orderBookDepth, ct) .ConfigureAwait(false); - response = (perpResponse.Success, perpResponse.Data, perpResponse.Error); + response = (futuresResponse.Success, futuresResponse.Data, futuresResponse.Error); break; default: throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); @@ -209,19 +209,20 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, // Step 6: Set local order book to snapshot _logger.Debug($"6: Applying snapshot with {snapshot.Asks.Count()} asks and {snapshot.Bids.Count()} bids"); marketDepth.UpdateDepth(snapshot.Asks, snapshot.Bids, snapshot.LastUpdateId); - _localOrderBookUpdateId = snapshot.LastUpdateId; - _isSnapshotLoaded = true; + _localOrderBookUpdateId = marketDepth.LastUpdateId ?? throw new InvalidOperationException("MarketDepth.LastUpdateId is null after applying snapshot"); + _isSnapshotLoaded = marketDepth.LastUpdateId == snapshot.LastUpdateId; // Step 7: Apply buffered updates int appliedCount = 0; while (_eventBuffer.Any()) { - var bufferedEvent = _eventBuffer.Dequeue(); + var bufferedEvent = _eventBuffer.Peek(); if (bufferedEvent != null) { ApplyDepthUpdate(marketDepth, bufferedEvent); appliedCount++; } + _eventBuffer.Dequeue(); } _logger.Debug($"7: Applied {appliedCount} buffered events"); } @@ -249,15 +250,15 @@ public async Task StreamUpdatesAsync(MarketDepth marketDepth, TimeSpan? updateIn subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( marketDepth.Symbol.FullName, updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, - data => OnDepthUpdateSpot(marketDepth, data), + data => OnSpotOrderBookUpdate(marketDepth, data), ct) .ConfigureAwait(false); break; - case ContractType.Perpetual: + case ContractType.Futures: subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( marketDepth.Symbol.FullName, updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, - data => OnDepthUpdatePerp(marketDepth, data), + data => OnFuturesOrderBookUpdate(marketDepth, data), ct) .ConfigureAwait(false); break; @@ -280,46 +281,34 @@ public async Task StopStreamingAsync(CancellationToken ct = default) } } - private void OnDepthUpdateSpot(MarketDepth marketDepth, DataEvent dataEvent) => - OnDepthUpdate(marketDepth, dataEvent.Data); - - private void OnDepthUpdatePerp(MarketDepth marketDepth, DataEvent dataEvent) => - OnDepthUpdate(marketDepth, dataEvent.Data); + private void OnSpotOrderBookUpdate(MarketDepth marketDepth, DataEvent eventData) => + OnDepthUpdate(marketDepth, eventData.Data); - private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook data) + private void OnFuturesOrderBookUpdate(MarketDepth marketDepth, DataEvent eventData) => + OnDepthUpdate(marketDepth, eventData.Data); + private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData) { - if (data == null) return; + if (eventData == null) return; lock (_eventBuffer) { if (!_isSnapshotLoaded) { // Step 2: Buffer events before snapshot is loaded - _eventBuffer.Enqueue(data); - _logger.Debug($"Step 2: Buffered event U={data.FirstUpdateId}, u={data.LastUpdateId}. Buffer size: {_eventBuffer.Count}"); + _eventBuffer.Enqueue(eventData); + _logger.Debug($"Step 2: Buffered event U={eventData.FirstUpdateId}, u={eventData.LastUpdateId}. Buffer size: {_eventBuffer.Count}"); return; } // Apply update to local order book - ApplyDepthUpdate(marketDepth, data); + ApplyDepthUpdate(marketDepth, eventData); } } private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData) { // Step 7: Apply update procedure - long lastUpdateId; - switch (marketDepth.Symbol.ContractType) - { - case ContractType.Spot: - lastUpdateId = eventData.LastUpdateId; - break; - case ContractType.Perpetual: - lastUpdateId = (eventData as BinanceFuturesStreamOrderBookDepth).LastUpdateIdStream; - break; - default: - throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); - } + long lastUpdateId = eventData.LastUpdateId; // 1. Decide whether the update event can be applied if (lastUpdateId <= _localOrderBookUpdateId) @@ -329,21 +318,10 @@ private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook ev return; } - if (lastUpdateId > _localOrderBookUpdateId + 1) - { - // Missed some events - need to restart - _logger.Error($"Missed updates! Expected U <= {_localOrderBookUpdateId + 1}, got U={eventData.FirstUpdateId}"); - throw new InvalidOperationException( - $"Missed order book updates. Expected U <= {_localOrderBookUpdateId + 1}, got {eventData.FirstUpdateId}. " + - "Local order book is out of sync. Please restart the process."); - } - - // Normally U of next event should equal u + 1 of previous event - // This is handled by the check above - // 2. Update price levels if (_localOrderBookUpdateId % 100 == 0) // Log every 100th update to avoid flooding _logger.Debug($"Applying update: U={eventData.FirstUpdateId}, u={lastUpdateId}, Asks={eventData.Asks.Count()}, Bids={eventData.Bids.Count()}"); + marketDepth.UpdateDepth(eventData.Asks, eventData.Bids, lastUpdateId); // 3. Set order book update ID diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index e571b01..e19a305 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -21,7 +21,7 @@ internal static class Program { #region Bot Settings // WARN: Set necessary token here - private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", ContractType.Perpetual); + private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", ContractType.Futures); private const int OrderBookDepth = 10; private static readonly TimeSpan OrderBookUpdateInterval = TimeSpan.FromMilliseconds(100); #endregion diff --git a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs index fa3fe3a..11c1b55 100644 --- a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs +++ b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs @@ -10,11 +10,11 @@ private static MarketDepth CreateTestMarketDepth() => new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Spot)); private static MarketDepth CreateTestMarketDepthPerpetual() => - new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Perpetual)); + new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Futures)); [Theory] [InlineData(ContractType.Spot)] - [InlineData(ContractType.Perpetual)] + [InlineData(ContractType.Futures)] public void Constructor_WithValidSymbol_CreatesInstance(ContractType contractType) { // Arrange @@ -85,7 +85,7 @@ public void UpdateDepth_WithValidData_UpdatesOrderBook_Perpetual() marketDepth.UpdateDepth(asks, bids, 123456); // Assert - Assert.Equal(ContractType.Perpetual, marketDepth.Symbol.ContractType); + Assert.Equal(ContractType.Futures, marketDepth.Symbol.ContractType); Assert.Equal(123456, marketDepth.LastUpdateId); Assert.Equal(2, marketDepth.Asks.Count()); Assert.Equal(2, marketDepth.Bids.Count()); @@ -206,7 +206,7 @@ public void BestPair_WhenOrderBookHasData_ReturnsPair_Perpetual() Assert.Equal(50000m, bestPair.Ask.Price); Assert.Equal(49900m, bestPair.Bid.Price); Assert.Equal(100m, bestPair.PriceSpread); - Assert.Equal(ContractType.Perpetual, marketDepth.Symbol.ContractType); + Assert.Equal(ContractType.Futures, marketDepth.Symbol.ContractType); } [Fact] From bd1ea3fcfdf05a113f849b1d435774852d3f4d2b Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Thu, 20 Nov 2025 22:35:58 +0300 Subject: [PATCH 09/25] Decrease buffering interval Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinanceBot.Market/MarketDepthManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 7d10a53..42b6e1d 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -112,7 +112,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, // Step 2: Wait a bit to buffer some events // Use longer buffer time to ensure we have enough events before snapshot - var bufferTimeMs = Math.Max(updateIntervalMs * 5, 1000); + var bufferTimeMs = Math.Max(updateIntervalMs * 5, 500); _logger.Debug($"2: Buffering events for {bufferTimeMs}ms"); await Task.Delay(bufferTimeMs, ct).ConfigureAwait(false); From d6641e60b527b537364ae10d5b7d87e0e5e0e37b Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Thu, 20 Nov 2025 20:31:45 +0100 Subject: [PATCH 10/25] Refactor MarketSymbol: consolidate into a single file and enhance contract type representation --- src/BinanceBot.Market/Domain/MarketSymbol.cs | 83 ++++++++++++++++++++ src/BinanceBot.Market/Domain/Symbol.cs | 55 ------------- 2 files changed, 83 insertions(+), 55 deletions(-) create mode 100644 src/BinanceBot.Market/Domain/MarketSymbol.cs delete mode 100644 src/BinanceBot.Market/Domain/Symbol.cs diff --git a/src/BinanceBot.Market/Domain/MarketSymbol.cs b/src/BinanceBot.Market/Domain/MarketSymbol.cs new file mode 100644 index 0000000..0a98219 --- /dev/null +++ b/src/BinanceBot.Market/Domain/MarketSymbol.cs @@ -0,0 +1,83 @@ + +using System; + +namespace BinanceBot.Market.Domain; + + +/// +/// Market Contract type +/// +public enum ContractType +{ + /// + /// Spot market + /// + Spot, + /// + /// Futures contract + /// + Futures +} + + +/// +/// A market symbol representation based on a Base and Quote asset and Contract type +/// +public record MarketSymbol +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// Thrown when baseAsset or quoteAsset is null. + public MarketSymbol(string baseAsset, string quoteAsset, ContractType contractType) + { + BaseAsset = baseAsset ?? throw new ArgumentNullException(nameof(baseAsset)); + QuoteAsset = quoteAsset ?? throw new ArgumentNullException(nameof(quoteAsset)); + ContractType = contractType; + } + + /// + /// Initializes a new instance of the class from a pair string. + /// + /// The trading pair in the format "BASE/QUOTE". + /// The contract type (Spot or Futures). Defaults to Spot. + /// Thrown when the pair format is invalid. + public MarketSymbol(string pair, ContractType contractType = ContractType.Spot) + { + if (string.IsNullOrWhiteSpace(pair)) + throw new ArgumentException("Pair cannot be null or empty", nameof(pair)); + + var assets = pair.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (assets.Length != 2) + throw new ArgumentException("Pair must be in the format 'BASE/QUOTE'", nameof(pair)); + + BaseAsset = assets[0]; + QuoteAsset = assets[1]; + ContractType = contractType; + } + + /// + /// The base asset of the symbol + /// + public string BaseAsset { get; init; } + + /// + /// The quote asset of the symbol + /// + public string QuoteAsset { get; init; } + + /// + /// The symbol name, can be used to overwrite the default formatted name + /// + public string FullName => $"{BaseAsset}{QuoteAsset}"; + + /// + /// The Contract type of the symbol + /// + public ContractType ContractType { get; init; } + + override public string ToString() => $"{BaseAsset}/{QuoteAsset} ({ContractType})"; +} diff --git a/src/BinanceBot.Market/Domain/Symbol.cs b/src/BinanceBot.Market/Domain/Symbol.cs deleted file mode 100644 index ddf1058..0000000 --- a/src/BinanceBot.Market/Domain/Symbol.cs +++ /dev/null @@ -1,55 +0,0 @@ - -using System; - -namespace BinanceBot.Market.Domain; - -/// -/// A symbol representation based on a Base and Quote asset -/// -public record MarketSymbol -{ - public MarketSymbol(string baseAsset, string quoteAsset, ContractType contractType) - { - BaseAsset = baseAsset ?? throw new ArgumentNullException(nameof(baseAsset)); - QuoteAsset = quoteAsset ?? throw new ArgumentNullException(nameof(quoteAsset)); - ContractType = contractType; - } - - /// - /// The base asset of the symbol - /// - public string BaseAsset { get; init; } - - /// - /// The quote asset of the symbol - /// - public string QuoteAsset { get; init; } - - /// - /// The symbol name, can be used to overwrite the default formatted name - /// - public string FullName => $"{BaseAsset}{QuoteAsset}"; - - /// - /// The Contract type of the symbol - /// - public ContractType ContractType { get; init; } - - override public string ToString() => $"{BaseAsset}/{QuoteAsset} ({ContractType})"; -} - - -/// -/// Contract type -/// -public enum ContractType -{ - /// - /// Spot market - /// - Spot, - /// - /// Futures contract - /// - Futures -} \ No newline at end of file From 65a2c18de3f3b9ee4aa6d0cc13ecf70f6f238931 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Thu, 20 Nov 2025 20:33:07 +0100 Subject: [PATCH 11/25] Minor improvements --- src/BinanceBot.Market/MarketDepthManager.cs | 1 - src/BinanceBot.MarketViewer.Console/Program.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 42b6e1d..3fcb340 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -11,7 +11,6 @@ using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets; using NLog; -using Binance.Net.Objects.Models.Futures.Socket; namespace BinanceBot.Market; diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index e19a305..79d6cb5 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -21,7 +21,7 @@ internal static class Program { #region Bot Settings // WARN: Set necessary token here - private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", ContractType.Futures); + private static readonly MarketSymbol Symbol = new MarketSymbol("BNB/USDT", ContractType.Futures); private const int OrderBookDepth = 10; private static readonly TimeSpan OrderBookUpdateInterval = TimeSpan.FromMilliseconds(100); #endregion From 2039400283bc1a3d08176e8a7937b1543d3bc1b4 Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Thu, 20 Nov 2025 22:38:44 +0300 Subject: [PATCH 12/25] Remove unnecessary import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs index 11c1b55..16cef76 100644 --- a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs +++ b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs @@ -1,7 +1,5 @@ using Binance.Net.Objects.Models; using BinanceBot.Market.Domain; -using ContractType = BinanceBot.Market.Domain.ContractType; - namespace BinanceBot.Market.Tests.Core; public class MarketDepthTests From 9ed1fca08a342e65ba1edb8dfb2b467bb8d45bb3 Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Thu, 20 Nov 2025 22:42:10 +0300 Subject: [PATCH 13/25] Extra leading whitespace fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinanceBot.MarketBot.Console/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BinanceBot.MarketBot.Console/Program.cs b/src/BinanceBot.MarketBot.Console/Program.cs index fd19892..1a27e68 100644 --- a/src/BinanceBot.MarketBot.Console/Program.cs +++ b/src/BinanceBot.MarketBot.Console/Program.cs @@ -20,7 +20,7 @@ internal static class Program { #region Bot Settings // WARN: set necessary token here - private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", BinanceBot.Market.Domain.ContractType.Spot); + private static readonly MarketSymbol Symbol = new MarketSymbol("BNB", "USDT", BinanceBot.Market.Domain.ContractType.Spot); private static readonly TimeSpan ReceiveWindow = TimeSpan.FromMilliseconds(100); #endregion From dcf0d401583bcb3766fdbed359619d843f1619f8 Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Thu, 20 Nov 2025 22:42:52 +0300 Subject: [PATCH 14/25] Remove unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs index 8cd3378..6c7a1bd 100644 --- a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs +++ b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs @@ -1,7 +1,6 @@ using System.Threading; using System.Threading.Tasks; using Binance.Net.Interfaces.Clients; -using BinanceBot.Market.Core; using BinanceBot.Market.Domain; using Moq; using NLog; From 64b3a5be0d3189de7e3cc16bfecd2a24bd8542af Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Thu, 20 Nov 2025 20:46:49 +0100 Subject: [PATCH 15/25] Formatting --- src/BinanceBot.Market/MarketDepthManager.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 3fcb340..6dc3a85 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -285,6 +285,7 @@ private void OnSpotOrderBookUpdate(MarketDepth marketDepth, DataEvent eventData) => OnDepthUpdate(marketDepth, eventData.Data); + private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData) { if (eventData == null) return; From 40377c554594d715722592552e7bd490d87f5c81 Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Fri, 21 Nov 2025 17:46:19 +0300 Subject: [PATCH 16/25] Repair checking for missed updates Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinanceBot.Market/MarketDepthManager.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 6dc3a85..0089c91 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -317,6 +317,14 @@ private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook ev _logger.Debug($"Ignoring old event: u={lastUpdateId} <= local={_localOrderBookUpdateId}"); return; } + // Critical validation: check for missed updates + if (eventData.FirstUpdateId > _localOrderBookUpdateId + 1) + { + _logger.Error($"Missed updates! Expected U <= {_localOrderBookUpdateId + 1}, got U={eventData.FirstUpdateId}"); + throw new InvalidOperationException( + $"Missed order book updates. Expected U <= {_localOrderBookUpdateId + 1}, got {eventData.FirstUpdateId}. " + + "Local order book is out of sync. Please restart the process."); + } // 2. Update price levels if (_localOrderBookUpdateId % 100 == 0) // Log every 100th update to avoid flooding From 3d33f060871626f9ab321facdc50087c3e662a0e Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Fri, 21 Nov 2025 17:48:14 +0300 Subject: [PATCH 17/25] Push reuse logic to GetOrderBookSnapshotAsync() Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinanceBot.Market/MarketDepthManager.cs | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 0089c91..ccbd9da 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -163,25 +163,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, { _logger.Warn($"Snapshot too old: LastUpdateId={snapshot.LastUpdateId} < FirstEvent.U={firstEvent.FirstUpdateId}. Retrying..."); // Snapshot is too old, need to get a new one - switch (marketDepth.Symbol.ContractType) - { - case ContractType.Spot: - WebCallResult spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync( - marketDepth.Symbol.FullName, orderBookDepth, ct) - .ConfigureAwait(false); - - response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); - break; - case ContractType.Futures: - WebCallResult futuresResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( - marketDepth.Symbol.FullName, orderBookDepth, ct) - .ConfigureAwait(false); - - response = (futuresResponse.Success, futuresResponse.Data, futuresResponse.Error); - break; - default: - throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); - } + response = await GetOrderBookSnapshotAsync(marketDepth.Symbol, orderBookDepth, ct).ConfigureAwait(false); if (!response.Success || response.Data == null) throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); From 53902072d39cad67392a9e002c6fdb1c0a0743bc Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Fri, 21 Nov 2025 15:51:44 +0100 Subject: [PATCH 18/25] Revert "Push reuse logic to GetOrderBookSnapshotAsync()" This reverts commit 3d33f060871626f9ab321facdc50087c3e662a0e. --- src/BinanceBot.Market/MarketDepthManager.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index ccbd9da..0089c91 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -163,7 +163,25 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, { _logger.Warn($"Snapshot too old: LastUpdateId={snapshot.LastUpdateId} < FirstEvent.U={firstEvent.FirstUpdateId}. Retrying..."); // Snapshot is too old, need to get a new one - response = await GetOrderBookSnapshotAsync(marketDepth.Symbol, orderBookDepth, ct).ConfigureAwait(false); + switch (marketDepth.Symbol.ContractType) + { + case ContractType.Spot: + WebCallResult spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync( + marketDepth.Symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); + break; + case ContractType.Futures: + WebCallResult futuresResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( + marketDepth.Symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (futuresResponse.Success, futuresResponse.Data, futuresResponse.Error); + break; + default: + throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); + } if (!response.Success || response.Data == null) throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); From 952dba3552bc1849ba8bf1397071ea1d1178438f Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Fri, 21 Nov 2025 15:57:07 +0100 Subject: [PATCH 19/25] Enhance missed updates validation for Spot market --- src/BinanceBot.Market/MarketDepthManager.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 0089c91..1fdc606 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -317,13 +317,13 @@ private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook ev _logger.Debug($"Ignoring old event: u={lastUpdateId} <= local={_localOrderBookUpdateId}"); return; } - // Critical validation: check for missed updates - if (eventData.FirstUpdateId > _localOrderBookUpdateId + 1) + + // Check for missed updates. WARN: not worked for Futures API + if (eventData.FirstUpdateId > _localOrderBookUpdateId + 1 && marketDepth.Symbol.ContractType == ContractType.Spot) { - _logger.Error($"Missed updates! Expected U <= {_localOrderBookUpdateId + 1}, got U={eventData.FirstUpdateId}"); - throw new InvalidOperationException( - $"Missed order book updates. Expected U <= {_localOrderBookUpdateId + 1}, got {eventData.FirstUpdateId}. " + - "Local order book is out of sync. Please restart the process."); + string errorMsg = $"Missed order book updates. Expected U <= {_localOrderBookUpdateId + 1}, got U={eventData.FirstUpdateId}"; + _logger.Error(errorMsg); + throw new InvalidOperationException(errorMsg); } // 2. Update price levels From 083f55c406740cf13b28e389aace52091eb06357 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Fri, 21 Nov 2025 16:05:57 +0100 Subject: [PATCH 20/25] Add method to retrieve order book snapshot --- src/BinanceBot.Market/MarketDepthManager.cs | 105 +++++++++----------- 1 file changed, 46 insertions(+), 59 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 1fdc606..144e7f7 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -56,6 +56,39 @@ public MarketDepthManager(IBinanceClient restClient, IBinanceSocketClient webSoc } + private async Task GetOrderBookSnapshotAsync(MarketSymbol symbol, short orderBookDepth, CancellationToken ct) + { + (bool Success, IBinanceOrderBook Data, Error Error) response; + + switch (symbol.ContractType) + { + case ContractType.Spot: + WebCallResult spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync( + symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); + break; + case ContractType.Futures: + WebCallResult futuresResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( + symbol.FullName, orderBookDepth, ct) + .ConfigureAwait(false); + + response = (futuresResponse.Success, futuresResponse.Data, futuresResponse.Error); + break; + default: + throw new ArgumentOutOfRangeException(nameof(symbol.ContractType), "Unknown contract type."); + } + + if (!response.Success || response.Data == null) + throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); + + return response.Data; + } + + private int GetUpdateIntervalMs(TimeSpan? updateInterval) => + updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds; + /// /// Build following Binance official guidelines /// @@ -80,7 +113,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, // Step 1: Open WebSocket stream and start buffering _logger.Debug($"1: Opening WebSocket stream for {marketDepth.Symbol}"); - var updateIntervalMs = updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds; + var updateIntervalMs = GetUpdateIntervalMs(updateInterval); CallResult subscriptionResult; @@ -118,32 +151,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, // Step 3: Get depth snapshot _logger.Debug($"3: Getting order book snapshot for {marketDepth.Symbol}"); - (bool Success, IBinanceOrderBook Data, Error Error) response; - - switch (marketDepth.Symbol.ContractType) - { - case ContractType.Spot: - WebCallResult spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync( - marketDepth.Symbol.FullName, orderBookDepth, ct) - .ConfigureAwait(false); - - response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); - break; - case ContractType.Futures: - WebCallResult futuresResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( - marketDepth.Symbol.FullName, orderBookDepth, ct) - .ConfigureAwait(false); - - response = (futuresResponse.Success, futuresResponse.Data, futuresResponse.Error); - break; - default: - throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); - } - - if (!response.Success || response.Data == null) - throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); - - IBinanceOrderBook snapshot = response.Data; + IBinanceOrderBook snapshot = await GetOrderBookSnapshotAsync(marketDepth.Symbol, orderBookDepth, ct); _logger.Debug($"Snapshot received: LastUpdateId={snapshot.LastUpdateId}"); // Step 4: Check if snapshot is valid @@ -162,30 +170,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, while (firstEvent != null && snapshot.LastUpdateId < firstEvent.FirstUpdateId) { _logger.Warn($"Snapshot too old: LastUpdateId={snapshot.LastUpdateId} < FirstEvent.U={firstEvent.FirstUpdateId}. Retrying..."); - // Snapshot is too old, need to get a new one - switch (marketDepth.Symbol.ContractType) - { - case ContractType.Spot: - WebCallResult spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync( - marketDepth.Symbol.FullName, orderBookDepth, ct) - .ConfigureAwait(false); - - response = (spotResponse.Success, spotResponse.Data, spotResponse.Error); - break; - case ContractType.Futures: - WebCallResult futuresResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync( - marketDepth.Symbol.FullName, orderBookDepth, ct) - .ConfigureAwait(false); - - response = (futuresResponse.Success, futuresResponse.Data, futuresResponse.Error); - break; - default: - throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); - } - - if (!response.Success || response.Data == null) - throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); - snapshot = response.Data; + snapshot = await GetOrderBookSnapshotAsync(marketDepth.Symbol, orderBookDepth, ct); _logger.Debug($"New snapshot received: LastUpdateId={snapshot.LastUpdateId}"); lock (_eventBuffer) @@ -241,24 +226,26 @@ public async Task StreamUpdatesAsync(MarketDepth marketDepth, TimeSpan? updateIn // Step 1 & 2: Open WebSocket and buffer events _logger.Debug($"1 & 2: Streaming updates: Opening WebSocket stream for {marketDepth.Symbol}"); + + var updateIntervalMs = GetUpdateIntervalMs(updateInterval); CallResult subscriptionResult; switch (marketDepth.Symbol.ContractType) { case ContractType.Spot: subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol.FullName, - updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, - data => OnSpotOrderBookUpdate(marketDepth, data), - ct) + marketDepth.Symbol.FullName, + updateIntervalMs, + data => OnSpotOrderBookUpdate(marketDepth, data), + ct) .ConfigureAwait(false); break; case ContractType.Futures: subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol.FullName, - updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, - data => OnFuturesOrderBookUpdate(marketDepth, data), - ct) + marketDepth.Symbol.FullName, + updateIntervalMs, + data => OnFuturesOrderBookUpdate(marketDepth, data), + ct) .ConfigureAwait(false); break; default: From eac4f77d1f42146ba6a8590559889ef5fb585b67 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Fri, 21 Nov 2025 16:21:41 +0100 Subject: [PATCH 21/25] Improve naming and add documentations --- src/BinanceBot.Market/MarketDepthManager.cs | 167 +++++++++++--------- 1 file changed, 95 insertions(+), 72 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 144e7f7..b2a477f 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -31,8 +31,8 @@ public class MarketDepthManager private readonly Logger _logger; private readonly Queue _eventBuffer = new(); - private long _localOrderBookUpdateId = 0; - private bool _isSnapshotLoaded = false; + private long _lastProcessedUpdateId = 0; + private bool _snapshotApplied = false; private readonly TimeSpan _defaultUpdateInterval = TimeSpan.FromMilliseconds(100); @@ -56,6 +56,15 @@ public MarketDepthManager(IBinanceClient restClient, IBinanceSocketClient webSoc } + /// + /// Retrieves an order book snapshot from the Binance REST API. + /// + /// The market symbol to get the order book for + /// Maximum number of price levels to retrieve + /// Cancellation token + /// Order book snapshot containing asks, bids, and last update ID + /// Thrown when contract type is unknown + /// Thrown when the API request fails private async Task GetOrderBookSnapshotAsync(MarketSymbol symbol, short orderBookDepth, CancellationToken ct) { (bool Success, IBinanceOrderBook Data, Error Error) response; @@ -86,6 +95,56 @@ private async Task GetOrderBookSnapshotAsync(MarketSymbol sym return response.Data; } + /// + /// Subscribes to order book updates via WebSocket for the specified market. + /// + /// The market symbol to subscribe to + /// Update interval in milliseconds + /// Callback for order book updates + /// Cancellation token + /// Update subscription + /// Thrown when contract type is unknown + /// Thrown when subscription fails + private async Task SubscribeToOrderBookAsync( + MarketSymbol symbol, + int updateIntervalMs, + Action onUpdate, + CancellationToken ct) + { + CallResult result; + switch (symbol.ContractType) + { + case ContractType.Spot: + result = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + symbol.FullName, + updateIntervalMs, + data => onUpdate(data.Data), + ct) + .ConfigureAwait(false); + break; + case ContractType.Futures: + result = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( + symbol.FullName, + updateIntervalMs, + data => onUpdate(data.Data), + ct) + .ConfigureAwait(false); + break; + default: + throw new ArgumentOutOfRangeException(nameof(symbol.ContractType), "Unknown contract type."); + } + + if (!result.Success || result.Data == null) + throw new InvalidOperationException($"Failed to subscribe to order book updates: {result.Error?.Message}"); + + return result.Data; + } + + /// + /// Calculates the update interval in milliseconds, using the default if not specified. + /// + /// Optional update interval (100ms or 1000ms recommended) + /// Update interval in milliseconds private int GetUpdateIntervalMs(TimeSpan? updateInterval) => updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds; @@ -114,33 +173,11 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, _logger.Debug($"1: Opening WebSocket stream for {marketDepth.Symbol}"); var updateIntervalMs = GetUpdateIntervalMs(updateInterval); - - CallResult subscriptionResult; - - switch (marketDepth.Symbol.ContractType) - { - case ContractType.Spot: - subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol.FullName, updateIntervalMs, - data => OnSpotOrderBookUpdate(marketDepth, data), - ct) - .ConfigureAwait(false); - break; - case ContractType.Futures: - subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol.FullName, updateIntervalMs, - data => OnFuturesOrderBookUpdate(marketDepth, data), - ct) - .ConfigureAwait(false); - break; - default: - throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); - } - - if (!subscriptionResult.Success || subscriptionResult.Data == null) - throw new InvalidOperationException($"Failed to subscribe to order book updates: {subscriptionResult.Error?.Message}"); - - _subscription = subscriptionResult.Data; + _subscription = await SubscribeToOrderBookAsync( + marketDepth.Symbol, + updateIntervalMs, + data => OnDepthUpdate(marketDepth, data), + ct); // Step 2: Wait a bit to buffer some events // Use longer buffer time to ensure we have enough events before snapshot @@ -193,8 +230,8 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, // Step 6: Set local order book to snapshot _logger.Debug($"6: Applying snapshot with {snapshot.Asks.Count()} asks and {snapshot.Bids.Count()} bids"); marketDepth.UpdateDepth(snapshot.Asks, snapshot.Bids, snapshot.LastUpdateId); - _localOrderBookUpdateId = marketDepth.LastUpdateId ?? throw new InvalidOperationException("MarketDepth.LastUpdateId is null after applying snapshot"); - _isSnapshotLoaded = marketDepth.LastUpdateId == snapshot.LastUpdateId; + _lastProcessedUpdateId = marketDepth.LastUpdateId ?? throw new InvalidOperationException("MarketDepth.LastUpdateId is null after applying snapshot"); + _snapshotApplied = marketDepth.LastUpdateId == snapshot.LastUpdateId; // Step 7: Apply buffered updates int appliedCount = 0; @@ -203,7 +240,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, var bufferedEvent = _eventBuffer.Peek(); if (bufferedEvent != null) { - ApplyDepthUpdate(marketDepth, bufferedEvent); + ProcessDepthUpdate(marketDepth, bufferedEvent); appliedCount++; } _eventBuffer.Dequeue(); @@ -228,31 +265,11 @@ public async Task StreamUpdatesAsync(MarketDepth marketDepth, TimeSpan? updateIn _logger.Debug($"1 & 2: Streaming updates: Opening WebSocket stream for {marketDepth.Symbol}"); var updateIntervalMs = GetUpdateIntervalMs(updateInterval); - CallResult subscriptionResult; - - switch (marketDepth.Symbol.ContractType) - { - case ContractType.Spot: - subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol.FullName, - updateIntervalMs, - data => OnSpotOrderBookUpdate(marketDepth, data), - ct) - .ConfigureAwait(false); - break; - case ContractType.Futures: - subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol.FullName, - updateIntervalMs, - data => OnFuturesOrderBookUpdate(marketDepth, data), - ct) - .ConfigureAwait(false); - break; - default: - throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type."); - } - - _subscription = subscriptionResult.Data; + _subscription = await SubscribeToOrderBookAsync( + marketDepth.Symbol, + updateIntervalMs, + data => OnDepthUpdate(marketDepth, data), + ct); } /// @@ -267,19 +284,18 @@ public async Task StopStreamingAsync(CancellationToken ct = default) } } - private void OnSpotOrderBookUpdate(MarketDepth marketDepth, DataEvent eventData) => - OnDepthUpdate(marketDepth, eventData.Data); - - private void OnFuturesOrderBookUpdate(MarketDepth marketDepth, DataEvent eventData) => - OnDepthUpdate(marketDepth, eventData.Data); - + /// + /// Processes incoming order book updates, buffering before snapshot or applying after. + /// + /// The market depth to update + /// The order book update event private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData) { if (eventData == null) return; lock (_eventBuffer) { - if (!_isSnapshotLoaded) + if (!_snapshotApplied) { // Step 2: Buffer events before snapshot is loaded _eventBuffer.Enqueue(eventData); @@ -288,38 +304,45 @@ private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook event } // Apply update to local order book - ApplyDepthUpdate(marketDepth, eventData); + ProcessDepthUpdate(marketDepth, eventData); } } - private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData) + /// + /// Process and apply a depth update event to the market depth. + /// Validates event sequence and updates the local order book. + /// + /// The market depth to update + /// The order book update event + /// Thrown when updates are missed (Spot markets only) + private void ProcessDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData) { // Step 7: Apply update procedure long lastUpdateId = eventData.LastUpdateId; // 1. Decide whether the update event can be applied - if (lastUpdateId <= _localOrderBookUpdateId) + if (lastUpdateId <= _lastProcessedUpdateId) { // Event is older than local order book, ignore - _logger.Debug($"Ignoring old event: u={lastUpdateId} <= local={_localOrderBookUpdateId}"); + _logger.Debug($"Ignoring old event: u={lastUpdateId} <= local={_lastProcessedUpdateId}"); return; } // Check for missed updates. WARN: not worked for Futures API - if (eventData.FirstUpdateId > _localOrderBookUpdateId + 1 && marketDepth.Symbol.ContractType == ContractType.Spot) + if (eventData.FirstUpdateId > _lastProcessedUpdateId + 1 && marketDepth.Symbol.ContractType == ContractType.Spot) { - string errorMsg = $"Missed order book updates. Expected U <= {_localOrderBookUpdateId + 1}, got U={eventData.FirstUpdateId}"; + string errorMsg = $"Missed order book updates. Expected U <= {_lastProcessedUpdateId + 1}, got U={eventData.FirstUpdateId}"; _logger.Error(errorMsg); throw new InvalidOperationException(errorMsg); } // 2. Update price levels - if (_localOrderBookUpdateId % 100 == 0) // Log every 100th update to avoid flooding + if (_lastProcessedUpdateId % 100 == 0) // Log every 100th update to avoid flooding _logger.Debug($"Applying update: U={eventData.FirstUpdateId}, u={lastUpdateId}, Asks={eventData.Asks.Count()}, Bids={eventData.Bids.Count()}"); marketDepth.UpdateDepth(eventData.Asks, eventData.Bids, lastUpdateId); // 3. Set order book update ID - _localOrderBookUpdateId = lastUpdateId; + _lastProcessedUpdateId = lastUpdateId; } } From 24219d9790c7ab851f729ea82c81edf80afbf839 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Fri, 21 Nov 2025 16:31:12 +0100 Subject: [PATCH 22/25] Improve parameter validation and add unit tests --- src/BinanceBot.Market/Domain/MarketSymbol.cs | 43 ++- .../Domain/MarketSymbolTests.cs | 280 ++++++++++++++++++ 2 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs diff --git a/src/BinanceBot.Market/Domain/MarketSymbol.cs b/src/BinanceBot.Market/Domain/MarketSymbol.cs index 0a98219..a8430ed 100644 --- a/src/BinanceBot.Market/Domain/MarketSymbol.cs +++ b/src/BinanceBot.Market/Domain/MarketSymbol.cs @@ -28,34 +28,44 @@ public record MarketSymbol /// /// Initializes a new instance of the class. /// - /// - /// - /// - /// Thrown when baseAsset or quoteAsset is null. + /// The base asset of the trading pair (e.g., "BTC", "ETH") + /// The quote asset of the trading pair (e.g., "USDT", "BTC") + /// The contract type (Spot or Futures) + /// Thrown when baseAsset or quoteAsset is null, empty or whitespace. public MarketSymbol(string baseAsset, string quoteAsset, ContractType contractType) { - BaseAsset = baseAsset ?? throw new ArgumentNullException(nameof(baseAsset)); - QuoteAsset = quoteAsset ?? throw new ArgumentNullException(nameof(quoteAsset)); + if (string.IsNullOrWhiteSpace(baseAsset)) + throw new ArgumentException("Base asset cannot be empty or whitespace", nameof(baseAsset)); + if (string.IsNullOrWhiteSpace(quoteAsset)) + throw new ArgumentException("Quote asset cannot be empty or whitespace", nameof(quoteAsset)); + + BaseAsset = baseAsset.Trim().ToUpperInvariant(); + QuoteAsset = quoteAsset.Trim().ToUpperInvariant(); ContractType = contractType; } /// /// Initializes a new instance of the class from a pair string. /// - /// The trading pair in the format "BASE/QUOTE". + /// The trading pair in the format "BASE/QUOTE" (e.g., "BTC/USDT", "ETH/BTC") /// The contract type (Spot or Futures). Defaults to Spot. - /// Thrown when the pair format is invalid. + /// Thrown when the pair is null, empty, or not in the correct format. public MarketSymbol(string pair, ContractType contractType = ContractType.Spot) { if (string.IsNullOrWhiteSpace(pair)) throw new ArgumentException("Pair cannot be null or empty", nameof(pair)); - var assets = pair.Split('/', StringSplitOptions.RemoveEmptyEntries); + var assets = pair.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (assets.Length != 2) - throw new ArgumentException("Pair must be in the format 'BASE/QUOTE'", nameof(pair)); + throw new ArgumentException("Pair must be in the format 'BASE/QUOTE' (e.g., 'BTC/USDT')", nameof(pair)); + + if (string.IsNullOrWhiteSpace(assets[0])) + throw new ArgumentException("Base asset cannot be empty", nameof(pair)); + if (string.IsNullOrWhiteSpace(assets[1])) + throw new ArgumentException("Quote asset cannot be empty", nameof(pair)); - BaseAsset = assets[0]; - QuoteAsset = assets[1]; + BaseAsset = assets[0].ToUpperInvariant(); + QuoteAsset = assets[1].ToUpperInvariant(); ContractType = contractType; } @@ -70,14 +80,17 @@ public MarketSymbol(string pair, ContractType contractType = ContractType.Spot) public string QuoteAsset { get; init; } /// - /// The symbol name, can be used to overwrite the default formatted name + /// The symbol name in Binance API format (e.g., "BTCUSDT") /// public string FullName => $"{BaseAsset}{QuoteAsset}"; /// - /// The Contract type of the symbol + /// The contract type of the symbol (Spot or Futures) /// public ContractType ContractType { get; init; } - override public string ToString() => $"{BaseAsset}/{QuoteAsset} ({ContractType})"; + /// + /// Returns a string representation in the format "BASE/QUOTE (ContractType)" (e.g., "BTC/USDT (Spot)") + /// + public override string ToString() => $"{BaseAsset}/{QuoteAsset} ({ContractType})"; } diff --git a/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs b/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs new file mode 100644 index 0000000..a2586d1 --- /dev/null +++ b/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs @@ -0,0 +1,280 @@ +using BinanceBot.Market.Domain; + +namespace BinanceBot.Market.Tests.Domain; + +public class MarketSymbolTests +{ + #region Constructor Tests - Three Parameters + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange & Act + var symbol = new MarketSymbol("BTC", "USDT", ContractType.Spot); + + // Assert + Assert.Equal("BTC", symbol.BaseAsset); + Assert.Equal("USDT", symbol.QuoteAsset); + Assert.Equal(ContractType.Spot, symbol.ContractType); + Assert.Equal("BTCUSDT", symbol.FullName); + Assert.Equal("BTC/USDT (Spot)", symbol.ToString()); + } + + [Theory] + [InlineData("btc", "usdt", "BTC", "USDT")] + [InlineData("BTC", "USDT", "BTC", "USDT")] + [InlineData("Eth", "BtC", "ETH", "BTC")] + [InlineData(" BNB ", " BUSD ", "BNB", "BUSD")] + public void Constructor_NormalizesToUpperCase(string baseInput, string quoteInput, string expectedBase, string expectedQuote) + { + // Act + var symbol = new MarketSymbol(baseInput, quoteInput, ContractType.Spot); + + // Assert + Assert.Equal(expectedBase, symbol.BaseAsset); + Assert.Equal(expectedQuote, symbol.QuoteAsset); + } + + [Fact] + public void Constructor_WithFuturesContractType_CreatesInstance() + { + // Act + var symbol = new MarketSymbol("ETH", "USDT", ContractType.Futures); + + // Assert + Assert.Equal(ContractType.Futures, symbol.ContractType); + Assert.Equal("ETH/USDT (Futures)", symbol.ToString()); + } + + [Fact] + public void Constructor_WithNullBaseAsset_ThrowsArgumentNullException() + { + // Act & Assert + var ex = Assert.Throws(() => + new MarketSymbol(null, "USDT", ContractType.Spot)); + Assert.Equal("baseAsset", ex.ParamName); + } + + [Fact] + public void Constructor_WithNullQuoteAsset_ThrowsArgumentNullException() + { + // Act & Assert + var ex = Assert.Throws(() => + new MarketSymbol("BTC", null, ContractType.Spot)); + Assert.Equal("quoteAsset", ex.ParamName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_WithEmptyBaseAsset_ThrowsArgumentException(string emptyValue) + { + // Act & Assert + var ex = Assert.Throws(() => + new MarketSymbol(emptyValue, "USDT", ContractType.Spot)); + Assert.Equal("baseAsset", ex.ParamName); + Assert.Contains("empty or whitespace", ex.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_WithEmptyQuoteAsset_ThrowsArgumentException(string emptyValue) + { + // Act & Assert + var ex = Assert.Throws(() => + new MarketSymbol("BTC", emptyValue, ContractType.Spot)); + Assert.Equal("quoteAsset", ex.ParamName); + Assert.Contains("empty or whitespace", ex.Message); + } + + #endregion + + #region Constructor Tests - Pair String + + [Theory] + [InlineData("BTC/USDT", "BTC", "USDT")] + [InlineData("ETH/BTC", "ETH", "BTC")] + [InlineData("BNB/BUSD", "BNB", "BUSD")] + [InlineData("DOGE/USDT", "DOGE", "USDT")] + public void Constructor_WithValidPair_CreatesInstance(string pair, string expectedBase, string expectedQuote) + { + // Act + var symbol = new MarketSymbol(pair); + + // Assert + Assert.Equal(expectedBase, symbol.BaseAsset); + Assert.Equal(expectedQuote, symbol.QuoteAsset); + Assert.Equal(ContractType.Spot, symbol.ContractType); + } + + [Theory] + [InlineData("btc/usdt", "BTC", "USDT")] + [InlineData("Eth/Btc", "ETH", "BTC")] + [InlineData(" BNB / BUSD ", "BNB", "BUSD")] + public void Constructor_WithPair_NormalizesToUpperCase(string pair, string expectedBase, string expectedQuote) + { + // Act + var symbol = new MarketSymbol(pair); + + // Assert + Assert.Equal(expectedBase, symbol.BaseAsset); + Assert.Equal(expectedQuote, symbol.QuoteAsset); + } + + [Fact] + public void Constructor_WithPairAndFutures_CreatesInstance() + { + // Act + var symbol = new MarketSymbol("BTC/USDT", ContractType.Futures); + + // Assert + Assert.Equal("BTC", symbol.BaseAsset); + Assert.Equal("USDT", symbol.QuoteAsset); + Assert.Equal(ContractType.Futures, symbol.ContractType); + } + + [Theory] + [InlineData(null!)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_WithNullOrEmptyPair_ThrowsArgumentException(string? invalidPair) + { + // Act & Assert + var ex = Assert.Throws(() => + new MarketSymbol(invalidPair)); + Assert.Equal("pair", ex.ParamName); + Assert.Contains("null or empty", ex.Message); + } + + [Theory] + [InlineData("BTCUSDT")] + [InlineData("BTC")] + [InlineData("BTC/USDT/EUR")] + [InlineData("BTC-USDT")] + public void Constructor_WithInvalidPairFormat_ThrowsArgumentException(string invalidPair) + { + // Act & Assert + var ex = Assert.Throws(() => + new MarketSymbol(invalidPair)); + Assert.Equal("pair", ex.ParamName); + Assert.Contains("BASE/QUOTE", ex.Message); + } + + [Theory] + [InlineData("/USDT")] + [InlineData("BTC/")] + [InlineData(" / ")] + public void Constructor_WithEmptyAssetInPair_ThrowsArgumentException(string invalidPair) + { + // Act & Assert + Assert.Throws(() => + new MarketSymbol(invalidPair)); + } + + #endregion + + #region Property Tests + + [Fact] + public void FullName_ReturnsCorrectFormat() + { + // Arrange + var symbol = new MarketSymbol("BTC", "USDT", ContractType.Spot); + + // Act & Assert + Assert.Equal("BTCUSDT", symbol.FullName); + } + + [Fact] + public void ToString_ReturnsCorrectFormat() + { + // Arrange + var spotSymbol = new MarketSymbol("BTC", "USDT", ContractType.Spot); + var futuresSymbol = new MarketSymbol("ETH", "BTC", ContractType.Futures); + + // Act & Assert + Assert.Equal("BTC/USDT (Spot)", spotSymbol.ToString()); + Assert.Equal("ETH/BTC (Futures)", futuresSymbol.ToString()); + } + + #endregion + + #region Record Equality Tests + + [Fact] + public void Equals_WithSameValues_ReturnsTrue() + { + // Arrange + var symbol1 = new MarketSymbol("BTC", "USDT", ContractType.Spot); + var symbol2 = new MarketSymbol("BTC", "USDT", ContractType.Spot); + + // Act & Assert + Assert.Equal(symbol1, symbol2); + Assert.True(symbol1 == symbol2); + } + + [Fact] + public void Equals_WithDifferentBaseAsset_ReturnsFalse() + { + // Arrange + var symbol1 = new MarketSymbol("BTC", "USDT", ContractType.Spot); + var symbol2 = new MarketSymbol("ETH", "USDT", ContractType.Spot); + + // Act & Assert + Assert.NotEqual(symbol1, symbol2); + Assert.True(symbol1 != symbol2); + } + + [Fact] + public void Equals_WithDifferentContractType_ReturnsFalse() + { + // Arrange + var symbol1 = new MarketSymbol("BTC", "USDT", ContractType.Spot); + var symbol2 = new MarketSymbol("BTC", "USDT", ContractType.Futures); + + // Act & Assert + Assert.NotEqual(symbol1, symbol2); + } + + [Fact] + public void GetHashCode_WithSameValues_ReturnsSameHashCode() + { + // Arrange + var symbol1 = new MarketSymbol("BTC", "USDT", ContractType.Spot); + var symbol2 = new MarketSymbol("BTC", "USDT", ContractType.Spot); + + // Act & Assert + Assert.Equal(symbol1.GetHashCode(), symbol2.GetHashCode()); + } + + #endregion + + #region Integration Tests + + [Fact] + public void BothConstructors_WithSameData_CreateEqualInstances() + { + // Arrange & Act + var symbol1 = new MarketSymbol("BTC", "USDT", ContractType.Spot); + var symbol2 = new MarketSymbol("BTC/USDT", ContractType.Spot); + + // Assert + Assert.Equal(symbol1, symbol2); + } + + [Fact] + public void BothConstructors_WithCaseInsensitiveInput_CreateEqualInstances() + { + // Arrange & Act + var symbol1 = new MarketSymbol("btc", "usdt", ContractType.Futures); + var symbol2 = new MarketSymbol("BTC/USDT", ContractType.Futures); + + // Assert + Assert.Equal(symbol1, symbol2); + } + + #endregion +} From 14419f0c0be27960b5b20379422e99babfd44272 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Fri, 21 Nov 2025 16:42:34 +0100 Subject: [PATCH 23/25] Fix tests --- tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs b/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs index a2586d1..3dde0af 100644 --- a/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs +++ b/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs @@ -47,19 +47,19 @@ public void Constructor_WithFuturesContractType_CreatesInstance() } [Fact] - public void Constructor_WithNullBaseAsset_ThrowsArgumentNullException() + public void Constructor_WithNullBaseAsset_ThrowsArgumentException() { // Act & Assert - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => new MarketSymbol(null, "USDT", ContractType.Spot)); Assert.Equal("baseAsset", ex.ParamName); } [Fact] - public void Constructor_WithNullQuoteAsset_ThrowsArgumentNullException() + public void Constructor_WithNullQuoteAsset_ThrowsArgumentException() { // Act & Assert - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => new MarketSymbol("BTC", null, ContractType.Spot)); Assert.Equal("quoteAsset", ex.ParamName); } From e00a7450a4b6b840f64633368eefbbe40791e700 Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Fri, 21 Nov 2025 18:44:35 +0300 Subject: [PATCH 24/25] Nullable Error Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinanceBot.Market/MarketDepthManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index b2a477f..2590810 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -67,7 +67,7 @@ public MarketDepthManager(IBinanceClient restClient, IBinanceSocketClient webSoc /// Thrown when the API request fails private async Task GetOrderBookSnapshotAsync(MarketSymbol symbol, short orderBookDepth, CancellationToken ct) { - (bool Success, IBinanceOrderBook Data, Error Error) response; + (bool Success, IBinanceOrderBook Data, Error? Error) response; switch (symbol.ContractType) { From 5298b8eeaa7cbc7487464cde738a850c4bd1fea5 Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Fri, 21 Nov 2025 18:46:18 +0300 Subject: [PATCH 25/25] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinanceBot.Market/MarketDepthManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 2590810..9ae6ce8 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -328,7 +328,7 @@ private void ProcessDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook return; } - // Check for missed updates. WARN: not worked for Futures API + // Check for missed updates. WARN: does not work for Futures API if (eventData.FirstUpdateId > _lastProcessedUpdateId + 1 && marketDepth.Symbol.ContractType == ContractType.Spot) { string errorMsg = $"Missed order book updates. Expected U <= {_lastProcessedUpdateId + 1}, got U={eventData.FirstUpdateId}";