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/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 85% rename from src/BinanceBot.Market/Core/MarketDepth.cs rename to src/BinanceBot.Market/Domain/MarketDepth.cs index 1e2c556..3a8403c 100644 --- a/src/BinanceBot.Market/Core/MarketDepth.cs +++ b/src/BinanceBot.Market/Domain/MarketDepth.cs @@ -3,26 +3,26 @@ 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 /// 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(); @@ -59,14 +59,14 @@ public MarketDepth(string 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(string 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/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/Domain/MarketSymbol.cs b/src/BinanceBot.Market/Domain/MarketSymbol.cs new file mode 100644 index 0000000..a8430ed --- /dev/null +++ b/src/BinanceBot.Market/Domain/MarketSymbol.cs @@ -0,0 +1,96 @@ + +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. + /// + /// 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) + { + 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" (e.g., "BTC/USDT", "ETH/BTC") + /// The contract type (Spot or Futures). Defaults to Spot. + /// 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 | StringSplitOptions.TrimEntries); + if (assets.Length != 2) + 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].ToUpperInvariant(); + QuoteAsset = assets[1].ToUpperInvariant(); + 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 in Binance API format (e.g., "BTCUSDT") + /// + public string FullName => $"{BaseAsset}{QuoteAsset}"; + + /// + /// The contract type of the symbol (Spot or Futures) + /// + public ContractType ContractType { get; init; } + + /// + /// 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/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/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..9ae6ce8 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -6,7 +6,8 @@ using Binance.Net.Interfaces; using Binance.Net.Interfaces.Clients; using Binance.Net.Objects.Models.Spot; -using BinanceBot.Market.Core; +using Binance.Net.Objects.Models.Futures; +using BinanceBot.Market.Domain; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets; using NLog; @@ -30,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); @@ -55,6 +56,98 @@ 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; + + 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; + } + + /// + /// 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; + /// /// Build following Binance official guidelines /// @@ -79,38 +172,31 @@ 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 subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol, updateIntervalMs, + var updateIntervalMs = GetUpdateIntervalMs(updateInterval); + _subscription = await SubscribeToOrderBookAsync( + marketDepth.Symbol, + updateIntervalMs, data => OnDepthUpdate(marketDepth, data), - ct) - .ConfigureAwait(false); - - if (!subscriptionResult.Success || subscriptionResult.Data == null) - throw new InvalidOperationException($"Failed to subscribe to order book updates: {subscriptionResult.Error?.Message}"); - - _subscription = subscriptionResult.Data; + ct); // 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); - if (!response.Success || response.Data == null) - throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); - - BinanceOrderBook snapshot = response.Data; + _logger.Debug($"3: Getting order book snapshot for {marketDepth.Symbol}"); + + IBinanceOrderBook snapshot = await GetOrderBookSnapshotAsync(marketDepth.Symbol, orderBookDepth, ct); _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.Any() ? _eventBuffer.Peek() : null; } if (firstEvent != null) @@ -121,16 +207,12 @@ 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 - response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, orderBookDepth, ct).ConfigureAwait(false); - 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) { - firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() as BinanceEventOrderBook : null; + firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() : null; } } @@ -148,17 +230,17 @@ 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; + _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; while (_eventBuffer.Any()) { - var bufferedEvent = _eventBuffer.Peek() as BinanceEventOrderBook; + var bufferedEvent = _eventBuffer.Peek(); if (bufferedEvent != null) { - ApplyDepthUpdate(marketDepth, bufferedEvent); + ProcessDepthUpdate(marketDepth, bufferedEvent); appliedCount++; } _eventBuffer.Dequeue(); @@ -169,27 +251,28 @@ 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) throw new ArgumentNullException(nameof(marketDepth)); // Step 1 & 2: Open WebSocket and buffer events - var subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + _logger.Debug($"1 & 2: Streaming updates: Opening WebSocket stream for {marketDepth.Symbol}"); + + var updateIntervalMs = GetUpdateIntervalMs(updateInterval); + _subscription = await SubscribeToOrderBookAsync( marketDepth.Symbol, - updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, + updateIntervalMs, data => OnDepthUpdate(marketDepth, data), ct); - - _subscription = subscriptionResult.Data; } + + /// /// Stop streaming updates and unsubscribe /// public async Task StopStreamingAsync(CancellationToken ct = default) @@ -201,56 +284,65 @@ public async Task StopStreamingAsync(CancellationToken ct = default) } } - private void OnDepthUpdate(MarketDepth marketDepth, DataEvent dataEvent) + /// + /// 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) { - var data = dataEvent.Data as BinanceEventOrderBook; - if (data == null) return; + if (eventData == null) return; lock (_eventBuffer) { - if (!_isSnapshotLoaded) + if (!_snapshotApplied) { // Step 2: Buffer events before snapshot is loaded - _eventBuffer.Enqueue(dataEvent.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); + ProcessDepthUpdate(marketDepth, eventData); } } - private void ApplyDepthUpdate(MarketDepth marketDepth, BinanceEventOrderBook 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 (eventData.LastUpdateId <= _localOrderBookUpdateId) + if (lastUpdateId <= _lastProcessedUpdateId) { // 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={_lastProcessedUpdateId}"); return; } - - if (eventData.FirstUpdateId > _localOrderBookUpdateId + 1) + + // Check for missed updates. WARN: does not work for Futures API + if (eventData.FirstUpdateId > _lastProcessedUpdateId + 1 && marketDepth.Symbol.ContractType == ContractType.Spot) { - // 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."); + string errorMsg = $"Missed order book updates. Expected U <= {_lastProcessedUpdateId + 1}, got U={eventData.FirstUpdateId}"; + _logger.Error(errorMsg); + throw new InvalidOperationException(errorMsg); } - // 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={eventData.LastUpdateId}, Asks={eventData.Asks.Count()}, Bids={eventData.Bids.Count()}"); - marketDepth.UpdateDepth(eventData.Asks, eventData.Bids, eventData.LastUpdateId); + 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 = eventData.LastUpdateId; + _lastProcessedUpdateId = lastUpdateId; } -} \ No newline at end of file +} diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index a7d0d61..fcb7aa6 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; @@ -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.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.MarketBot.Console/Program.cs b/src/BinanceBot.MarketBot.Console/Program.cs index 1f23b3d..1a27e68 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))) { diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index 1a8e583..79d6cb5 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; @@ -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.Futures); 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"); } } diff --git a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs index c5571e6..16cef76 100644 --- a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs +++ b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs @@ -1,37 +1,73 @@ -using Binance.Net.Enums; using Binance.Net.Objects.Models; -using BinanceBot.Market.Core; - +using BinanceBot.Market.Domain; namespace BinanceBot.Market.Tests.Core; public class MarketDepthTests { - [Fact] - public void Constructor_WithValidSymbol_CreatesInstance() + private static MarketDepth CreateTestMarketDepth() => + new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Spot)); + + private static MarketDepth CreateTestMarketDepthPerpetual() => + new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Futures)); + + [Theory] + [InlineData(ContractType.Spot)] + [InlineData(ContractType.Futures)] + public void Constructor_WithValidSymbol_CreatesInstance(ContractType contractType) { - // Arrange & Act - var marketDepth = new MarketDepth("BTCUSDT"); + // Arrange + var symbol = new MarketSymbol("BTC", "USDT", contractType); + + // Act + var marketDepth = new MarketDepth(symbol); // Assert - Assert.Equal("BTCUSDT", marketDepth.Symbol); - Assert.Null(marketDepth.LastUpdateTime); + Assert.Equal(symbol, marketDepth.Symbol); + Assert.Equal(contractType, marketDepth.Symbol.ContractType); + Assert.Null(marketDepth.LastUpdateId); 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 }, + 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(123456, marketDepth.LastUpdateId); + 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_WithValidData_UpdatesOrderBook_Perpetual() + { + // Arrange + var marketDepth = CreateTestMarketDepthPerpetual(); var asks = new List { new() { Price = 50000m, Quantity = 1.5m }, @@ -47,7 +83,8 @@ public void UpdateDepth_WithValidData_UpdatesOrderBook() marketDepth.UpdateDepth(asks, bids, 123456); // Assert - Assert.Equal(123456, marketDepth.LastUpdateTime); + Assert.Equal(ContractType.Futures, marketDepth.Symbol.ContractType); + Assert.Equal(123456, marketDepth.LastUpdateId); Assert.Equal(2, marketDepth.Asks.Count()); Assert.Equal(2, marketDepth.Bids.Count()); Assert.Equal(50000m, marketDepth.BestAsk.Price); @@ -58,7 +95,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 } @@ -77,7 +114,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); } @@ -85,7 +122,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 }, @@ -113,7 +150,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); @@ -123,7 +160,32 @@ 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 } + }; + 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); + } + + [Fact] + public void BestPair_WhenOrderBookHasData_ReturnsPair_Perpetual() + { + // Arrange + var marketDepth = CreateTestMarketDepthPerpetual(); var asks = new List { new() { Price = 50000m, Quantity = 1.5m } @@ -142,13 +204,14 @@ public void BestPair_WhenOrderBookHasData_ReturnsPair() Assert.Equal(50000m, bestPair.Ask.Price); Assert.Equal(49900m, bestPair.Bid.Price); Assert.Equal(100m, bestPair.PriceSpread); + Assert.Equal(ContractType.Futures, marketDepth.Symbol.ContractType); } [Fact] public void MarketDepthChanged_RaisesEvent_WhenDepthUpdated() { // Arrange - var marketDepth = new MarketDepth("BTCUSDT"); + var marketDepth = CreateTestMarketDepth(); MarketDepthChangedEventArgs? eventArgs = null; marketDepth.MarketDepthChanged += (sender, e) => eventArgs = e; @@ -174,7 +237,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; @@ -198,7 +261,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 }, @@ -222,7 +285,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 } @@ -246,7 +309,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/Domain/MarketSymbolTests.cs b/tests/BinanceBot.Market.Tests/Domain/MarketSymbolTests.cs new file mode 100644 index 0000000..3dde0af --- /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_ThrowsArgumentException() + { + // Act & Assert + var ex = Assert.Throws(() => + new MarketSymbol(null, "USDT", ContractType.Spot)); + Assert.Equal("baseAsset", ex.ParamName); + } + + [Fact] + public void Constructor_WithNullQuoteAsset_ThrowsArgumentException() + { + // 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 +} diff --git a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs index 7ca6165..6c7a1bd 100644 --- a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs +++ b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs @@ -1,7 +1,7 @@ using System.Threading; using System.Threading.Tasks; using Binance.Net.Interfaces.Clients; -using BinanceBot.Market.Core; +using BinanceBot.Market.Domain; using Moq; using NLog; @@ -20,6 +20,9 @@ public MarketDepthManagerTests() _mockLogger = new Mock(); } + private static MarketDepth CreateTestMarketDepth() => + new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Spot)); + [Fact] public void Constructor_WithNullRestClient_ThrowsArgumentNullException() { @@ -70,7 +73,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(() => @@ -82,7 +85,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(() => @@ -94,7 +97,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(() => @@ -106,7 +109,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(() =>