From 936d05c88f195b4d4347865febd3d12686ffdb66 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 15:56:55 +0100 Subject: [PATCH 01/14] Logging improvements --- .gitignore | 5 ++++- logs/.gitkeep | 0 src/BinanceBot.Market/BinanceBot.Market.csproj | 2 +- .../BinanceBot.MarketBot.Console.csproj | 2 +- src/BinanceBot.MarketBot.Console/NLog.config | 8 +++++--- .../BinanceBot.MarketViewer.Console.csproj | 4 ++-- src/BinanceBot.MarketViewer.Console/NLog.config | 8 +++++--- 7 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 logs/.gitkeep diff --git a/.gitignore b/.gitignore index c6543b1..704412f 100644 --- a/.gitignore +++ b/.gitignore @@ -262,4 +262,7 @@ __pycache__/ # Environment variables .env -!.env.example \ No newline at end of file +!.env.example + +# Logs +logs/*.log diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/BinanceBot.Market/BinanceBot.Market.csproj b/src/BinanceBot.Market/BinanceBot.Market.csproj index f7ca31c..b090306 100644 --- a/src/BinanceBot.Market/BinanceBot.Market.csproj +++ b/src/BinanceBot.Market/BinanceBot.Market.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/BinanceBot.MarketBot.Console/BinanceBot.MarketBot.Console.csproj b/src/BinanceBot.MarketBot.Console/BinanceBot.MarketBot.Console.csproj index 11affff..a3b0dc0 100644 --- a/src/BinanceBot.MarketBot.Console/BinanceBot.MarketBot.Console.csproj +++ b/src/BinanceBot.MarketBot.Console/BinanceBot.MarketBot.Console.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/BinanceBot.MarketBot.Console/NLog.config b/src/BinanceBot.MarketBot.Console/NLog.config index 35464a6..395cce2 100644 --- a/src/BinanceBot.MarketBot.Console/NLog.config +++ b/src/BinanceBot.MarketBot.Console/NLog.config @@ -4,12 +4,14 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> - + - - + + \ No newline at end of file diff --git a/src/BinanceBot.MarketViewer.Console/BinanceBot.MarketViewer.Console.csproj b/src/BinanceBot.MarketViewer.Console/BinanceBot.MarketViewer.Console.csproj index 88bff0f..7d261a3 100644 --- a/src/BinanceBot.MarketViewer.Console/BinanceBot.MarketViewer.Console.csproj +++ b/src/BinanceBot.MarketViewer.Console/BinanceBot.MarketViewer.Console.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/BinanceBot.MarketViewer.Console/NLog.config b/src/BinanceBot.MarketViewer.Console/NLog.config index 35464a6..d71b8dd 100644 --- a/src/BinanceBot.MarketViewer.Console/NLog.config +++ b/src/BinanceBot.MarketViewer.Console/NLog.config @@ -4,12 +4,14 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> - + - - + + \ No newline at end of file From 20a6151bddb41d181171ad397965679255dc2e0a Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 16:00:23 +0100 Subject: [PATCH 02/14] Enhance logging for Orderbook viewer --- src/BinanceBot.MarketViewer.Console/Program.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index 9fc54d0..9babd27 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -21,9 +21,9 @@ internal static class Program { #region Bot Settings // WARN: Set necessary token here - private const string Symbol = "ETHUSDT"; + private const string Symbol = "BNBUSDT"; private const int OrderBookDepth = 10; - private static readonly TimeSpan? OrderBookUpdateLimit = TimeSpan.FromMilliseconds(1000); + private static readonly TimeSpan? OrderBookUpdateLimit = TimeSpan.FromMilliseconds(100); #endregion private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); @@ -34,6 +34,8 @@ static async Task Main(string[] args) // Load .env file first DotEnv.Load(); + Logger.Debug($"Symbol: {Symbol}, OrderBookDepth: {OrderBookDepth}"); + // WARN: Set your credentials in .env file var apiKey = Environment.GetEnvironmentVariable("BINANCE_API_KEY") ?? throw new InvalidOperationException("BINANCE_API_KEY environment variable is not set"); var secret = Environment.GetEnvironmentVariable("BINANCE_SECRET") ?? throw new InvalidOperationException("BINANCE_SECRET environment variable is not set"); @@ -45,11 +47,13 @@ static async Task Main(string[] args) // 2. test connection + Logger.Info("Testing connection to Binance..."); await AnsiConsole.Status() .StartAsync("Testing connection...", async ctx => { var pingResult = await binanceRestClient.SpotApi.ExchangeData.PingAsync(); AnsiConsole.MarkupLine($"Ping time: [yellow]{pingResult.Data} ms[/]"); + Logger.Info($"Ping successful: {pingResult.Data} ms"); Task.Delay(1000).Wait(); }); @@ -108,13 +112,17 @@ await AnsiConsole.Status() // build order book + Logger.Info($"Building order book for {Symbol}..."); await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth); // stream order book updates + Logger.Info("Streaming order book updates..."); marketDepthManager.StreamUpdates(marketDepth, OrderBookUpdateLimit); WriteLine("Press Enter to exit..."); ReadLine(); + + Logger.Info("Order book viewer stopped"); } } } From 4058300abf3a1fc94f835586ff003636aab4eb19 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 16:02:33 +0100 Subject: [PATCH 03/14] Convert all to file-scoped namespaces --- .../Abstracts/BaseMarketBot.cs | 53 ++- src/BinanceBot.Market/Abstracts/IMarketBot.cs | 87 +++-- .../Abstracts/IMarketDepthPublisher.cs | 77 +++-- .../Abstracts/IMarketStrategy.cs | 19 +- .../MarketStrategyConfiguration.cs | 93 +++--- src/BinanceBot.Market/Core/MarketDepth.cs | 255 ++++++++------- src/BinanceBot.Market/Core/MarketDepthPair.cs | 59 ++-- src/BinanceBot.Market/Core/Quote.cs | 51 ++- src/BinanceBot.Market/CreateOrderRequest.cs | 35 +- src/BinanceBot.Market/MarketDepthManager.cs | 123 ++++--- src/BinanceBot.Market/MarketMakerBot.cs | 303 +++++++++--------- .../Strategies/NaiveMarketMakerStrategy.cs | 95 +++--- .../Utility/DescDecimalComparer.cs | 17 +- .../Utility/QuoteExtensions.cs | 13 +- src/BinanceBot.MarketBot.Console/Program.cs | 199 ++++++------ .../Program.cs | 159 +++++---- 16 files changed, 810 insertions(+), 828 deletions(-) diff --git a/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs b/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs index 185fbdb..86c13c4 100644 --- a/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs +++ b/src/BinanceBot.Market/Abstracts/BaseMarketBot.cs @@ -4,45 +4,44 @@ using Binance.Net.Objects.Models.Spot; using NLog; -namespace BinanceBot.Market +namespace BinanceBot.Market; + +/// +/// Base Market Bot +/// +/// +public abstract class BaseMarketBot : + IMarketBot, IDisposable + where TStrategy : class, IMarketStrategy { - /// - /// Base Market Bot - /// - /// - public abstract class BaseMarketBot : - IMarketBot, IDisposable - where TStrategy : class, IMarketStrategy - { - protected readonly Logger Logger; + protected readonly Logger Logger; - protected readonly TStrategy MarketStrategy; + protected readonly TStrategy MarketStrategy; - protected BaseMarketBot(string symbol, TStrategy marketStrategy, Logger logger) - { - Symbol = symbol ?? throw new ArgumentNullException(nameof(symbol)); - MarketStrategy = marketStrategy ?? throw new ArgumentNullException(nameof(marketStrategy)); - Logger = logger ?? LogManager.GetCurrentClassLogger(); - } + protected BaseMarketBot(string symbol, TStrategy marketStrategy, Logger logger) + { + Symbol = symbol ?? throw new ArgumentNullException(nameof(symbol)); + MarketStrategy = marketStrategy ?? throw new ArgumentNullException(nameof(marketStrategy)); + Logger = logger ?? LogManager.GetCurrentClassLogger(); + } - public string Symbol { get; } + public string Symbol { get; } - public abstract Task RunAsync(); + public abstract Task RunAsync(); - public abstract void Stop(); + public abstract void Stop(); - public abstract Task ValidateServerTimeAsync(); + public abstract Task ValidateServerTimeAsync(); - public abstract Task> GetOpenedOrdersAsync(string symbol); + public abstract Task> GetOpenedOrdersAsync(string symbol); - public abstract Task CancelOrdersAsync(IEnumerable orders); + public abstract Task CancelOrdersAsync(IEnumerable orders); - public abstract Task CreateOrderAsync(CreateOrderRequest order); + public abstract Task CreateOrderAsync(CreateOrderRequest order); - - public abstract void Dispose(); - } + + public abstract void Dispose(); } \ No newline at end of file diff --git a/src/BinanceBot.Market/Abstracts/IMarketBot.cs b/src/BinanceBot.Market/Abstracts/IMarketBot.cs index b8c73d3..cf5793a 100644 --- a/src/BinanceBot.Market/Abstracts/IMarketBot.cs +++ b/src/BinanceBot.Market/Abstracts/IMarketBot.cs @@ -3,51 +3,50 @@ using Binance.Net.Objects.Models.Spot; -namespace BinanceBot.Market +namespace BinanceBot.Market; + +/// +/// Market Bot Interface +/// +public interface IMarketBot { /// - /// Market Bot Interface + /// Symbol + /// + string Symbol { get; } + + + /// + /// Run bot + /// + Task RunAsync(); + + /// + /// Stop bot + /// + void Stop(); + + + /// + /// Validate connection w/ stock + /// + Task ValidateServerTimeAsync(); + + /// + /// Get currently opened orders + /// + /// + Task> GetOpenedOrdersAsync(string symbol); + + /// + /// Create new order + /// + /// + Task CreateOrderAsync(CreateOrderRequest order); + + /// + /// Cancel orders /// - public interface IMarketBot - { - /// - /// Symbol - /// - string Symbol { get; } - - - /// - /// Run bot - /// - Task RunAsync(); - - /// - /// Stop bot - /// - void Stop(); - - - /// - /// Validate connection w/ stock - /// - Task ValidateServerTimeAsync(); - - /// - /// Get currently opened orders - /// - /// - Task> GetOpenedOrdersAsync(string symbol); - - /// - /// Create new order - /// - /// - Task CreateOrderAsync(CreateOrderRequest order); - - /// - /// Cancel orders - /// - /// - Task CancelOrdersAsync(IEnumerable orders); - } + /// + Task CancelOrdersAsync(IEnumerable orders); } \ No newline at end of file diff --git a/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs b/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs index d314aad..6159738 100644 --- a/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs +++ b/src/BinanceBot.Market/Abstracts/IMarketDepthPublisher.cs @@ -2,59 +2,58 @@ using System.Collections.Generic; using BinanceBot.Market.Core; -namespace BinanceBot.Market +namespace BinanceBot.Market; + +/// +/// Publisher of events +/// +public interface IMarketDepthPublisher { /// - /// Publisher of events + /// Order book was changed /// - public interface IMarketDepthPublisher - { - /// - /// Order book was changed - /// - event EventHandler MarketDepthChanged; - - /// - /// Best was changed - /// - event EventHandler MarketBestPairChanged; - } - - + event EventHandler MarketDepthChanged; /// - /// Order book changed event args + /// Best was changed /// - public sealed class MarketBestPairChangedEventArgs : EventArgs - { - public MarketBestPairChangedEventArgs(MarketDepthPair marketBestPair) - { - MarketBestPair = marketBestPair ?? throw new ArgumentNullException(nameof(marketBestPair)); - } + event EventHandler MarketBestPairChanged; +} + + - public MarketDepthPair MarketBestPair { get; } +/// +/// Order book changed event args +/// +public sealed class MarketBestPairChangedEventArgs : EventArgs +{ + public MarketBestPairChangedEventArgs(MarketDepthPair marketBestPair) + { + MarketBestPair = marketBestPair ?? throw new ArgumentNullException(nameof(marketBestPair)); } + public MarketDepthPair MarketBestPair { get; } +} - /// - /// Best changed event args - /// - public sealed class MarketDepthChangedEventArgs : EventArgs + +/// +/// Best changed event args +/// +public sealed class MarketDepthChangedEventArgs : EventArgs +{ + public MarketDepthChangedEventArgs(IEnumerable asks, IEnumerable bids, long updateTime) { - public MarketDepthChangedEventArgs(IEnumerable asks, IEnumerable bids, long updateTime) - { - if (updateTime <= 0) throw new ArgumentOutOfRangeException(nameof(updateTime)); + if (updateTime <= 0) throw new ArgumentOutOfRangeException(nameof(updateTime)); - Asks = asks; - Bids = bids; - UpdateTime = updateTime; - } + Asks = asks; + Bids = bids; + UpdateTime = updateTime; + } - public IEnumerable Asks { get; } + public IEnumerable Asks { get; } - public IEnumerable Bids { get; } + public IEnumerable Bids { get; } - public long UpdateTime { get; } - } + public long UpdateTime { get; } } \ No newline at end of file diff --git a/src/BinanceBot.Market/Abstracts/IMarketStrategy.cs b/src/BinanceBot.Market/Abstracts/IMarketStrategy.cs index bada149..1adfd2e 100644 --- a/src/BinanceBot.Market/Abstracts/IMarketStrategy.cs +++ b/src/BinanceBot.Market/Abstracts/IMarketStrategy.cs @@ -1,11 +1,10 @@ -namespace BinanceBot.Market -{ +namespace BinanceBot.Market; - /// - /// MarketStrategy interface - /// - /// - /// As simple as possible now - /// - public interface IMarketStrategy { } -} \ No newline at end of file + +/// +/// MarketStrategy interface +/// +/// +/// As simple as possible now +/// +public interface IMarketStrategy { } \ No newline at end of file diff --git a/src/BinanceBot.Market/Configurations/MarketStrategyConfiguration.cs b/src/BinanceBot.Market/Configurations/MarketStrategyConfiguration.cs index 3e83bf1..e1c69e2 100644 --- a/src/BinanceBot.Market/Configurations/MarketStrategyConfiguration.cs +++ b/src/BinanceBot.Market/Configurations/MarketStrategyConfiguration.cs @@ -1,65 +1,64 @@ using System; -namespace BinanceBot.Market.Configurations +namespace BinanceBot.Market.Configurations; + +/// +/// Market Strategy configuration +/// +/// +/// limits must not contradict Stock limits. +/// Binance limits: +/// +public record MarketStrategyConfiguration { /// - /// Market Strategy configuration + /// Start trading when spread greater than that values (in percentage point) /// - /// - /// limits must not contradict Stock limits. - /// Binance limits: - /// - public record MarketStrategyConfiguration - { - /// - /// Start trading when spread greater than that values (in percentage point) - /// - public decimal TradeWhenSpreadGreaterThan { get; set; } + public decimal TradeWhenSpreadGreaterThan { get; set; } - #region Order limits - /// - /// Minimal order volume - /// - public decimal MinOrderVolume { get; set; } - - /// - /// Maximum order volume - /// - public decimal MaxOrderVolume { get; set; } + #region Order limits + /// + /// Minimal order volume + /// + public decimal MinOrderVolume { get; set; } + + /// + /// Maximum order volume + /// + public decimal MaxOrderVolume { get; set; } - /// - /// Precision of the base asset - /// - public int BaseAssetPrecision { get; set; } + /// + /// Precision of the base asset + /// + public int BaseAssetPrecision { get; set; } - /// - /// Price precision - /// - public int PricePrecision { get; set; } - #endregion + /// + /// Price precision + /// + public int PricePrecision { get; set; } + #endregion - #region Day limits (not usage now, but usefull in future) - /// - /// Minimal order volume - /// - public decimal MinVolumePerDay { get; set; } + #region Day limits (not usage now, but usefull in future) + /// + /// Minimal order volume + /// + public decimal MinVolumePerDay { get; set; } - /// - /// Maximum order volume - /// - public decimal MaxVolumePerDay { get; set; } - #endregion + /// + /// Maximum order volume + /// + public decimal MaxVolumePerDay { get; set; } + #endregion - #region Behaviour settings (not usage now, but usefull in future) - public bool CancelOrdersWhenStopping { get; set; } = true; + #region Behaviour settings (not usage now, but usefull in future) + public bool CancelOrdersWhenStopping { get; set; } = true; - public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5); + public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5); - public (TimeSpan From, TimeSpan To) WorkingTime { get; set; } - #endregion - } + public (TimeSpan From, TimeSpan To) WorkingTime { get; set; } + #endregion } \ No newline at end of file diff --git a/src/BinanceBot.Market/Core/MarketDepth.cs b/src/BinanceBot.Market/Core/MarketDepth.cs index 18f213b..4eac2f3 100644 --- a/src/BinanceBot.Market/Core/MarketDepth.cs +++ b/src/BinanceBot.Market/Core/MarketDepth.cs @@ -5,152 +5,151 @@ using Binance.Net.Objects.Models; using BinanceBot.Market.Utility; -namespace BinanceBot.Market.Core +namespace BinanceBot.Market.Core; + +/// +/// Order book +/// +public class MarketDepth : IMarketDepthPublisher { + public MarketDepth(string symbol) + { + if (string.IsNullOrEmpty(symbol)) + throw new ArgumentException("Invalid symbol value", nameof(symbol)); + + Symbol = symbol; + } + + + public string Symbol { get; } + + + #region Ask section + private readonly IDictionary _asks = new SortedDictionary(); + + /// + /// Get prices that a seller is willing to receive a symbol. + /// Asks sorted by ascending price. The first (best) ask will be the minimum price. + /// + public IEnumerable Asks => _asks.ToQuotes(OrderSide.Sell); + + /// + /// The best ask. If the order book does not contain asks, will be returned . + /// + public Quote BestAsk => Asks.FirstOrDefault(); + #endregion + + + #region Bid section + private readonly IDictionary _bids = new SortedDictionary(new DescendingDecimalComparer()); + + /// + /// Get prices that a buyer is willing to pay for a symbol. + /// Bids sorted by descending price. The first (best) bid will be the maximum price. + /// + public IEnumerable Bids => _bids.ToQuotes(OrderSide.Buy); + + /// + /// The best bid. If the order book does not contain bids, will be returned . + /// + public Quote BestBid => Bids.FirstOrDefault(); + #endregion + + + /// + /// The best pair. If the order book is empty, will be returned . + /// + public MarketDepthPair BestPair => LastUpdateTime.HasValue + ? new MarketDepthPair(BestAsk, BestBid, LastUpdateTime.Value) + : null; + + /// + /// Last update of market depth + /// + public long? LastUpdateTime { get; private set; } + + + + #region Update depth section + private const decimal IgnoreVolumeValue = 1e-11M; + /// - /// Order book + /// Update market depth /// - public class MarketDepth : IMarketDepthPublisher + /// + /// How to manage a local order book correctly [1]: + /// 1. Open a stream to wss://stream.binance.com:9443/ws/bnbbtc@depth + /// 2. Buffer the events you receive from the stream + /// 3. Get a depth snapshot from https://www.binance.com/api/v1/depth?symbol=BNBBTC&limit=1000 + /// -> 4. Drop any event where u is less or equal lastUpdateId in the snapshot + /// 5. The first processed should have U less or equal lastUpdateId+1 AND u equal or greater lastUpdateId+1 + /// -> 6. While listening to the stream, each new event's U should be equal to the previous event's u+1 + /// -> 7. The data in each event is the absolute quantity for a price level + /// -> 8. If the quantity is 0, remove the price level + /// 9. Receiving an event that removes a price level that is not in your local order book can happen and is normal. + /// Reference: + /// 1. https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#how-to-manage-a-local-order-book-correctly + /// + public void UpdateDepth(IEnumerable asks, IEnumerable bids, long updateTime) { - public MarketDepth(string symbol) - { - if (string.IsNullOrEmpty(symbol)) - throw new ArgumentException("Invalid symbol value", nameof(symbol)); + if (updateTime <= 0) + throw new ArgumentOutOfRangeException(nameof(updateTime)); - Symbol = symbol; - } + // if nothing was changed then return + if (updateTime <= LastUpdateTime) return; + if (asks == null && bids == null) return; - public string Symbol { get; } - - - #region Ask section - private readonly IDictionary _asks = new SortedDictionary(); - - /// - /// Get prices that a seller is willing to receive a symbol. - /// Asks sorted by ascending price. The first (best) ask will be the minimum price. - /// - public IEnumerable Asks => _asks.ToQuotes(OrderSide.Sell); - - /// - /// The best ask. If the order book does not contain asks, will be returned . - /// - public Quote BestAsk => Asks.FirstOrDefault(); - #endregion - - - #region Bid section - private readonly IDictionary _bids = new SortedDictionary(new DescendingDecimalComparer()); - - /// - /// Get prices that a buyer is willing to pay for a symbol. - /// Bids sorted by descending price. The first (best) bid will be the maximum price. - /// - public IEnumerable Bids => _bids.ToQuotes(OrderSide.Buy); - - /// - /// The best bid. If the order book does not contain bids, will be returned . - /// - public Quote BestBid => Bids.FirstOrDefault(); - #endregion - - - /// - /// The best pair. If the order book is empty, will be returned . - /// - public MarketDepthPair BestPair => LastUpdateTime.HasValue - ? new MarketDepthPair(BestAsk, BestBid, LastUpdateTime.Value) - : null; - - /// - /// Last update of market depth - /// - public long? LastUpdateTime { get; private set; } - - - - #region Update depth section - private const decimal IgnoreVolumeValue = 1e-11M; - - /// - /// Update market depth - /// - /// - /// How to manage a local order book correctly [1]: - /// 1. Open a stream to wss://stream.binance.com:9443/ws/bnbbtc@depth - /// 2. Buffer the events you receive from the stream - /// 3. Get a depth snapshot from https://www.binance.com/api/v1/depth?symbol=BNBBTC&limit=1000 - /// -> 4. Drop any event where u is less or equal lastUpdateId in the snapshot - /// 5. The first processed should have U less or equal lastUpdateId+1 AND u equal or greater lastUpdateId+1 - /// -> 6. While listening to the stream, each new event's U should be equal to the previous event's u+1 - /// -> 7. The data in each event is the absolute quantity for a price level - /// -> 8. If the quantity is 0, remove the price level - /// 9. Receiving an event that removes a price level that is not in your local order book can happen and is normal. - /// Reference: - /// 1. https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#how-to-manage-a-local-order-book-correctly - /// - public void UpdateDepth(IEnumerable asks, IEnumerable bids, long updateTime) + static void UpdateOrderBook(IEnumerable updates, IDictionary orders) { - if (updateTime <= 0) - throw new ArgumentOutOfRangeException(nameof(updateTime)); + if (orders == null) throw new ArgumentNullException(nameof(orders)); + if (updates == null) return; - // if nothing was changed then return - if (updateTime <= LastUpdateTime) return; - if (asks == null && bids == null) return; + // WARN: clean orders in cases when connector received orderbook snapshots instead of orderbook updates + // orders.Clear(); - - static void UpdateOrderBook(IEnumerable updates, IDictionary orders) + // update order book + foreach (BinanceOrderBookEntry t in updates) { - if (orders == null) throw new ArgumentNullException(nameof(orders)); - if (updates == null) return; - - // WARN: clean orders in cases when connector received orderbook snapshots instead of orderbook updates - // orders.Clear(); - - // update order book - foreach (BinanceOrderBookEntry t in updates) - { - if (t.Quantity > IgnoreVolumeValue) - orders[t.Price] = t.Quantity; - else - if (orders.ContainsKey(t.Price)) orders.Remove(t.Price); - } + if (t.Quantity > IgnoreVolumeValue) + orders[t.Price] = t.Quantity; + else + if (orders.ContainsKey(t.Price)) orders.Remove(t.Price); } - - // save prev BestPair to OnMarketBestPairChanged raise event - MarketDepthPair prevBestPair = BestPair; - // update asks market depth - UpdateOrderBook(asks, _asks); - UpdateOrderBook(bids, _bids); - // set new update time - LastUpdateTime = updateTime; - - // raise events - OnMarketDepthChanged(new MarketDepthChangedEventArgs(Asks, Bids, LastUpdateTime.Value)); - if(!BestPair.Equals(prevBestPair)) - OnMarketBestPairChanged(new MarketBestPairChangedEventArgs(BestPair)); } - #endregion + // save prev BestPair to OnMarketBestPairChanged raise event + MarketDepthPair prevBestPair = BestPair; + // update asks market depth + UpdateOrderBook(asks, _asks); + UpdateOrderBook(bids, _bids); + // set new update time + LastUpdateTime = updateTime; + + // raise events + OnMarketDepthChanged(new MarketDepthChangedEventArgs(Asks, Bids, LastUpdateTime.Value)); + if(!BestPair.Equals(prevBestPair)) + OnMarketBestPairChanged(new MarketBestPairChangedEventArgs(BestPair)); + } + #endregion - #region Market Depth events - public event EventHandler MarketDepthChanged; - protected virtual void OnMarketDepthChanged(MarketDepthChangedEventArgs e) - { - EventHandler handler = MarketDepthChanged; - handler?.Invoke(this, e); - } + #region Market Depth events + public event EventHandler MarketDepthChanged; + protected virtual void OnMarketDepthChanged(MarketDepthChangedEventArgs e) + { + EventHandler handler = MarketDepthChanged; + handler?.Invoke(this, e); + } - public event EventHandler MarketBestPairChanged; - protected virtual void OnMarketBestPairChanged(MarketBestPairChangedEventArgs e) - { - EventHandler handler = MarketBestPairChanged; - handler?.Invoke(this, e); - } - #endregion + public event EventHandler MarketBestPairChanged; + + protected virtual void OnMarketBestPairChanged(MarketBestPairChangedEventArgs e) + { + EventHandler handler = MarketBestPairChanged; + handler?.Invoke(this, e); } + #endregion } \ No newline at end of file diff --git a/src/BinanceBot.Market/Core/MarketDepthPair.cs b/src/BinanceBot.Market/Core/MarketDepthPair.cs index 7df105e..00456c9 100644 --- a/src/BinanceBot.Market/Core/MarketDepthPair.cs +++ b/src/BinanceBot.Market/Core/MarketDepthPair.cs @@ -1,44 +1,41 @@ using System; -namespace BinanceBot.Market.Core +namespace BinanceBot.Market.Core; + +/// +/// Order book's ask-bid pair +/// +public record MarketDepthPair { - /// - /// Order book's ask-bid pair - /// - public record MarketDepthPair + public MarketDepthPair(Quote ask, Quote bid, long updateTime) { - public MarketDepthPair(Quote ask, Quote bid, long updateTime) - { - if (ask == null) - throw new ArgumentNullException(nameof(ask)); - if (bid == null) - throw new ArgumentNullException(nameof(bid)); - if (ask.Price < bid.Price) - throw new ArgumentNullException(nameof(bid), "Best sell price (ask) cannot be less the best buy price (bid)"); - if (updateTime <= 0) - throw new ArgumentOutOfRangeException(nameof(updateTime)); - - Ask = ask; - Bid = bid; - UpdateTime = updateTime; - } + if (ask == null) + throw new ArgumentNullException(nameof(ask)); + if (bid == null) + throw new ArgumentNullException(nameof(bid)); + if (updateTime <= 0) + throw new ArgumentOutOfRangeException(nameof(updateTime)); + + Ask = ask; + Bid = bid; + UpdateTime = updateTime; + } - public Quote Ask { get; } + public Quote Ask { get; } - public Quote Bid { get; } + public Quote Bid { get; } - public long UpdateTime { get; } + public long UpdateTime { get; } - /// - /// Flag that has orders on 2 sides - /// - public bool IsFull => Ask != null && Bid != null; + /// + /// Flag that has orders on 2 sides + /// + public bool IsFull => Ask != null && Bid != null; - public decimal? PriceSpread => IsFull ? Ask.Price - Bid.Price : default; + public decimal? PriceSpread => IsFull ? Ask.Price - Bid.Price : default; - public decimal? VolumeSpread => IsFull ? Math.Abs(Ask.Volume - Bid.Volume) : default; + public decimal? VolumeSpread => IsFull ? Math.Abs(Ask.Volume - Bid.Volume) : default; - public decimal? MediumPrice => IsFull ? (Ask.Price + Bid.Price)/2 : default; - } + public decimal? MediumPrice => IsFull ? (Ask.Price + Bid.Price)/2 : default; } \ No newline at end of file diff --git a/src/BinanceBot.Market/Core/Quote.cs b/src/BinanceBot.Market/Core/Quote.cs index ab915f1..cbe8623 100644 --- a/src/BinanceBot.Market/Core/Quote.cs +++ b/src/BinanceBot.Market/Core/Quote.cs @@ -1,38 +1,37 @@ using System; using Binance.Net.Enums; -namespace BinanceBot.Market.Core +namespace BinanceBot.Market.Core; + +/// +/// quote representing bid or ask +/// +public record Quote { - /// - /// quote representing bid or ask - /// - public record Quote + public Quote(decimal price, decimal volume, OrderSide direction) { - public Quote(decimal price, decimal volume, OrderSide direction) - { - if (price <= 0) throw new ArgumentOutOfRangeException(nameof(price)); - if (volume <= 0) throw new ArgumentOutOfRangeException(nameof(volume)); + if (price <= 0) throw new ArgumentOutOfRangeException(nameof(price)); + if (volume <= 0) throw new ArgumentOutOfRangeException(nameof(volume)); - Price = price; - Volume = volume; - Direction = direction; - } + Price = price; + Volume = volume; + Direction = direction; + } - /// - /// Quote price - /// - public decimal Price { get; } + /// + /// Quote price + /// + public decimal Price { get; } - /// - /// Quote volume - /// - public decimal Volume { get; } + /// + /// Quote volume + /// + public decimal Volume { get; } - /// - /// Direction (buy or sell) - /// - public OrderSide Direction { get; } - } + /// + /// Direction (buy or sell) + /// + public OrderSide Direction { get; } } diff --git a/src/BinanceBot.Market/CreateOrderRequest.cs b/src/BinanceBot.Market/CreateOrderRequest.cs index daf2110..023196e 100644 --- a/src/BinanceBot.Market/CreateOrderRequest.cs +++ b/src/BinanceBot.Market/CreateOrderRequest.cs @@ -1,30 +1,29 @@ using Binance.Net.Enums; -namespace BinanceBot.Market +namespace BinanceBot.Market; + +/// +/// Request object used to create a new Binance order +/// +public class CreateOrderRequest { - /// - /// Request object used to create a new Binance order - /// - public class CreateOrderRequest - { - public string Symbol { get; set; } + public string Symbol { get; set; } - public OrderSide Side { get; set; } + public OrderSide Side { get; set; } - public SpotOrderType OrderType { get; set; } + public SpotOrderType OrderType { get; set; } - public TimeInForce? TimeInForce { get; set; } + public TimeInForce? TimeInForce { get; set; } - public decimal Quantity { get; set; } + public decimal Quantity { get; set; } - public decimal? Price { get; set; } - - public string NewClientOrderId { get; set; } + public decimal? Price { get; set; } + + public string NewClientOrderId { get; set; } - public decimal? StopPrice { get; set; } + public decimal? StopPrice { get; set; } - public decimal? IcebergQuantity { get; set; } + public decimal? IcebergQuantity { get; set; } - public int? RecvWindow { get; set; } - } + public int? RecvWindow { get; set; } } \ No newline at end of file diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 6d9a582..103c11d 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -6,78 +6,77 @@ using CryptoExchange.Net.Objects; -namespace BinanceBot.Market +namespace BinanceBot.Market; + +/// +/// manager +/// +/// +/// How to manage a local order book correctly [1]: +/// 1. Open a stream to wss://stream.binance.com:9443/ws/bnbbtc@depth +/// 2. Buffer the events you receive from the stream +/// -> 3. Get a depth snapshot from https://www.binance.com/api/v1/depth?symbol=BNBBTC&limit=1000 +/// 4. Drop any event where u is less or equal lastUpdateId in the snapshot +/// 5. The first processed should have U less or equal lastUpdateId+1 AND u equal or greater lastUpdateId+1 +/// 6. While listening to the stream, each new event's U should be equal to the previous event's u+1 +/// 7. The data in each event is the absolute quantity for a price level +/// 8. If the quantity is 0, remove the price level +/// 9. Receiving an event that removes a price level that is not in your local order book can happen and is normal. +/// Reference: +/// 1. https://github.com/binance-exchange/binance-official-api-docs/blob/master/web-socket-streams.md#how-to-manage-a-local-order-book-correctly +/// +public class MarketDepthManager { + private readonly IBinanceClient _restClient; + private readonly IBinanceSocketClient _webSocketClient; + + /// - /// manager + /// Create instance of /// - /// - /// How to manage a local order book correctly [1]: - /// 1. Open a stream to wss://stream.binance.com:9443/ws/bnbbtc@depth - /// 2. Buffer the events you receive from the stream - /// -> 3. Get a depth snapshot from https://www.binance.com/api/v1/depth?symbol=BNBBTC&limit=1000 - /// 4. Drop any event where u is less or equal lastUpdateId in the snapshot - /// 5. The first processed should have U less or equal lastUpdateId+1 AND u equal or greater lastUpdateId+1 - /// 6. While listening to the stream, each new event's U should be equal to the previous event's u+1 - /// 7. The data in each event is the absolute quantity for a price level - /// 8. If the quantity is 0, remove the price level - /// 9. Receiving an event that removes a price level that is not in your local order book can happen and is normal. - /// Reference: - /// 1. https://github.com/binance-exchange/binance-official-api-docs/blob/master/web-socket-streams.md#how-to-manage-a-local-order-book-correctly - /// - public class MarketDepthManager + /// Binance REST client + /// Binance WebSocket client + /// cannot be + /// cannot be + public MarketDepthManager(IBinanceClient binanceRestClient, IBinanceSocketClient webSocketClient) { - private readonly IBinanceClient _restClient; - private readonly IBinanceSocketClient _webSocketClient; - - - /// - /// Create instance of - /// - /// Binance REST client - /// Binance WebSocket client - /// cannot be - /// cannot be - public MarketDepthManager(IBinanceClient binanceRestClient, IBinanceSocketClient webSocketClient) - { - _restClient = binanceRestClient ?? throw new ArgumentNullException(nameof(binanceRestClient)); - _webSocketClient = webSocketClient ?? throw new ArgumentNullException(nameof(webSocketClient)); - } + _restClient = binanceRestClient ?? throw new ArgumentNullException(nameof(binanceRestClient)); + _webSocketClient = webSocketClient ?? throw new ArgumentNullException(nameof(webSocketClient)); + } - /// - /// Build - /// - /// Market depth - /// Limit of returned orders count - public async Task BuildAsync(MarketDepth marketDepth, short limit = 10) - { - if (marketDepth == null) - throw new ArgumentNullException(nameof(marketDepth)); - if (limit <= 0) - throw new ArgumentOutOfRangeException(nameof(limit)); + /// + /// Build + /// + /// Market depth + /// Limit of returned orders count + public async Task BuildAsync(MarketDepth marketDepth, short limit = 10) + { + if (marketDepth == null) + throw new ArgumentNullException(nameof(marketDepth)); + if (limit <= 0) + throw new ArgumentOutOfRangeException(nameof(limit)); - WebCallResult response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, limit); - BinanceOrderBook orderBook = response.Data; + WebCallResult response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, limit); + BinanceOrderBook orderBook = response.Data; - marketDepth.UpdateDepth(orderBook.Asks, orderBook.Bids, orderBook.LastUpdateId); - } + marketDepth.UpdateDepth(orderBook.Asks, orderBook.Bids, orderBook.LastUpdateId); + } - /// - /// Stream updates - /// - /// Market depth - /// - public void StreamUpdates(MarketDepth marketDepth, TimeSpan? updateInterval = default) - { - if (marketDepth == null) - throw new ArgumentNullException(nameof(marketDepth)); + /// + /// Stream updates + /// + /// Market depth + /// + public void StreamUpdates(MarketDepth marketDepth, TimeSpan? updateInterval = default) + { + if (marketDepth == null) + throw new ArgumentNullException(nameof(marketDepth)); - _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( - marketDepth.Symbol, - (int)updateInterval?.TotalMilliseconds, - marketData => marketDepth.UpdateDepth(marketData.Data.Asks, marketData.Data.Bids, marketData.Data.LastUpdateId)); - } + _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + marketDepth.Symbol, + (int)updateInterval?.TotalMilliseconds, + marketData => marketDepth.UpdateDepth(marketData.Data.Asks, marketData.Data.Bids, marketData.Data.LastUpdateId)); } } \ No newline at end of file diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index 668f8c5..339c051 100644 --- a/src/BinanceBot.Market/MarketMakerBot.cs +++ b/src/BinanceBot.Market/MarketMakerBot.cs @@ -12,199 +12,198 @@ using NLog; -namespace BinanceBot.Market +namespace BinanceBot.Market; + +/// +/// Market Maker Bot +/// +public class MarketMakerBot : BaseMarketBot { - /// - /// Market Maker Bot - /// - public class MarketMakerBot : BaseMarketBot + private readonly IBinanceClient _binanceRestClient; + private readonly IBinanceSocketClient _webSocketClient; + private readonly MarketDepth _marketDepth; + + + /// + /// + /// + /// + /// + /// cannot be + /// cannot be + /// cannot be + /// cannot be + public MarketMakerBot( + string symbol, + NaiveMarketMakerStrategy marketStrategy, + IBinanceClient binanceRestClient, + IBinanceSocketClient webSocketClient, + Logger logger) : + base(symbol, marketStrategy, logger) { - private readonly IBinanceClient _binanceRestClient; - private readonly IBinanceSocketClient _webSocketClient; - private readonly MarketDepth _marketDepth; - - - /// - /// - /// - /// - /// - /// cannot be - /// cannot be - /// cannot be - /// cannot be - public MarketMakerBot( - string symbol, - NaiveMarketMakerStrategy marketStrategy, - IBinanceClient binanceRestClient, - IBinanceSocketClient webSocketClient, - Logger logger) : - base(symbol, marketStrategy, logger) - { - _marketDepth = new MarketDepth(symbol); - _binanceRestClient = binanceRestClient ?? throw new ArgumentNullException(nameof(binanceRestClient)); - _webSocketClient = webSocketClient ?? throw new ArgumentNullException(nameof(webSocketClient)); - } + _marketDepth = new MarketDepth(symbol); + _binanceRestClient = binanceRestClient ?? throw new ArgumentNullException(nameof(binanceRestClient)); + _webSocketClient = webSocketClient ?? throw new ArgumentNullException(nameof(webSocketClient)); + } - public override async Task ValidateServerTimeAsync() + public override async Task ValidateServerTimeAsync() + { + CallResult testConnectResponse = await _binanceRestClient.SpotApi.ExchangeData.PingAsync().ConfigureAwait(false); + + if (testConnectResponse.Error != null) + Logger.Error(testConnectResponse.Error.Message); + else { - CallResult testConnectResponse = await _binanceRestClient.SpotApi.ExchangeData.PingAsync().ConfigureAwait(false); - - if (testConnectResponse.Error != null) - Logger.Error(testConnectResponse.Error.Message); + string msg = $"Connection was established successfully. Approximate ping time: {testConnectResponse.Data} ms"; + if (testConnectResponse.Data > 1000) + Logger.Warn(msg); else - { - string msg = $"Connection was established successfully. Approximate ping time: {testConnectResponse.Data} ms"; - if (testConnectResponse.Data > 1000) - Logger.Warn(msg); - else - Logger.Info(msg); - } + Logger.Info(msg); } + } - public override async Task> GetOpenedOrdersAsync(string symbol) - { - if (string.IsNullOrEmpty(symbol)) - throw new ArgumentException("Invalid symbol value", nameof(symbol)); + public override async Task> GetOpenedOrdersAsync(string symbol) + { + if (string.IsNullOrEmpty(symbol)) + throw new ArgumentException("Invalid symbol value", nameof(symbol)); - var response = await _binanceRestClient.SpotApi.Trading.GetOpenOrdersAsync(symbol).ConfigureAwait(false); - return response.Data; - } + var response = await _binanceRestClient.SpotApi.Trading.GetOpenOrdersAsync(symbol).ConfigureAwait(false); + return response.Data; + } - public override async Task CancelOrdersAsync(IEnumerable orders) - { - if (orders == null) - throw new ArgumentNullException(nameof(orders)); + public override async Task CancelOrdersAsync(IEnumerable orders) + { + if (orders == null) + throw new ArgumentNullException(nameof(orders)); - foreach (var order in orders) - await _binanceRestClient.SpotApi.Trading.CancelOrderAsync(orderId: order.Id, origClientOrderId: order.ClientOrderId, symbol: order.Symbol).ConfigureAwait(false); - } + foreach (var order in orders) + await _binanceRestClient.SpotApi.Trading.CancelOrderAsync(orderId: order.Id, origClientOrderId: order.ClientOrderId, symbol: order.Symbol).ConfigureAwait(false); + } - public override async Task CreateOrderAsync(CreateOrderRequest order) - { - if (order == null) throw new ArgumentNullException(nameof(order)); + public override async Task CreateOrderAsync(CreateOrderRequest order) + { + if (order == null) throw new ArgumentNullException(nameof(order)); #if TEST_ORDER_CREATION_MODE - WebCallResult response = await _binanceRestClient.SpotApi.Trading.PlaceTestOrderAsync( - // general - order.Symbol, - order.Side, - order.OrderType, - // price-quantity - price: order.Price, - quantity: order.Quantity, - // metadata - newClientOrderId: order.NewClientOrderId, - timeInForce: order.TimeInForce, - receiveWindow: order.RecvWindow) - .ConfigureAwait(false); + WebCallResult response = await _binanceRestClient.SpotApi.Trading.PlaceTestOrderAsync( + // general + order.Symbol, + order.Side, + order.OrderType, + // price-quantity + price: order.Price, + quantity: order.Quantity, + // metadata + newClientOrderId: order.NewClientOrderId, + timeInForce: order.TimeInForce, + receiveWindow: order.RecvWindow) + .ConfigureAwait(false); #else - WebCallResult response = await _binanceRestClient.SpotApi.Trading.PlaceOrderAsync( - // general - order.Symbol, - order.Side, - order.OrderType, - // price-quantity - price: order.Price, - quantity: order.Quantity, - // metadata - newClientOrderId: order.NewClientOrderId, - timeInForce: order.TimeInForce, - receiveWindow: order.RecvWindow) - .ConfigureAwait(false); + WebCallResult response = await _binanceRestClient.SpotApi.Trading.PlaceOrderAsync( + // general + order.Symbol, + order.Side, + order.OrderType, + // price-quantity + price: order.Price, + quantity: order.Quantity, + // metadata + newClientOrderId: order.NewClientOrderId, + timeInForce: order.TimeInForce, + receiveWindow: order.RecvWindow) + .ConfigureAwait(false); #endif - if (response.Error != null) - Logger.Error(response.Error.Message); + if (response.Error != null) + Logger.Error(response.Error.Message); - return response.Data; - } + return response.Data; + } - #region Run bot section - public override async Task RunAsync() - { - // validate connection w/ stock - await ValidateServerTimeAsync(); + #region Run bot section + public override async Task RunAsync() + { + // validate connection w/ stock + await ValidateServerTimeAsync(); - // subscribe on order book updates - _marketDepth.MarketBestPairChanged += async (s, e) => await OnMarketBestPairChanged(s, e); + // subscribe on order book updates + _marketDepth.MarketBestPairChanged += async (s, e) => await OnMarketBestPairChanged(s, e); - var marketDepthManager = new MarketDepthManager(_binanceRestClient, _webSocketClient); + var marketDepthManager = new MarketDepthManager(_binanceRestClient, _webSocketClient); - // stream order book updates - marketDepthManager.StreamUpdates(_marketDepth, TimeSpan.FromMilliseconds(1000)); - // build order book - await marketDepthManager.BuildAsync(_marketDepth, 100); - } + // stream order book updates + marketDepthManager.StreamUpdates(_marketDepth, TimeSpan.FromMilliseconds(1000)); + // build order book + await marketDepthManager.BuildAsync(_marketDepth, 100); + } - private async Task OnMarketBestPairChanged(object sender, MarketBestPairChangedEventArgs e) - { - if (e == null) - throw new ArgumentNullException(nameof(e)); + private async Task OnMarketBestPairChanged(object sender, MarketBestPairChangedEventArgs e) + { + if (e == null) + throw new ArgumentNullException(nameof(e)); - // get current opened orders by token - var openOrdersResponse = await GetOpenedOrdersAsync(Symbol); + // get current opened orders by token + var openOrdersResponse = await GetOpenedOrdersAsync(Symbol); - // cancel already opened orders (if necessary) - if (openOrdersResponse != null) await CancelOrdersAsync(openOrdersResponse); + // cancel already opened orders (if necessary) + if (openOrdersResponse != null) await CancelOrdersAsync(openOrdersResponse); - // find new market position - Quote q = MarketStrategy.Process(e.MarketBestPair); - // if position found then create order - if (q != null) + // find new market position + Quote q = MarketStrategy.Process(e.MarketBestPair); + // if position found then create order + if (q != null) + { + var newOrderRequest = new CreateOrderRequest { - var newOrderRequest = new CreateOrderRequest - { - // general - Symbol = Symbol, - Side = q.Direction, - OrderType = SpotOrderType.Limit, - // price-quantity - Price = Decimal.Round(q.Price, decimals: MarketStrategy.Config.PricePrecision), - Quantity = Decimal.Round(q.Volume, decimals: MarketStrategy.Config.BaseAssetPrecision), - // metadata - NewClientOrderId = $"market-bot-{Guid.NewGuid():N}".Substring(0, 36), - TimeInForce = TimeInForce.GoodTillCanceled, - RecvWindow = (int)MarketStrategy.Config.ReceiveWindow.TotalMilliseconds - }; - - var createOrderResponse = await CreateOrderAsync(newOrderRequest); - if (createOrderResponse != null) - Logger.Warn($"Limit order was created. Price: {createOrderResponse.Price}. Volume: {createOrderResponse.Quantity}"); - } + // general + Symbol = Symbol, + Side = q.Direction, + OrderType = SpotOrderType.Limit, + // price-quantity + Price = Decimal.Round(q.Price, decimals: MarketStrategy.Config.PricePrecision), + Quantity = Decimal.Round(q.Volume, decimals: MarketStrategy.Config.BaseAssetPrecision), + // metadata + NewClientOrderId = $"market-bot-{Guid.NewGuid():N}".Substring(0, 36), + TimeInForce = TimeInForce.GoodTillCanceled, + RecvWindow = (int)MarketStrategy.Config.ReceiveWindow.TotalMilliseconds + }; + + var createOrderResponse = await CreateOrderAsync(newOrderRequest); + if (createOrderResponse != null) + Logger.Warn($"Limit order was created. Price: {createOrderResponse.Price}. Volume: {createOrderResponse.Quantity}"); } - #endregion + } + #endregion - #region Stop/dispose bot section - public override void Stop() - { - Logger.Warn("Bot was stopped"); - Dispose(); - } + #region Stop/dispose bot section + public override void Stop() + { + Logger.Warn("Bot was stopped"); + Dispose(); + } - public override void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + public override void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - protected virtual void Dispose(bool disposing) - { - if (disposing) - _webSocketClient.Dispose(); - } - #endregion + protected virtual void Dispose(bool disposing) + { + if (disposing) + _webSocketClient.Dispose(); } + #endregion } \ No newline at end of file diff --git a/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs b/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs index 7e0925b..be8fdb8 100644 --- a/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs +++ b/src/BinanceBot.Market/Strategies/NaiveMarketMakerStrategy.cs @@ -4,69 +4,68 @@ using BinanceBot.Market.Core; using NLog; -namespace BinanceBot.Market.Strategies -{ - /// - /// Market Maker strategy (naive version) - /// - public class NaiveMarketMakerStrategy : IMarketStrategy - { - private readonly MarketStrategyConfiguration _marketStrategyConfig; - private readonly Logger _logger; - +namespace BinanceBot.Market.Strategies; - public NaiveMarketMakerStrategy(MarketStrategyConfiguration marketStrategyConfig, Logger logger) - { - _marketStrategyConfig = marketStrategyConfig ?? throw new ArgumentNullException(nameof(marketStrategyConfig)); - _logger = logger ?? LogManager.GetCurrentClassLogger(); - } +/// +/// Market Maker strategy (naive version) +/// +public class NaiveMarketMakerStrategy : IMarketStrategy +{ + private readonly MarketStrategyConfiguration _marketStrategyConfig; + private readonly Logger _logger; - public MarketStrategyConfiguration Config => _marketStrategyConfig; + public NaiveMarketMakerStrategy(MarketStrategyConfiguration marketStrategyConfig, Logger logger) + { + _marketStrategyConfig = marketStrategyConfig ?? throw new ArgumentNullException(nameof(marketStrategyConfig)); + _logger = logger ?? LogManager.GetCurrentClassLogger(); + } - /// - /// Process new best - /// - /// Best ask-bid pair - /// Recommended price-volume pair or - public Quote Process(MarketDepthPair marketPair) - { - if (marketPair == null) - throw new ArgumentNullException(nameof(marketPair)); - if (!marketPair.IsFull) - return null; + public MarketStrategyConfiguration Config => _marketStrategyConfig; - Quote quote = null; + /// + /// Process new best + /// + /// Best ask-bid pair + /// Recommended price-volume pair or + public Quote Process(MarketDepthPair marketPair) + { + if (marketPair == null) + throw new ArgumentNullException(nameof(marketPair)); + if (!marketPair.IsFull) + return null; - _logger.Info($"Best ask / bid: {marketPair.Ask.Price} / {marketPair.Bid.Price}. Update Id: {marketPair.UpdateTime}."); - // get price spreads (in percent) - decimal actualSpread = marketPair.PriceSpread!.Value/marketPair.MediumPrice!.Value * 100; // spread_relative = spread_absolute/price * 100 - decimal expectedSpread = _marketStrategyConfig.TradeWhenSpreadGreaterThan; + Quote quote = null; - _logger.Info($"Spread absolute (relative): {marketPair.PriceSpread} ({actualSpread/100:P}). Update Id: {marketPair.UpdateTime}."); + _logger.Info($"Best ask / bid: {marketPair.Ask.Price} / {marketPair.Bid.Price}. Update Id: {marketPair.UpdateTime}."); + // get price spreads (in percent) + decimal actualSpread = marketPair.PriceSpread!.Value/marketPair.MediumPrice!.Value * 100; // spread_relative = spread_absolute/price * 100 + decimal expectedSpread = _marketStrategyConfig.TradeWhenSpreadGreaterThan; - if (actualSpread >= expectedSpread) - { - // compute new order price - decimal extra = marketPair.MediumPrice.Value * (actualSpread - expectedSpread)/100; // extra = medium_price * (spread_actual - spread_expected) - decimal orderPrice = marketPair.Bid.Price + extra; // new_price = best_bid + extra + _logger.Info($"Spread absolute (relative): {marketPair.PriceSpread} ({actualSpread/100:P}). Update Id: {marketPair.UpdateTime}."); - // compute order volume - decimal volumeSpread = marketPair.VolumeSpread!.Value; - decimal orderVolume = volumeSpread > _marketStrategyConfig.MaxOrderVolume ? - _marketStrategyConfig.MaxOrderVolume : // max volume restriction - (volumeSpread < _marketStrategyConfig.MinOrderVolume ? _marketStrategyConfig.MinOrderVolume : volumeSpread); // min volume restriction - // return new price-volume pair - quote = new Quote(orderPrice, orderVolume, OrderSide.Buy); - } + if (actualSpread >= expectedSpread) + { + // compute new order price + decimal extra = marketPair.MediumPrice.Value * (actualSpread - expectedSpread)/100; // extra = medium_price * (spread_actual - spread_expected) + decimal orderPrice = marketPair.Bid.Price + extra; // new_price = best_bid + extra + + // compute order volume + decimal volumeSpread = marketPair.VolumeSpread!.Value; + decimal orderVolume = volumeSpread > _marketStrategyConfig.MaxOrderVolume ? + _marketStrategyConfig.MaxOrderVolume : // max volume restriction + (volumeSpread < _marketStrategyConfig.MinOrderVolume ? _marketStrategyConfig.MinOrderVolume : volumeSpread); // min volume restriction + + // return new price-volume pair + quote = new Quote(orderPrice, orderVolume, OrderSide.Buy); + } - return quote; - } + return quote; } } \ No newline at end of file diff --git a/src/BinanceBot.Market/Utility/DescDecimalComparer.cs b/src/BinanceBot.Market/Utility/DescDecimalComparer.cs index 8c9d650..60e3287 100644 --- a/src/BinanceBot.Market/Utility/DescDecimalComparer.cs +++ b/src/BinanceBot.Market/Utility/DescDecimalComparer.cs @@ -1,13 +1,12 @@ using System.Collections.Generic; -namespace BinanceBot.Market.Utility +namespace BinanceBot.Market.Utility; + +/// +/// Descending decimal comparer +/// +internal class DescendingDecimalComparer : IComparer { - /// - /// Descending decimal comparer - /// - internal class DescendingDecimalComparer : IComparer - { - public int Compare(decimal x, decimal y) => - decimal.Compare(x, y) * -1; - } + public int Compare(decimal x, decimal y) => + decimal.Compare(x, y) * -1; } \ No newline at end of file diff --git a/src/BinanceBot.Market/Utility/QuoteExtensions.cs b/src/BinanceBot.Market/Utility/QuoteExtensions.cs index bf53d4a..12eee45 100644 --- a/src/BinanceBot.Market/Utility/QuoteExtensions.cs +++ b/src/BinanceBot.Market/Utility/QuoteExtensions.cs @@ -3,14 +3,13 @@ using Binance.Net.Enums; using BinanceBot.Market.Core; -namespace BinanceBot.Market.Utility +namespace BinanceBot.Market.Utility; + +internal static class QuoteExtensions { - internal static class QuoteExtensions + public static IEnumerable ToQuotes(this IDictionary source, OrderSide direction) { - public static IEnumerable ToQuotes(this IDictionary source, OrderSide direction) - { - return source? - .Select(s => new Quote(s.Key, s.Value, direction)); - } + return source? + .Select(s => new Quote(s.Key, s.Value, direction)); } } \ No newline at end of file diff --git a/src/BinanceBot.MarketBot.Console/Program.cs b/src/BinanceBot.MarketBot.Console/Program.cs index d45e1d0..2bf43ce 100644 --- a/src/BinanceBot.MarketBot.Console/Program.cs +++ b/src/BinanceBot.MarketBot.Console/Program.cs @@ -13,111 +13,110 @@ using static System.Console; -namespace BinanceBot.MarketBot.Console +namespace BinanceBot.MarketBot.Console; + +internal static class Program { - internal static class Program + #region Bot Settings + // WARN: set necessary token here + private const string Symbol = "SOLUSDT"; + private static readonly TimeSpan ReceiveWindow = TimeSpan.FromMilliseconds(1000); + #endregion + + private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + + + static async Task Main(string[] args) { - #region Bot Settings - // WARN: set necessary token here - private const string Symbol = "SOLUSDT"; - private static readonly TimeSpan ReceiveWindow = TimeSpan.FromMilliseconds(1000); - #endregion + // Load .env file first + DotEnv.Load(); - private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + // WARN: Set your credentials in .env file + var apiKey = Environment.GetEnvironmentVariable("BINANCE_API_KEY") ?? throw new InvalidOperationException("BINANCE_API_KEY environment variable is not set"); + var secret = Environment.GetEnvironmentVariable("BINANCE_SECRET") ?? throw new InvalidOperationException("BINANCE_SECRET environment variable is not set"); + // 1. create connections with exchange + var credentials = new BinanceApiCredentials(apiKey, secret); + using IBinanceClient binanceRestClient = new BinanceClient(new BinanceClientOptions { ApiCredentials = credentials }); + using IBinanceSocketClient binanceSocketClient = new BinanceSocketClient(new BinanceSocketClientOptions { ApiCredentials = credentials }); - static async Task Main(string[] args) + + // 2. test connection + Logger.Info("Testing connection..."); + var pingAsyncResult = await binanceRestClient.SpotApi.ExchangeData.PingAsync(); + Logger.Info($"Ping {pingAsyncResult.Data} ms"); + + + // 2.1. check permissions + var permissionsResponse = await binanceRestClient.SpotApi.Account.GetAPIKeyPermissionsAsync(); + if (!permissionsResponse.Success) { - // Load .env file first - DotEnv.Load(); - - // WARN: Set your credentials in .env file - var apiKey = Environment.GetEnvironmentVariable("BINANCE_API_KEY") ?? throw new InvalidOperationException("BINANCE_API_KEY environment variable is not set"); - var secret = Environment.GetEnvironmentVariable("BINANCE_SECRET") ?? throw new InvalidOperationException("BINANCE_SECRET environment variable is not set"); - - // 1. create connections with exchange - var credentials = new BinanceApiCredentials(apiKey, secret); - using IBinanceClient binanceRestClient = new BinanceClient(new BinanceClientOptions { ApiCredentials = credentials }); - using IBinanceSocketClient binanceSocketClient = new BinanceSocketClient(new BinanceSocketClientOptions { ApiCredentials = credentials }); - - - // 2. test connection - Logger.Info("Testing connection..."); - var pingAsyncResult = await binanceRestClient.SpotApi.ExchangeData.PingAsync(); - Logger.Info($"Ping {pingAsyncResult.Data} ms"); - - - // 2.1. check permissions - var permissionsResponse = await binanceRestClient.SpotApi.Account.GetAPIKeyPermissionsAsync(); - if (!permissionsResponse.Success) - { - Logger.Error($"{permissionsResponse.Error?.Message}"); - ReadLine(); - } - else if (permissionsResponse.Data.IpRestrict | !permissionsResponse.Data.EnableSpotAndMarginTrading) - { - Logger.Error("Insufficient API permissions"); - ReadLine(); - } - - - // 3. set bot strategy config - var exchangeInfoResult = binanceRestClient.SpotApi.ExchangeData.GetExchangeInfoAsync(Symbol); - - var symbolInfo = exchangeInfoResult.Result.Data.Symbols - .Single(s => s.Name.Equals(Symbol, StringComparison.InvariantCultureIgnoreCase)); - - if (!(symbolInfo.Status == SymbolStatus.Trading && symbolInfo.OrderTypes.Contains(SpotOrderType.Market))) - { - Logger.Error($"Symbol {symbolInfo.Name} doesn't suitable for this strategy"); - return; - } - - if (symbolInfo.LotSizeFilter == null) - { - Logger.Error($"Cannot define risks strategy for {symbolInfo.Name}"); - return; - } - - if (symbolInfo.PriceFilter == null) - { - Logger.Error($"Cannot define price precision for {symbolInfo.Name}. Please define it manually."); - return; - } - int pricePrecision = (int)Math.Abs(Math.Log10(Convert.ToDouble(symbolInfo.PriceFilter.TickSize))); - - - // WARN: set thresholds for strategy here - var strategyConfig = new MarketStrategyConfiguration - { - TradeWhenSpreadGreaterThan = .05M, // or 0.05%, (price spread*min_volume) should be greater than broker's commissions for trade - MinOrderVolume = symbolInfo.LotSizeFilter.MinQuantity*10, - MaxOrderVolume = symbolInfo.LotSizeFilter.MinQuantity*100, - BaseAssetPrecision = symbolInfo.BaseAssetPrecision, - PricePrecision = pricePrecision, - ReceiveWindow = ReceiveWindow - }; - - var marketStrategy = new NaiveMarketMakerStrategy(strategyConfig, Logger); - - - // 3. start bot - IMarketBot bot = new MarketMakerBot(Symbol, marketStrategy, binanceRestClient, binanceSocketClient, Logger); - - try - { - await bot.RunAsync(); - - WriteLine($"Press Enter to stop {Symbol} bot..."); - ReadLine(); - } - finally - { - bot.Stop(); - } - - WriteLine("Press Enter to exit..."); + Logger.Error($"{permissionsResponse.Error?.Message}"); ReadLine(); - } - } + } + else if (permissionsResponse.Data.IpRestrict | !permissionsResponse.Data.EnableSpotAndMarginTrading) + { + Logger.Error("Insufficient API permissions"); + ReadLine(); + } + + + // 3. set bot strategy config + var exchangeInfoResult = binanceRestClient.SpotApi.ExchangeData.GetExchangeInfoAsync(Symbol); + + var symbolInfo = exchangeInfoResult.Result.Data.Symbols + .Single(s => s.Name.Equals(Symbol, StringComparison.InvariantCultureIgnoreCase)); + + if (!(symbolInfo.Status == SymbolStatus.Trading && symbolInfo.OrderTypes.Contains(SpotOrderType.Market))) + { + Logger.Error($"Symbol {symbolInfo.Name} doesn't suitable for this strategy"); + return; + } + + if (symbolInfo.LotSizeFilter == null) + { + Logger.Error($"Cannot define risks strategy for {symbolInfo.Name}"); + return; + } + + if (symbolInfo.PriceFilter == null) + { + Logger.Error($"Cannot define price precision for {symbolInfo.Name}. Please define it manually."); + return; + } + int pricePrecision = (int)Math.Abs(Math.Log10(Convert.ToDouble(symbolInfo.PriceFilter.TickSize))); + + + // WARN: set thresholds for strategy here + var strategyConfig = new MarketStrategyConfiguration + { + TradeWhenSpreadGreaterThan = .05M, // or 0.05%, (price spread*min_volume) should be greater than broker's commissions for trade + MinOrderVolume = symbolInfo.LotSizeFilter.MinQuantity*10, + MaxOrderVolume = symbolInfo.LotSizeFilter.MinQuantity*100, + BaseAssetPrecision = symbolInfo.BaseAssetPrecision, + PricePrecision = pricePrecision, + ReceiveWindow = ReceiveWindow + }; + + var marketStrategy = new NaiveMarketMakerStrategy(strategyConfig, Logger); + + + // 3. start bot + IMarketBot bot = new MarketMakerBot(Symbol, marketStrategy, binanceRestClient, binanceSocketClient, Logger); + + try + { + await bot.RunAsync(); + + WriteLine($"Press Enter to stop {Symbol} bot..."); + ReadLine(); + } + finally + { + bot.Stop(); + } + + WriteLine("Press Enter to exit..."); + ReadLine(); + } } diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index 9babd27..ba4f148 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -15,114 +15,113 @@ using static System.Console; -namespace BinanceBot.MarketViewer.Console +namespace BinanceBot.MarketViewer.Console; + +internal static class Program { - internal static class Program - { - #region Bot Settings - // WARN: Set necessary token here - private const string Symbol = "BNBUSDT"; - private const int OrderBookDepth = 10; - private static readonly TimeSpan? OrderBookUpdateLimit = TimeSpan.FromMilliseconds(100); - #endregion + #region Bot Settings + // WARN: Set necessary token here + private const string Symbol = "BNBUSDT"; + private const int OrderBookDepth = 10; + private static readonly TimeSpan? OrderBookUpdateLimit = TimeSpan.FromMilliseconds(100); + #endregion - private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); - static async Task Main(string[] args) - { - // Load .env file first - DotEnv.Load(); + static async Task Main(string[] args) + { + // Load .env file first + DotEnv.Load(); - Logger.Debug($"Symbol: {Symbol}, OrderBookDepth: {OrderBookDepth}"); + Logger.Debug($"Symbol: {Symbol}, OrderBookDepth: {OrderBookDepth}"); - // WARN: Set your credentials in .env file - var apiKey = Environment.GetEnvironmentVariable("BINANCE_API_KEY") ?? throw new InvalidOperationException("BINANCE_API_KEY environment variable is not set"); - var secret = Environment.GetEnvironmentVariable("BINANCE_SECRET") ?? throw new InvalidOperationException("BINANCE_SECRET environment variable is not set"); + // WARN: Set your credentials in .env file + var apiKey = Environment.GetEnvironmentVariable("BINANCE_API_KEY") ?? throw new InvalidOperationException("BINANCE_API_KEY environment variable is not set"); + var secret = Environment.GetEnvironmentVariable("BINANCE_SECRET") ?? throw new InvalidOperationException("BINANCE_SECRET environment variable is not set"); - // 1. create connections with exchange - var credentials = new BinanceApiCredentials(apiKey, secret); - using IBinanceClient binanceRestClient = new BinanceClient(new BinanceClientOptions { ApiCredentials = credentials }); - using IBinanceSocketClient binanceSocketClient = new BinanceSocketClient(new BinanceSocketClientOptions { ApiCredentials = credentials }); + // 1. create connections with exchange + var credentials = new BinanceApiCredentials(apiKey, secret); + using IBinanceClient binanceRestClient = new BinanceClient(new BinanceClientOptions { ApiCredentials = credentials }); + using IBinanceSocketClient binanceSocketClient = new BinanceSocketClient(new BinanceSocketClientOptions { ApiCredentials = credentials }); - // 2. test connection - Logger.Info("Testing connection to Binance..."); - await AnsiConsole.Status() - .StartAsync("Testing connection...", async ctx => - { - var pingResult = await binanceRestClient.SpotApi.ExchangeData.PingAsync(); - AnsiConsole.MarkupLine($"Ping time: [yellow]{pingResult.Data} ms[/]"); - Logger.Info($"Ping successful: {pingResult.Data} ms"); + // 2. test connection + Logger.Info("Testing connection to Binance..."); + await AnsiConsole.Status() + .StartAsync("Testing connection...", async ctx => + { + var pingResult = await binanceRestClient.SpotApi.ExchangeData.PingAsync(); + AnsiConsole.MarkupLine($"Ping time: [yellow]{pingResult.Data} ms[/]"); + Logger.Info($"Ping successful: {pingResult.Data} ms"); - Task.Delay(1000).Wait(); - }); + Task.Delay(1000).Wait(); + }); - // 3. get order book - var marketDepthManager = new MarketDepthManager(binanceRestClient, binanceSocketClient); - var marketDepth = new MarketDepth(Symbol); + // 3. get order book + var marketDepthManager = new MarketDepthManager(binanceRestClient, binanceSocketClient); + var marketDepth = new MarketDepth(Symbol); - // 4. Render order book - var orderBookTable = new Table - { - Title = new TableTitle($"{Symbol} Quotes") - }; + // 4. Render order book + var orderBookTable = new Table + { + Title = new TableTitle($"{Symbol} Quotes") + }; - foreach (var column in new[] { "Asks (volume)", "Price", "Bid (volume)" }) - orderBookTable.AddColumn(column); + foreach (var column in new[] { "Asks (volume)", "Price", "Bid (volume)" }) + orderBookTable.AddColumn(column); - static IEnumerable<(string price, string volume)> GetValues(IEnumerable data) - { - return data - .OrderByDescending(q => q.Price) - .Select(q => (price: q.Price.ToString(CultureInfo.InvariantCulture), volume: q.Volume.ToString(CultureInfo.InvariantCulture)) ); - } + static IEnumerable<(string price, string volume)> GetValues(IEnumerable data) + { + return data + .OrderByDescending(q => q.Price) + .Select(q => (price: q.Price.ToString(CultureInfo.InvariantCulture), volume: q.Volume.ToString(CultureInfo.InvariantCulture)) ); + } - marketDepth.MarketDepthChanged += (sender, e) => - { - var asks = e.Asks.OrderBy(q => q.Price).Take(OrderBookDepth).ToImmutableArray(); - var bids = e.Bids.OrderByDescending(q => q.Price).Take(OrderBookDepth).ToImmutableArray(); + marketDepth.MarketDepthChanged += (sender, e) => + { + 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(); + orderBookTable.Rows.Clear(); - foreach (var row in GetValues(asks)) - orderBookTable.AddRow(row.volume, $"[red]{row.price}[/]", String.Empty); + foreach (var row in GetValues(asks)) + orderBookTable.AddRow(row.volume, $"[red]{row.price}[/]", String.Empty); - foreach (var row in GetValues(bids)) - orderBookTable.AddRow(String.Empty, $"[green]{row.price}[/]", row.volume); + foreach (var row in GetValues(bids)) + 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" - ); + 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" + ); - var dominanceChart = new BreakdownChart() - .ShowTagValues() - .AddItem("Asks", (double) asks.Select(q => q.Volume).Sum(), Color.Red) - .AddItem("Bids", (double) bids.Select(q => q.Volume).Sum(), Color.Green); + var dominanceChart = new BreakdownChart() + .ShowTagValues() + .AddItem("Asks", (double) asks.Select(q => q.Volume).Sum(), Color.Red) + .AddItem("Bids", (double) bids.Select(q => q.Volume).Sum(), Color.Green); - AnsiConsole.Clear(); + AnsiConsole.Clear(); - AnsiConsole.Write(orderBookTable); - AnsiConsole.Write(dominanceChart); - }; + AnsiConsole.Write(orderBookTable); + AnsiConsole.Write(dominanceChart); + }; - // build order book - Logger.Info($"Building order book for {Symbol}..."); - await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth); - // stream order book updates - Logger.Info("Streaming order book updates..."); - marketDepthManager.StreamUpdates(marketDepth, OrderBookUpdateLimit); + // build order book + Logger.Info($"Building order book for {Symbol}..."); + await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth); + // stream order book updates + Logger.Info("Streaming order book updates..."); + marketDepthManager.StreamUpdates(marketDepth, OrderBookUpdateLimit); - WriteLine("Press Enter to exit..."); - ReadLine(); - - Logger.Info("Order book viewer stopped"); - } + WriteLine("Press Enter to exit..."); + ReadLine(); + + Logger.Info("Order book viewer stopped"); } } From 3e1585ed074a0ff61301d34553937ce9042a838f Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 17:43:05 +0100 Subject: [PATCH 04/14] Enhance order book management: more robust OB build logic --- src/BinanceBot.Market/MarketDepthManager.cs | 208 +++++++++++++++--- .../Program.cs | 9 +- 2 files changed, 187 insertions(+), 30 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 103c11d..3674316 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -1,34 +1,38 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Binance.Net.Interfaces; using Binance.Net.Interfaces.Clients; using Binance.Net.Objects.Models.Spot; using BinanceBot.Market.Core; using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets; +using NLog; namespace BinanceBot.Market; /// -/// manager +/// Manager. +/// +/// Manages local order book synchronization with Binance WebSocket streams following official guidelines. +/// Implements the 7-step algorithm: (1) Open WebSocket, (2) Buffer events, (3) Get snapshot, +/// (4) Validate snapshot, (5) Discard old events, (6) Apply snapshot, (7) Apply buffered and live updates. +/// +/// See full instructions at https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#how-to-manage-a-local-order-book-correctly /// -/// -/// How to manage a local order book correctly [1]: -/// 1. Open a stream to wss://stream.binance.com:9443/ws/bnbbtc@depth -/// 2. Buffer the events you receive from the stream -/// -> 3. Get a depth snapshot from https://www.binance.com/api/v1/depth?symbol=BNBBTC&limit=1000 -/// 4. Drop any event where u is less or equal lastUpdateId in the snapshot -/// 5. The first processed should have U less or equal lastUpdateId+1 AND u equal or greater lastUpdateId+1 -/// 6. While listening to the stream, each new event's U should be equal to the previous event's u+1 -/// 7. The data in each event is the absolute quantity for a price level -/// 8. If the quantity is 0, remove the price level -/// 9. Receiving an event that removes a price level that is not in your local order book can happen and is normal. -/// Reference: -/// 1. https://github.com/binance-exchange/binance-official-api-docs/blob/master/web-socket-streams.md#how-to-manage-a-local-order-book-correctly -/// public class MarketDepthManager -{ +{ private readonly IBinanceClient _restClient; private readonly IBinanceSocketClient _webSocketClient; + private readonly Logger _logger; + + private readonly Queue _eventBuffer = new(); + private long _localOrderBookUpdateId = 0; + private bool _isSnapshotLoaded = false; + + private UpdateSubscription _subscription; /// @@ -36,31 +40,119 @@ public class MarketDepthManager /// /// Binance REST client /// Binance WebSocket client + /// Logger instance /// cannot be /// cannot be - public MarketDepthManager(IBinanceClient binanceRestClient, IBinanceSocketClient webSocketClient) + /// cannot be + public MarketDepthManager(IBinanceClient binanceRestClient, IBinanceSocketClient webSocketClient, Logger logger) { _restClient = binanceRestClient ?? throw new ArgumentNullException(nameof(binanceRestClient)); _webSocketClient = webSocketClient ?? throw new ArgumentNullException(nameof(webSocketClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - /// Build + /// Build following Binance official guidelines /// /// Market depth /// Limit of returned orders count - public async Task BuildAsync(MarketDepth marketDepth, short limit = 10) + /// Update speed limit (100ms, 1000ms) + public async Task BuildAsync(MarketDepth marketDepth, short limit = 10, int updateLimit = 1000) { if (marketDepth == null) throw new ArgumentNullException(nameof(marketDepth)); if (limit <= 0) throw new ArgumentOutOfRangeException(nameof(limit)); + // Step 1: Open WebSocket stream and start buffering + _logger.Debug($"Step 1: Opening WebSocket stream for {marketDepth.Symbol}"); + var subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + marketDepth.Symbol, updateLimit, + data => OnDepthUpdate(marketDepth, data)).ConfigureAwait(false); + + if (!subscriptionResult.Success || subscriptionResult.Data == null) + throw new InvalidOperationException($"Failed to subscribe to order book updates: {subscriptionResult.Error?.Message}"); + + _subscription = subscriptionResult.Data; + + // Step 2: Wait a bit to buffer some events + _logger.Debug($"Step 2: Buffering events for 200ms"); + await Task.Delay(200).ConfigureAwait(false); + + _logger.Debug($"Step 3: Getting order book snapshot for {marketDepth.Symbol}"); + // Step 3: Get depth snapshot WebCallResult response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, limit); - BinanceOrderBook orderBook = response.Data; + if (!response.Success || response.Data == null) + throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); + + BinanceOrderBook 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; + lock (_eventBuffer) + { + if (_eventBuffer.Count > 0) + firstEvent = _eventBuffer.Peek() as BinanceEventOrderBook; + } + + if (firstEvent != null) + { + _logger.Debug($"Step 4: Validating snapshot. FirstEvent.U={firstEvent.FirstUpdateId}, Snapshot.LastUpdateId={snapshot.LastUpdateId}"); + } + + 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, limit); + if (!response.Success || response.Data == null) + throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}"); + snapshot = response.Data; + _logger.Debug($"New snapshot received: LastUpdateId={snapshot.LastUpdateId}"); + + lock (_eventBuffer) + { + if (_eventBuffer.Count > 0) + firstEvent = _eventBuffer.Peek() as BinanceEventOrderBook; + else + firstEvent = null; + } + } + + lock (_eventBuffer) + { + // Step 5: Discard buffered events where u <= lastUpdateId + int discardedCount = 0; + while (_eventBuffer.Count > 0 && _eventBuffer.Peek().LastUpdateId <= snapshot.LastUpdateId) + { + _eventBuffer.Dequeue(); + discardedCount++; + } + _logger.Debug($"Step 5: Discarded {discardedCount} outdated events (u <= {snapshot.LastUpdateId})"); - marketDepth.UpdateDepth(orderBook.Asks, orderBook.Bids, orderBook.LastUpdateId); + // Step 6: Set local order book to snapshot + _logger.Debug($"Step 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; + + // Step 7: Apply buffered updates + int appliedCount = 0; + while (_eventBuffer.Count > 0) + { + var bufferedEvent = _eventBuffer.Peek() as BinanceEventOrderBook; + if (bufferedEvent != null) + { + ApplyDepthUpdate(marketDepth, bufferedEvent); + appliedCount++; + } + _eventBuffer.Dequeue(); + } + _logger.Debug($"Step 7: Applied {appliedCount} buffered events"); + } } @@ -68,15 +160,81 @@ public async Task BuildAsync(MarketDepth marketDepth, short limit = 10) /// Stream updates /// /// Market depth - /// + /// Update interval (100ms or 1000ms) public void StreamUpdates(MarketDepth marketDepth, TimeSpan? updateInterval = default) { if (marketDepth == null) throw new ArgumentNullException(nameof(marketDepth)); - _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + // Step 1 & 2: Open WebSocket and buffer events + _subscription = _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( marketDepth.Symbol, - (int)updateInterval?.TotalMilliseconds, - marketData => marketDepth.UpdateDepth(marketData.Data.Asks, marketData.Data.Bids, marketData.Data.LastUpdateId)); + updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : 1000, + data => OnDepthUpdate(marketDepth, data)).Result.Data; + } + + /// + /// Stop streaming updates and unsubscribe + /// + public async Task StopStreamingAsync() + { + if (_subscription != null) + { + await _subscription.CloseAsync(); + _subscription = null; + } + } + + private void OnDepthUpdate(MarketDepth marketDepth, DataEvent dataEvent) + { + var data = dataEvent.Data as BinanceEventOrderBook; + if (data == null) return; + + lock (_eventBuffer) + { + if (!_isSnapshotLoaded) + { + // 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}"); + return; + } + + // Apply update to local order book + ApplyDepthUpdate(marketDepth, data); + } + } + + private void ApplyDepthUpdate(MarketDepth marketDepth, BinanceEventOrderBook eventData) + { + // Step 7: Apply update procedure + + // 1. Decide whether the update event can be applied + if (eventData.LastUpdateId <= _localOrderBookUpdateId) + { + // Event is older than local order book, ignore + _logger.Debug($"Ignoring old event: u={eventData.LastUpdateId} <= local={_localOrderBookUpdateId}"); + return; + } + + if (eventData.FirstUpdateId > _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={eventData.LastUpdateId}, Asks={eventData.Asks.Count()}, Bids={eventData.Bids.Count()}"); + marketDepth.UpdateDepth(eventData.Asks, eventData.Bids, eventData.LastUpdateId); + + // 3. Set order book update ID + _localOrderBookUpdateId = eventData.LastUpdateId; } } \ No newline at end of file diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index ba4f148..f2676e4 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -59,7 +59,7 @@ await AnsiConsole.Status() }); // 3. get order book - var marketDepthManager = new MarketDepthManager(binanceRestClient, binanceSocketClient); + var marketDepthManager = new MarketDepthManager(binanceRestClient, binanceSocketClient, Logger); var marketDepth = new MarketDepth(Symbol); @@ -113,10 +113,9 @@ await AnsiConsole.Status() // build order book Logger.Info($"Building order book for {Symbol}..."); - await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth); - // stream order book updates - Logger.Info("Streaming order book updates..."); - marketDepthManager.StreamUpdates(marketDepth, OrderBookUpdateLimit); + await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth, + OrderBookUpdateLimit.HasValue ? (int)OrderBookUpdateLimit.Value.TotalMilliseconds : 1000); + Logger.Info("Order book ready and streaming updates..."); WriteLine("Press Enter to exit..."); From f9d0e14b17f8cc3463ec423e47cd388a191cb25d Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 17:43:36 +0100 Subject: [PATCH 05/14] Minor fixes --- src/BinanceBot.Market/MarketMakerBot.cs | 2 +- src/BinanceBot.MarketBot.Console/Program.cs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index 339c051..84865b8 100644 --- a/src/BinanceBot.Market/MarketMakerBot.cs +++ b/src/BinanceBot.Market/MarketMakerBot.cs @@ -137,7 +137,7 @@ public override async Task RunAsync() _marketDepth.MarketBestPairChanged += async (s, e) => await OnMarketBestPairChanged(s, e); - var marketDepthManager = new MarketDepthManager(_binanceRestClient, _webSocketClient); + var marketDepthManager = new MarketDepthManager(_binanceRestClient, _webSocketClient, Logger); // stream order book updates marketDepthManager.StreamUpdates(_marketDepth, TimeSpan.FromMilliseconds(1000)); diff --git a/src/BinanceBot.MarketBot.Console/Program.cs b/src/BinanceBot.MarketBot.Console/Program.cs index 2bf43ce..56fec7a 100644 --- a/src/BinanceBot.MarketBot.Console/Program.cs +++ b/src/BinanceBot.MarketBot.Console/Program.cs @@ -19,8 +19,8 @@ internal static class Program { #region Bot Settings // WARN: set necessary token here - private const string Symbol = "SOLUSDT"; - private static readonly TimeSpan ReceiveWindow = TimeSpan.FromMilliseconds(1000); + private const string Symbol = "BNBUSDT"; + private static readonly TimeSpan ReceiveWindow = TimeSpan.FromMilliseconds(100); #endregion private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); @@ -54,7 +54,8 @@ static async Task Main(string[] args) Logger.Error($"{permissionsResponse.Error?.Message}"); ReadLine(); } - else if (permissionsResponse.Data.IpRestrict | !permissionsResponse.Data.EnableSpotAndMarginTrading) + + if (!(permissionsResponse.Data.IpRestrict && permissionsResponse.Data.EnableSpotAndMarginTrading)) { Logger.Error("Insufficient API permissions"); ReadLine(); From ff166116f5e7894d577f23e3316465244eafd05c Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 18:16:59 +0100 Subject: [PATCH 06/14] Comprehensive naming and Cancellation token management --- src/BinanceBot.Market/MarketDepthManager.cs | 67 ++++++++++++------- src/BinanceBot.Market/MarketMakerBot.cs | 6 +- .../Program.cs | 16 ++--- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 3674316..4f414cc 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Binance.Net.Interfaces; using Binance.Net.Interfaces.Clients; @@ -31,6 +32,8 @@ public class MarketDepthManager private readonly Queue _eventBuffer = new(); private long _localOrderBookUpdateId = 0; private bool _isSnapshotLoaded = false; + + private readonly TimeSpan _defaultUpdateInterval = TimeSpan.FromMilliseconds(100); private UpdateSubscription _subscription; @@ -38,15 +41,15 @@ public class MarketDepthManager /// /// Create instance of /// - /// Binance REST client + /// Binance REST client /// Binance WebSocket client /// Logger instance - /// cannot be + /// cannot be /// cannot be /// cannot be - public MarketDepthManager(IBinanceClient binanceRestClient, IBinanceSocketClient webSocketClient, Logger logger) + public MarketDepthManager(IBinanceClient restClient, IBinanceSocketClient webSocketClient, Logger logger) { - _restClient = binanceRestClient ?? throw new ArgumentNullException(nameof(binanceRestClient)); + _restClient = restClient ?? throw new ArgumentNullException(nameof(restClient)); _webSocketClient = webSocketClient ?? throw new ArgumentNullException(nameof(webSocketClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -56,20 +59,32 @@ public MarketDepthManager(IBinanceClient binanceRestClient, IBinanceSocketClient /// Build following Binance official guidelines /// /// Market depth - /// Limit of returned orders count - /// Update speed limit (100ms, 1000ms) - public async Task BuildAsync(MarketDepth marketDepth, short limit = 10, int updateLimit = 1000) + /// Limit of returned orders count (default 10) + /// Update speed limit (100ms, 1000ms) + /// Cancellation token + /// cannot be + /// must be greater than zero + /// must be greater than zero + /// The operation was canceled. + /// Failed to subscribe to order book updates or get order book snapshot + public async Task BuildAsync(MarketDepth marketDepth, TimeSpan? updateInterval = default, short orderBookDepth = 10, CancellationToken ct = default) { if (marketDepth == null) throw new ArgumentNullException(nameof(marketDepth)); - if (limit <= 0) - throw new ArgumentOutOfRangeException(nameof(limit)); + if (updateInterval.HasValue && updateInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(updateInterval)); + if (orderBookDepth <= 0) + throw new ArgumentOutOfRangeException(nameof(orderBookDepth)); // Step 1: Open WebSocket stream and start buffering - _logger.Debug($"Step 1: Opening WebSocket stream for {marketDepth.Symbol}"); + _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, updateLimit, - data => OnDepthUpdate(marketDepth, data)).ConfigureAwait(false); + 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}"); @@ -77,12 +92,12 @@ public async Task BuildAsync(MarketDepth marketDepth, short limit = 10, int upda _subscription = subscriptionResult.Data; // Step 2: Wait a bit to buffer some events - _logger.Debug($"Step 2: Buffering events for 200ms"); - await Task.Delay(200).ConfigureAwait(false); + _logger.Debug($"2: Buffering events for {updateIntervalMs * 2}ms"); + await Task.Delay(updateIntervalMs * 2, ct).ConfigureAwait(false); - _logger.Debug($"Step 3: Getting order book snapshot for {marketDepth.Symbol}"); + _logger.Debug($"3: Getting order book snapshot for {marketDepth.Symbol}"); // Step 3: Get depth snapshot - WebCallResult response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, limit); + 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}"); @@ -100,14 +115,14 @@ public async Task BuildAsync(MarketDepth marketDepth, short limit = 10, int upda if (firstEvent != null) { - _logger.Debug($"Step 4: Validating snapshot. FirstEvent.U={firstEvent.FirstUpdateId}, Snapshot.LastUpdateId={snapshot.LastUpdateId}"); + _logger.Debug($"4: Validating snapshot. FirstEvent.U={firstEvent.FirstUpdateId}, Snapshot.LastUpdateId={snapshot.LastUpdateId}"); } 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, limit); + 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; @@ -131,10 +146,10 @@ public async Task BuildAsync(MarketDepth marketDepth, short limit = 10, int upda _eventBuffer.Dequeue(); discardedCount++; } - _logger.Debug($"Step 5: Discarded {discardedCount} outdated events (u <= {snapshot.LastUpdateId})"); + _logger.Debug($"5: Discarded {discardedCount} outdated events (u <= {snapshot.LastUpdateId})"); // Step 6: Set local order book to snapshot - _logger.Debug($"Step 6: Applying snapshot with {snapshot.Asks.Count()} asks and {snapshot.Bids.Count()} bids"); + _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; @@ -151,7 +166,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short limit = 10, int upda } _eventBuffer.Dequeue(); } - _logger.Debug($"Step 7: Applied {appliedCount} buffered events"); + _logger.Debug($"7: Applied {appliedCount} buffered events"); } } @@ -161,7 +176,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short limit = 10, int upda /// /// Market depth /// Update interval (100ms or 1000ms) - public void StreamUpdates(MarketDepth marketDepth, TimeSpan? updateInterval = default) + public void StreamUpdates(MarketDepth marketDepth, TimeSpan? updateInterval = default, CancellationToken ct = default) { if (marketDepth == null) throw new ArgumentNullException(nameof(marketDepth)); @@ -169,14 +184,16 @@ public void StreamUpdates(MarketDepth marketDepth, TimeSpan? updateInterval = de // Step 1 & 2: Open WebSocket and buffer events _subscription = _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( marketDepth.Symbol, - updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : 1000, - data => OnDepthUpdate(marketDepth, data)).Result.Data; + updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, + data => OnDepthUpdate(marketDepth, data), + ct) + .Result.Data; } /// /// Stop streaming updates and unsubscribe /// - public async Task StopStreamingAsync() + public async Task StopStreamingAsync(CancellationToken ct = default) { if (_subscription != null) { diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index 84865b8..98665fd 100644 --- a/src/BinanceBot.Market/MarketMakerBot.cs +++ b/src/BinanceBot.Market/MarketMakerBot.cs @@ -138,11 +138,11 @@ public override async Task RunAsync() var marketDepthManager = new MarketDepthManager(_binanceRestClient, _webSocketClient, Logger); - + var uppdateInterval = TimeSpan.FromMilliseconds(1000); // stream order book updates - marketDepthManager.StreamUpdates(_marketDepth, TimeSpan.FromMilliseconds(1000)); + marketDepthManager.StreamUpdates(_marketDepth, uppdateInterval); // build order book - await marketDepthManager.BuildAsync(_marketDepth, 100); + await marketDepthManager.BuildAsync(_marketDepth, orderBookDepth: 100, updateInterval: uppdateInterval); } diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index f2676e4..44de417 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -23,7 +23,7 @@ internal static class Program // WARN: Set necessary token here private const string Symbol = "BNBUSDT"; private const int OrderBookDepth = 10; - private static readonly TimeSpan? OrderBookUpdateLimit = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan OrderBookUpdateInterval = TimeSpan.FromMilliseconds(100); #endregion private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); @@ -42,24 +42,25 @@ static async Task Main(string[] args) // 1. create connections with exchange var credentials = new BinanceApiCredentials(apiKey, secret); - using IBinanceClient binanceRestClient = new BinanceClient(new BinanceClientOptions { ApiCredentials = credentials }); - using IBinanceSocketClient binanceSocketClient = new BinanceSocketClient(new BinanceSocketClientOptions { ApiCredentials = credentials }); + using IBinanceClient restClient = new BinanceClient(new BinanceClientOptions { ApiCredentials = credentials }); + using IBinanceSocketClient webSocketClient = new BinanceSocketClient(new BinanceSocketClientOptions { ApiCredentials = credentials }); + var ct = default(System.Threading.CancellationToken); // 2. test connection Logger.Info("Testing connection to Binance..."); await AnsiConsole.Status() .StartAsync("Testing connection...", async ctx => { - var pingResult = await binanceRestClient.SpotApi.ExchangeData.PingAsync(); + var pingResult = await restClient.SpotApi.ExchangeData.PingAsync(ct); AnsiConsole.MarkupLine($"Ping time: [yellow]{pingResult.Data} ms[/]"); Logger.Info($"Ping successful: {pingResult.Data} ms"); - Task.Delay(1000).Wait(); + Task.Delay(1000, ct).Wait(); }); // 3. get order book - var marketDepthManager = new MarketDepthManager(binanceRestClient, binanceSocketClient, Logger); + var marketDepthManager = new MarketDepthManager(restClient, webSocketClient, Logger); var marketDepth = new MarketDepth(Symbol); @@ -113,8 +114,7 @@ await AnsiConsole.Status() // build order book Logger.Info($"Building order book for {Symbol}..."); - await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth, - OrderBookUpdateLimit.HasValue ? (int)OrderBookUpdateLimit.Value.TotalMilliseconds : 1000); + await marketDepthManager.BuildAsync(marketDepth, OrderBookUpdateInterval, OrderBookDepth, ct); Logger.Info("Order book ready and streaming updates..."); From e11d6155677f3afb7cc4e82f432d64cd741fedf0 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 20:01:47 +0100 Subject: [PATCH 07/14] Add unit tests for MarketDepth and MarketDepthManager classes --- .github/workflows/build.yml | 7 +- src/BinanceBot.sln | 6 + .../BinanceBot.Market.Tests.csproj | 26 ++ .../Core/MarketDepthTests.cs | 259 ++++++++++++++++++ .../MarketDepthManagerTests.cs | 136 +++++++++ 5 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 tests/BinanceBot.Market.Tests/BinanceBot.Market.Tests.csproj create mode 100644 tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs create mode 100644 tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b88d81..d0db6a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,7 @@ jobs: cache: true cache-dependency-path: | src/**/*.csproj + tests/**/*.csproj src/**/global.json src/**/NuGet.Config @@ -50,9 +51,9 @@ jobs: run: dotnet build --no-restore --configuration ${{ matrix.configuration }} --nologo - name: Test - run: dotnet test --no-build --configuration ${{ matrix.configuration }} --collect:"XPlat Code Coverage" --logger "trx;LogFileName=test_results.trx" || echo "No tests found" + run: dotnet test BinanceBot.sln --no-build --configuration ${{ matrix.configuration }} --collect:"XPlat Code Coverage" --logger "trx;LogFileName=test_results.trx" - - name: Upload test results (TRX) + - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: @@ -61,7 +62,7 @@ jobs: **/TestResults/**/*.trx if-no-files-found: warn - - name: Upload code coverage (Cobertura) + - name: Upload code coverage if: always() uses: actions/upload-artifact@v4 with: diff --git a/src/BinanceBot.sln b/src/BinanceBot.sln index fd1af75..740d7bd 100644 --- a/src/BinanceBot.sln +++ b/src/BinanceBot.sln @@ -14,6 +14,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ..\README.md = ..\README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BinanceBot.Market.Tests", "..\tests\BinanceBot.Market.Tests\BinanceBot.Market.Tests.csproj", "{C2DCC04A-3BF3-4568-96D9-5E54B4952657}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,6 +34,10 @@ Global {7912A437-0EC4-4939-B244-9379927AB0D1}.Debug|Any CPU.Build.0 = Debug|Any CPU {7912A437-0EC4-4939-B244-9379927AB0D1}.Release|Any CPU.ActiveCfg = Release|Any CPU {7912A437-0EC4-4939-B244-9379927AB0D1}.Release|Any CPU.Build.0 = Release|Any CPU + {C2DCC04A-3BF3-4568-96D9-5E54B4952657}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2DCC04A-3BF3-4568-96D9-5E54B4952657}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2DCC04A-3BF3-4568-96D9-5E54B4952657}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2DCC04A-3BF3-4568-96D9-5E54B4952657}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/tests/BinanceBot.Market.Tests/BinanceBot.Market.Tests.csproj b/tests/BinanceBot.Market.Tests/BinanceBot.Market.Tests.csproj new file mode 100644 index 0000000..9029acc --- /dev/null +++ b/tests/BinanceBot.Market.Tests/BinanceBot.Market.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs new file mode 100644 index 0000000..c5571e6 --- /dev/null +++ b/tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs @@ -0,0 +1,259 @@ +using Binance.Net.Enums; +using Binance.Net.Objects.Models; +using BinanceBot.Market.Core; + +namespace BinanceBot.Market.Tests.Core; + +public class MarketDepthTests +{ + [Fact] + public void Constructor_WithValidSymbol_CreatesInstance() + { + // Arrange & Act + var marketDepth = new MarketDepth("BTCUSDT"); + + // Assert + Assert.Equal("BTCUSDT", marketDepth.Symbol); + Assert.Null(marketDepth.LastUpdateTime); + Assert.Empty(marketDepth.Asks); + Assert.Empty(marketDepth.Bids); + } + + [Theory] + [InlineData("")] + public void Constructor_WithInvalidSymbol_ThrowsArgumentException(string symbol) + { + // Act & Assert + Assert.Throws(() => new MarketDepth(symbol)); + } + + [Fact] + public void UpdateDepth_WithValidData_UpdatesOrderBook() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + 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.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() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + 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 - try to update with older timestamp + var newAsks = new List + { + new() { Price = 51000m, Quantity = 2.0m } + }; + marketDepth.UpdateDepth(newAsks, bids, 123400); + + // Assert - should still have old data + Assert.Equal(123456, marketDepth.LastUpdateTime); + Assert.Equal(50000m, marketDepth.BestAsk.Price); + } + + [Fact] + public void UpdateDepth_RemovesPriceLevelWithZeroQuantity() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + 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 } + }; + marketDepth.UpdateDepth(asks, bids, 123456); + + // Act - update with zero quantity to remove price level + var updateAsks = new List + { + new() { Price = 50000m, Quantity = 0m } + }; + marketDepth.UpdateDepth(updateAsks, bids, 123457); + + // Assert + Assert.Single(marketDepth.Asks); + Assert.Equal(50100m, marketDepth.BestAsk.Price); + } + + [Fact] + public void BestPair_WhenOrderBookIsEmpty_ReturnsNull() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + + // Act & Assert + Assert.Null(marketDepth.BestPair); + } + + [Fact] + public void BestPair_WhenOrderBookHasData_ReturnsPair() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + 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 MarketDepthChanged_RaisesEvent_WhenDepthUpdated() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + MarketDepthChangedEventArgs? eventArgs = null; + marketDepth.MarketDepthChanged += (sender, e) => eventArgs = e; + + var asks = new List + { + new() { Price = 50000m, Quantity = 1.5m } + }; + var bids = new List + { + new() { Price = 49900m, Quantity = 1.0m } + }; + + // Act + marketDepth.UpdateDepth(asks, bids, 123456); + + // Assert + Assert.NotNull(eventArgs); + Assert.Equal(123456, eventArgs.UpdateTime); + Assert.Single(eventArgs.Asks); + } + + [Fact] + public void MarketBestPairChanged_RaisesEvent_WhenBestPairChanges() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + var eventRaised = false; + marketDepth.MarketBestPairChanged += (sender, e) => eventRaised = true; + + var asks = new List + { + new() { Price = 50000m, Quantity = 1.5m } + }; + var bids = new List + { + new() { Price = 49900m, Quantity = 1.0m } + }; + + // Act + marketDepth.UpdateDepth(asks, bids, 123456); + + // Assert + Assert.True(eventRaised); + } + + [Fact] + public void Asks_AreSortedAscending() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + var asks = new List + { + new() { Price = 50100m, Quantity = 2.0m }, + new() { Price = 50000m, Quantity = 1.5m }, + new() { Price = 50200m, Quantity = 1.0m } + }; + var bids = new List + { + new() { Price = 49900m, Quantity = 1.0m } + }; + + // Act + marketDepth.UpdateDepth(asks, bids, 123456); + + // Assert + var askPrices = marketDepth.Asks.Select(a => a.Price).ToList(); + Assert.Equal(new[] { 50000m, 50100m, 50200m }, askPrices); + } + + [Fact] + public void Bids_AreSortedDescending() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + var asks = new List + { + new() { Price = 50000m, Quantity = 1.0m } + }; + var bids = new List + { + new() { Price = 49800m, Quantity = 0.5m }, + new() { Price = 49900m, Quantity = 1.0m }, + new() { Price = 49700m, Quantity = 2.0m } + }; + + // Act + marketDepth.UpdateDepth(asks, bids, 123456); + + // Assert + var bidPrices = marketDepth.Bids.Select(b => b.Price).ToList(); + Assert.Equal(new[] { 49900m, 49800m, 49700m }, bidPrices); + } + + [Fact] + public void UpdateDepth_WithZeroOrNegativeUpdateTime_ThrowsArgumentOutOfRangeException() + { + // Arrange + var marketDepth = new MarketDepth("BTCUSDT"); + var asks = new List + { + new() { Price = 50000m, Quantity = 1.5m } + }; + + // Act & Assert + Assert.Throws(() => marketDepth.UpdateDepth(asks, null, 0)); + Assert.Throws(() => marketDepth.UpdateDepth(asks, null, -1)); + } +} diff --git a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs new file mode 100644 index 0000000..f0693b7 --- /dev/null +++ b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs @@ -0,0 +1,136 @@ +using System.Threading; +using System.Threading.Tasks; +using Binance.Net.Interfaces.Clients; +using BinanceBot.Market.Core; +using Moq; +using NLog; + +namespace BinanceBot.Market.Tests; + +public class MarketDepthManagerTests +{ + private readonly Mock _mockRestClient; + private readonly Mock _mockSocketClient; + private readonly Mock _mockLogger; + + public MarketDepthManagerTests() + { + _mockRestClient = new Mock(); + _mockSocketClient = new Mock(); + _mockLogger = new Mock(); + } + + [Fact] + public void Constructor_WithNullRestClient_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new MarketDepthManager(null, _mockSocketClient.Object, _mockLogger.Object)); + } + + [Fact] + public void Constructor_WithNullSocketClient_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new MarketDepthManager(_mockRestClient.Object, null, _mockLogger.Object)); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, null)); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Act + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + + // Assert + Assert.NotNull(manager); + } + + [Fact] + public async Task BuildAsync_WithNullMarketDepth_ThrowsArgumentNullException() + { + // Arrange + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => + manager.BuildAsync(null)); + } + + [Fact] + public async Task BuildAsync_WithZeroOrderBookDepth_ThrowsArgumentOutOfRangeException() + { + // Arrange + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + var marketDepth = new MarketDepth("BTCUSDT"); + + // Act & Assert + await Assert.ThrowsAsync(() => + manager.BuildAsync(marketDepth, null, 0)); + } + + [Fact] + public async Task BuildAsync_WithNegativeOrderBookDepth_ThrowsArgumentOutOfRangeException() + { + // Arrange + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + var marketDepth = new MarketDepth("BTCUSDT"); + + // Act & Assert + await Assert.ThrowsAsync(() => + manager.BuildAsync(marketDepth, null, -5)); + } + + [Fact] + public async Task BuildAsync_WithNegativeUpdateInterval_ThrowsArgumentOutOfRangeException() + { + // Arrange + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + var marketDepth = new MarketDepth("BTCUSDT"); + + // Act & Assert + await Assert.ThrowsAsync(() => + manager.BuildAsync(marketDepth, TimeSpan.FromMilliseconds(-100))); + } + + [Fact] + public async Task BuildAsync_WithZeroUpdateInterval_ThrowsArgumentOutOfRangeException() + { + // Arrange + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + var marketDepth = new MarketDepth("BTCUSDT"); + + // Act & Assert + await Assert.ThrowsAsync(() => + manager.BuildAsync(marketDepth, TimeSpan.Zero)); + } + + [Fact] + public void StreamUpdates_WithNullMarketDepth_ThrowsArgumentNullException() + { + // Arrange + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + + // Act & Assert + Assert.Throws(() => + manager.StreamUpdates(null)); + } + + [Fact] + public async Task StopStreamingAsync_WithoutActiveSubscription_DoesNotThrow() + { + // Arrange + var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); + + // Act & Assert - should not throw + await manager.StopStreamingAsync(); + } +} From 4820145cc7e7798979953261afbbba4ba469f855 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Tue, 18 Nov 2025 20:02:10 +0100 Subject: [PATCH 08/14] Remove outdated remarks --- src/BinanceBot.Market/Core/MarketDepth.cs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/BinanceBot.Market/Core/MarketDepth.cs b/src/BinanceBot.Market/Core/MarketDepth.cs index 4eac2f3..1e2c556 100644 --- a/src/BinanceBot.Market/Core/MarketDepth.cs +++ b/src/BinanceBot.Market/Core/MarketDepth.cs @@ -77,19 +77,6 @@ public MarketDepth(string symbol) /// Update market depth /// /// - /// How to manage a local order book correctly [1]: - /// 1. Open a stream to wss://stream.binance.com:9443/ws/bnbbtc@depth - /// 2. Buffer the events you receive from the stream - /// 3. Get a depth snapshot from https://www.binance.com/api/v1/depth?symbol=BNBBTC&limit=1000 - /// -> 4. Drop any event where u is less or equal lastUpdateId in the snapshot - /// 5. The first processed should have U less or equal lastUpdateId+1 AND u equal or greater lastUpdateId+1 - /// -> 6. While listening to the stream, each new event's U should be equal to the previous event's u+1 - /// -> 7. The data in each event is the absolute quantity for a price level - /// -> 8. If the quantity is 0, remove the price level - /// 9. Receiving an event that removes a price level that is not in your local order book can happen and is normal. - /// Reference: - /// 1. https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#how-to-manage-a-local-order-book-correctly - /// public void UpdateDepth(IEnumerable asks, IEnumerable bids, long updateTime) { if (updateTime <= 0) From 41193d8911b1ec17836a97f4093e84c28573e717 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 05:59:44 +0100 Subject: [PATCH 09/14] Validate ask-bid price relationship --- src/BinanceBot.Market/Core/MarketDepthPair.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/BinanceBot.Market/Core/MarketDepthPair.cs b/src/BinanceBot.Market/Core/MarketDepthPair.cs index 00456c9..67e65b5 100644 --- a/src/BinanceBot.Market/Core/MarketDepthPair.cs +++ b/src/BinanceBot.Market/Core/MarketDepthPair.cs @@ -13,6 +13,8 @@ public MarketDepthPair(Quote ask, Quote bid, long updateTime) throw new ArgumentNullException(nameof(ask)); if (bid == null) throw new ArgumentNullException(nameof(bid)); + if (ask.Price < bid.Price) + throw new ArgumentException("Ask price must be greater than or equal to bid price."); if (updateTime <= 0) throw new ArgumentOutOfRangeException(nameof(updateTime)); From f9962e77333baeb77b7869d14b8762300c7ce4e9 Mon Sep 17 00:00:00 2001 From: Dmitry Petukhov Date: Wed, 19 Nov 2025 08:02:14 +0300 Subject: [PATCH 10/14] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/BinanceBot.Market/MarketMakerBot.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index 98665fd..b4aba44 100644 --- a/src/BinanceBot.Market/MarketMakerBot.cs +++ b/src/BinanceBot.Market/MarketMakerBot.cs @@ -138,11 +138,11 @@ public override async Task RunAsync() var marketDepthManager = new MarketDepthManager(_binanceRestClient, _webSocketClient, Logger); - var uppdateInterval = TimeSpan.FromMilliseconds(1000); + var updateInterval = TimeSpan.FromMilliseconds(1000); // stream order book updates - marketDepthManager.StreamUpdates(_marketDepth, uppdateInterval); + marketDepthManager.StreamUpdates(_marketDepth, updateInterval); // build order book - await marketDepthManager.BuildAsync(_marketDepth, orderBookDepth: 100, updateInterval: uppdateInterval); + await marketDepthManager.BuildAsync(_marketDepth, orderBookDepth: 100, updateInterval: updateInterval); } From cd9313748a4678fb63ff52d23f0fcf2b73b00f5d Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 06:07:22 +0100 Subject: [PATCH 11/14] Align args order --- src/BinanceBot.Market/MarketDepthManager.cs | 2 +- src/BinanceBot.MarketViewer.Console/Program.cs | 2 +- tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 4f414cc..4de37d2 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -67,7 +67,7 @@ public MarketDepthManager(IBinanceClient restClient, IBinanceSocketClient webSoc /// must be greater than zero /// The operation was canceled. /// Failed to subscribe to order book updates or get order book snapshot - public async Task BuildAsync(MarketDepth marketDepth, TimeSpan? updateInterval = default, short orderBookDepth = 10, CancellationToken ct = default) + public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, TimeSpan? updateInterval = default, CancellationToken ct = default) { if (marketDepth == null) throw new ArgumentNullException(nameof(marketDepth)); diff --git a/src/BinanceBot.MarketViewer.Console/Program.cs b/src/BinanceBot.MarketViewer.Console/Program.cs index 44de417..1a8e583 100644 --- a/src/BinanceBot.MarketViewer.Console/Program.cs +++ b/src/BinanceBot.MarketViewer.Console/Program.cs @@ -114,7 +114,7 @@ await AnsiConsole.Status() // build order book Logger.Info($"Building order book for {Symbol}..."); - await marketDepthManager.BuildAsync(marketDepth, OrderBookUpdateInterval, OrderBookDepth, ct); + await marketDepthManager.BuildAsync(marketDepth, OrderBookDepth, OrderBookUpdateInterval, ct); Logger.Info("Order book ready and streaming updates..."); diff --git a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs index f0693b7..69c952b 100644 --- a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs +++ b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs @@ -74,7 +74,7 @@ public async Task BuildAsync_WithZeroOrderBookDepth_ThrowsArgumentOutOfRangeExce // Act & Assert await Assert.ThrowsAsync(() => - manager.BuildAsync(marketDepth, null, 0)); + manager.BuildAsync(marketDepth, updateInterval: null, orderBookDepth: 0)); } [Fact] @@ -86,7 +86,7 @@ public async Task BuildAsync_WithNegativeOrderBookDepth_ThrowsArgumentOutOfRange // Act & Assert await Assert.ThrowsAsync(() => - manager.BuildAsync(marketDepth, null, -5)); + manager.BuildAsync(marketDepth, updateInterval: null, orderBookDepth: -5)); } [Fact] @@ -98,7 +98,7 @@ public async Task BuildAsync_WithNegativeUpdateInterval_ThrowsArgumentOutOfRange // Act & Assert await Assert.ThrowsAsync(() => - manager.BuildAsync(marketDepth, TimeSpan.FromMilliseconds(-100))); + manager.BuildAsync(marketDepth, updateInterval: TimeSpan.FromMilliseconds(-100))); } [Fact] @@ -110,7 +110,7 @@ public async Task BuildAsync_WithZeroUpdateInterval_ThrowsArgumentOutOfRangeExce // Act & Assert await Assert.ThrowsAsync(() => - manager.BuildAsync(marketDepth, TimeSpan.Zero)); + manager.BuildAsync(marketDepth, updateInterval: TimeSpan.Zero)); } [Fact] From 17be0970f72849caa6c7eb02b2f97210eba87cea Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 06:11:00 +0100 Subject: [PATCH 12/14] Minot refactor event buffer checks --- src/BinanceBot.Market/MarketDepthManager.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 4de37d2..6f8b21a 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -130,10 +130,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, lock (_eventBuffer) { - if (_eventBuffer.Count > 0) - firstEvent = _eventBuffer.Peek() as BinanceEventOrderBook; - else - firstEvent = null; + firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() as BinanceEventOrderBook : null; } } @@ -141,7 +138,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, { // Step 5: Discard buffered events where u <= lastUpdateId int discardedCount = 0; - while (_eventBuffer.Count > 0 && _eventBuffer.Peek().LastUpdateId <= snapshot.LastUpdateId) + while (_eventBuffer.Any() && _eventBuffer.Peek().LastUpdateId <= snapshot.LastUpdateId) { _eventBuffer.Dequeue(); discardedCount++; @@ -156,7 +153,7 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, // Step 7: Apply buffered updates int appliedCount = 0; - while (_eventBuffer.Count > 0) + while (_eventBuffer.Any()) { var bufferedEvent = _eventBuffer.Peek() as BinanceEventOrderBook; if (bufferedEvent != null) From 692d427aab1a51acbdc8a077a9dd929eed74e624 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 06:13:34 +0100 Subject: [PATCH 13/14] Remove extra permissions check --- 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 56fec7a..1f23b3d 100644 --- a/src/BinanceBot.MarketBot.Console/Program.cs +++ b/src/BinanceBot.MarketBot.Console/Program.cs @@ -55,7 +55,7 @@ static async Task Main(string[] args) ReadLine(); } - if (!(permissionsResponse.Data.IpRestrict && permissionsResponse.Data.EnableSpotAndMarginTrading)) + if (!permissionsResponse.Data.EnableSpotAndMarginTrading) { Logger.Error("Insufficient API permissions"); ReadLine(); From 5e6fd730582fc555dae39a4f33895c3a81240b69 Mon Sep 17 00:00:00 2001 From: codez0mb1e Date: Wed, 19 Nov 2025 06:20:51 +0100 Subject: [PATCH 14/14] Refactor StreamUpdates method to be asynchronous --- src/BinanceBot.Market/MarketDepthManager.cs | 14 ++++++++------ src/BinanceBot.Market/MarketMakerBot.cs | 2 +- .../MarketDepthManagerTests.cs | 6 +++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/BinanceBot.Market/MarketDepthManager.cs b/src/BinanceBot.Market/MarketDepthManager.cs index 6f8b21a..c6ed1af 100644 --- a/src/BinanceBot.Market/MarketDepthManager.cs +++ b/src/BinanceBot.Market/MarketDepthManager.cs @@ -172,22 +172,24 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10, /// Stream updates /// /// Market depth + /// Stream updates asynchronously. + /// + /// Market depth /// Update interval (100ms or 1000ms) - public void StreamUpdates(MarketDepth marketDepth, TimeSpan? updateInterval = default, CancellationToken ct = default) + 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 - _subscription = _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( + var subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync( marketDepth.Symbol, updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds, data => OnDepthUpdate(marketDepth, data), - ct) - .Result.Data; - } + ct); - /// + _subscription = subscriptionResult.Data; + } /// Stop streaming updates and unsubscribe /// public async Task StopStreamingAsync(CancellationToken ct = default) diff --git a/src/BinanceBot.Market/MarketMakerBot.cs b/src/BinanceBot.Market/MarketMakerBot.cs index b4aba44..a7d0d61 100644 --- a/src/BinanceBot.Market/MarketMakerBot.cs +++ b/src/BinanceBot.Market/MarketMakerBot.cs @@ -140,7 +140,7 @@ public override async Task RunAsync() var marketDepthManager = new MarketDepthManager(_binanceRestClient, _webSocketClient, Logger); var updateInterval = TimeSpan.FromMilliseconds(1000); // stream order book updates - marketDepthManager.StreamUpdates(_marketDepth, updateInterval); + await marketDepthManager.StreamUpdatesAsync(_marketDepth, updateInterval); // build order book await marketDepthManager.BuildAsync(_marketDepth, orderBookDepth: 100, updateInterval: updateInterval); } diff --git a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs index 69c952b..7ca6165 100644 --- a/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs +++ b/tests/BinanceBot.Market.Tests/MarketDepthManagerTests.cs @@ -114,14 +114,14 @@ await Assert.ThrowsAsync(() => } [Fact] - public void StreamUpdates_WithNullMarketDepth_ThrowsArgumentNullException() + public async Task StreamUpdates_WithNullMarketDepth_ThrowsArgumentNullException() { // Arrange var manager = new MarketDepthManager(_mockRestClient.Object, _mockSocketClient.Object, _mockLogger.Object); // Act & Assert - Assert.Throws(() => - manager.StreamUpdates(null)); + await Assert.ThrowsAsync(() => + manager.StreamUpdatesAsync(null)); } [Fact]