From 7fae0b21685ddbb52946b78ba67d3c5a225b182f Mon Sep 17 00:00:00 2001 From: Morpheums Date: Fri, 26 Jun 2026 22:01:06 +0100 Subject: [PATCH 1/4] patch(PATCH-006): map Bybit order-book depth to supported spot tiers Bybit v5 spot publishes order books only at depths 1/50/200/1000. The topic builder used the raw requested depth, so a non-tier depth (e.g. 20) produced an invalid topic that Bybit rejected (success:false), and no book updates were ever delivered. Round each requested depth UP to the nearest supported tier in BuildTopic, keeping RoutingKeyFor and Classify consistent since both derive from the same topic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Streaming/BybitStreamProtocol.cs | 14 ++++- .../Streaming/BybitStreamProtocolTests.cs | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs b/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs index eaf4353..df8a806 100644 --- a/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs +++ b/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs @@ -143,8 +143,7 @@ public StreamFrame Classify(ReadOnlySpan frame) { StreamKind.Ticker => $"tickers.{request.WireSymbol}", StreamKind.Trade => $"publicTrade.{request.WireSymbol}", - StreamKind.OrderBook when request.Depth.HasValue => $"orderbook.{request.Depth}.{request.WireSymbol}", - StreamKind.OrderBook => $"orderbook.{DefaultOrderBookDepth}.{request.WireSymbol}", + StreamKind.OrderBook => $"orderbook.{MapOrderBookDepth(request.Depth)}.{request.WireSymbol}", StreamKind.Kline when request.Interval is not null => $"kline.{MapInterval(request.Interval)}.{request.WireSymbol}", StreamKind.Kline => $"kline.1.{request.WireSymbol}", @@ -152,6 +151,17 @@ public StreamFrame Classify(ReadOnlySpan frame) $"Unsupported stream kind: {request.Kind}") }; + // Bybit v5 spot order books publish only at depths 1/50/200/1000; an unsupported depth + // yields an invalid topic the venue rejects, so round each request UP to the nearest tier. + private static int MapOrderBookDepth(int? requested) => requested switch + { + null => DefaultOrderBookDepth, + <= 1 => 1, + <= 50 => 50, + <= 200 => 200, + _ => 1000 + }; + private static string MapInterval(string intervalToken) => intervalToken switch { nameof(KlineInterval.OneMinute) => "1", diff --git a/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs b/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs index 891d377..5b16f8d 100644 --- a/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs +++ b/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs @@ -269,6 +269,59 @@ public void BuildSubscribe_OrderBook_ExplicitDepth200() doc.RootElement.GetProperty("args")[0].GetString().Should().Be("orderbook.200.BTCUSDT"); } + [Theory] + [InlineData(20, "orderbook.50.BTCUSDT")] + [InlineData(5, "orderbook.50.BTCUSDT")] + [InlineData(1, "orderbook.1.BTCUSDT")] + [InlineData(50, "orderbook.50.BTCUSDT")] + [InlineData(200, "orderbook.200.BTCUSDT")] + [InlineData(500, "orderbook.1000.BTCUSDT")] + [InlineData(1000, "orderbook.1000.BTCUSDT")] + [InlineData(5000, "orderbook.1000.BTCUSDT")] + public void BuildSubscribe_OrderBook_RoundsDepthUpToSupportedTier(int requestedDepth, string expectedTopic) + { + // Bybit v5 spot publishes order books only at depths 1/50/200/1000; a non-tier depth + // would yield an invalid topic the venue rejects, so each request rounds UP to a tier. + var protocol = MakeProtocol(); + var request = new StreamRequest(StreamKind.OrderBook, "BTCUSDT", Depth: requestedDepth); + + var wire = protocol.BuildSubscribe(request); + using var doc = JsonDocument.Parse(wire); + + doc.RootElement.GetProperty("args")[0].GetString().Should().Be(expectedTopic); + } + + [Fact] + public void BuildSubscribe_OrderBook_NullDepth_MapsToDefaultTier50() + { + var protocol = MakeProtocol(); + var request = new StreamRequest(StreamKind.OrderBook, "BTCUSDT", Depth: null); + + var wire = protocol.BuildSubscribe(request); + using var doc = JsonDocument.Parse(wire); + + doc.RootElement.GetProperty("args")[0].GetString().Should().Be("orderbook.50.BTCUSDT"); + } + + [Fact] + public void RoutingKeyFor_OrderBook_NonTierDepth_MatchesBuildSubscribeTopic() + { + // Depth 20 is not a Bybit tier; both RoutingKeyFor and the subscribe topic must map it + // to the same tier (50) or data frames would never route back to the subscription. + var protocol = MakeProtocol(); + var request = new StreamRequest(StreamKind.OrderBook, "BTCUSDT", Depth: 20); + + var routingKey = protocol.RoutingKeyFor(request); + var wire = protocol.BuildSubscribe(request); + using var doc = JsonDocument.Parse(wire); + var subscribeTopic = doc.RootElement.GetProperty("args")[0].GetString(); + + routingKey.Should().Be("orderbook.50.BTCUSDT"); + subscribeTopic.Should().Be("orderbook.50.BTCUSDT"); + routingKey.Should().Be(subscribeTopic, + "RoutingKeyFor and BuildSubscribe must share one mapped keyspace so frames reach their subscription"); + } + [Fact] public void BuildSubscribe_Kline_OneMinute() { From f3b5cedad51aa1a7faf438515e46e2b8b77c263c Mon Sep 17 00:00:00 2001 From: Morpheums Date: Fri, 26 Jun 2026 22:06:26 +0100 Subject: [PATCH 2/4] patch(PATCH-006): refresh stale order-book depth comment to include 1000 tier Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Streaming/BybitStreamProtocolTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs b/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs index 5b16f8d..759b88f 100644 --- a/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs +++ b/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs @@ -235,7 +235,7 @@ public void BuildSubscribe_Trade_ProducesCorrectTopic() [Fact] public void BuildSubscribe_OrderBook_DefaultDepth50() { - // Confirmed default depth = 50 (Bybit v5 spot available levels: 1, 50, 200). + // Confirmed default depth = 50 (Bybit v5 spot available levels: 1, 50, 200, 1000). var protocol = MakeProtocol(); var request = new StreamRequest(StreamKind.OrderBook, "BTCUSDT"); From ed7e0e98dc9143d281abe00aeedc06bad195acb4 Mon Sep 17 00:00:00 2001 From: Morpheums Date: Fri, 26 Jun 2026 22:35:43 +0100 Subject: [PATCH 3/4] patch(PATCH-007): Bybit MapOrderBookDepth throws for depth > 1000 instead of silent clamp CodeRabbit PR #55: depths above the deepest Bybit v5 spot tier (1000) were silently clamped DOWN to 1000, under-delivering a shallower book than requested with no signal. Since the mapping otherwise rounds UP, an unsatisfiable request (> 1000) now throws ArgumentOutOfRangeException, matching the file's existing unsupported-value style. Round-up for 1..1000 unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Streaming/BybitStreamProtocol.cs | 8 +++++--- .../Streaming/BybitStreamProtocolTests.cs | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs b/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs index df8a806..94f6e0f 100644 --- a/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs +++ b/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs @@ -151,15 +151,17 @@ public StreamFrame Classify(ReadOnlySpan frame) $"Unsupported stream kind: {request.Kind}") }; - // Bybit v5 spot order books publish only at depths 1/50/200/1000; an unsupported depth - // yields an invalid topic the venue rejects, so round each request UP to the nearest tier. + // Bybit v5 spot publishes order books only at tiers 1/50/200/1000; round each request UP to + // the nearest tier, throwing above 1000 (deepest tier) rather than under-delivering silently. private static int MapOrderBookDepth(int? requested) => requested switch { null => DefaultOrderBookDepth, <= 1 => 1, <= 50 => 50, <= 200 => 200, - _ => 1000 + <= 1000 => 1000, + _ => throw new ArgumentOutOfRangeException(nameof(requested), requested, + $"Bybit v5 spot supports order-book depths up to 1000; requested {requested}.") }; private static string MapInterval(string intervalToken) => intervalToken switch diff --git a/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs b/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs index 759b88f..9118be4 100644 --- a/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs +++ b/tests/CryptoExchanges.Net.Bybit.Tests.Unit/Streaming/BybitStreamProtocolTests.cs @@ -277,7 +277,6 @@ public void BuildSubscribe_OrderBook_ExplicitDepth200() [InlineData(200, "orderbook.200.BTCUSDT")] [InlineData(500, "orderbook.1000.BTCUSDT")] [InlineData(1000, "orderbook.1000.BTCUSDT")] - [InlineData(5000, "orderbook.1000.BTCUSDT")] public void BuildSubscribe_OrderBook_RoundsDepthUpToSupportedTier(int requestedDepth, string expectedTopic) { // Bybit v5 spot publishes order books only at depths 1/50/200/1000; a non-tier depth @@ -291,6 +290,21 @@ public void BuildSubscribe_OrderBook_RoundsDepthUpToSupportedTier(int requestedD doc.RootElement.GetProperty("args")[0].GetString().Should().Be(expectedTopic); } + [Theory] + [InlineData(1001)] + [InlineData(5000)] + public void BuildSubscribe_OrderBook_DepthAbove1000_Throws(int requestedDepth) + { + // 1000 is the deepest Bybit v5 spot tier; a deeper request is unsatisfiable and must + // throw rather than silently clamp DOWN to a shallower book than asked for. + var protocol = MakeProtocol(); + var request = new StreamRequest(StreamKind.OrderBook, "BTCUSDT", Depth: requestedDepth); + + var act = () => protocol.BuildSubscribe(request); + + act.Should().Throw(); + } + [Fact] public void BuildSubscribe_OrderBook_NullDepth_MapsToDefaultTier50() { From a28fe8b83cbf31207615f26050418134bc383d9f Mon Sep 17 00:00:00 2001 From: Morpheums Date: Fri, 26 Jun 2026 23:17:27 +0100 Subject: [PATCH 4/4] patch(PATCH-008): list supported Bybit order-book depth tiers in OOR message Address Copilot nit on PR #55: the ArgumentOutOfRangeException for depth > 1000 now enumerates the discrete supported Bybit v5 spot tiers (1, 50, 200, 1000) instead of only stating the maximum, making the failure actionable. Same exception type, paramName, and actual value preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs b/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs index 94f6e0f..57325fd 100644 --- a/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs +++ b/src/CryptoExchanges.Net.Bybit/Streaming/BybitStreamProtocol.cs @@ -161,7 +161,7 @@ public StreamFrame Classify(ReadOnlySpan frame) <= 200 => 200, <= 1000 => 1000, _ => throw new ArgumentOutOfRangeException(nameof(requested), requested, - $"Bybit v5 spot supports order-book depths up to 1000; requested {requested}.") + $"Bybit v5 spot supports order-book depths 1, 50, 200, or 1000; requested {requested}.") }; private static string MapInterval(string intervalToken) => intervalToken switch