Skip to content

Commit c548df5

Browse files
committed
Add support for Perpetual contract
1 parent 21dc1b4 commit c548df5

2 files changed

Lines changed: 174 additions & 29 deletions

File tree

src/BinanceBot.Market/MarketDepthManager.cs

Lines changed: 110 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Binance.Net.Interfaces;
77
using Binance.Net.Interfaces.Clients;
88
using Binance.Net.Objects.Models.Spot;
9+
using Binance.Net.Objects.Models.Futures;
910
using BinanceBot.Market.Domain;
1011
using CryptoExchange.Net.Objects;
1112
using CryptoExchange.Net.Sockets;
@@ -80,37 +81,78 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10,
8081
_logger.Debug($"1: Opening WebSocket stream for {marketDepth.Symbol}");
8182

8283
var updateIntervalMs = updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds;
83-
var subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync(
84-
marketDepth.Symbol, updateIntervalMs,
85-
data => OnDepthUpdate(marketDepth, data),
86-
ct)
87-
.ConfigureAwait(false);
84+
85+
CallResult<UpdateSubscription> subscriptionResult;
86+
87+
switch (marketDepth.Symbol.ContractType)
88+
{
89+
case ContractType.Spot:
90+
subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync(
91+
marketDepth.Symbol.FullName, updateIntervalMs,
92+
data => OnDepthUpdateSpot(marketDepth, data),
93+
ct)
94+
.ConfigureAwait(false);
95+
break;
96+
case ContractType.Perpetual:
97+
subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync(
98+
marketDepth.Symbol.FullName, updateIntervalMs,
99+
data => OnDepthUpdatePerp(marketDepth, data),
100+
ct)
101+
.ConfigureAwait(false);
102+
break;
103+
default:
104+
throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type.");
105+
}
88106

89107
if (!subscriptionResult.Success || subscriptionResult.Data == null)
90108
throw new InvalidOperationException($"Failed to subscribe to order book updates: {subscriptionResult.Error?.Message}");
91109

92110
_subscription = subscriptionResult.Data;
93111

94112
// Step 2: Wait a bit to buffer some events
95-
_logger.Debug($"2: Buffering events for {updateIntervalMs * 2}ms");
96-
await Task.Delay(updateIntervalMs * 2, ct).ConfigureAwait(false);
113+
// Use longer buffer time to ensure we have enough events before snapshot
114+
var bufferTimeMs = Math.Max(updateIntervalMs * 5, 500);
115+
_logger.Debug($"2: Buffering events for {bufferTimeMs}ms");
116+
await Task.Delay(bufferTimeMs, ct).ConfigureAwait(false);
97117

98-
_logger.Debug($"3: Getting order book snapshot for {marketDepth.Symbol}");
99118
// Step 3: Get depth snapshot
100-
WebCallResult<BinanceOrderBook> response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, orderBookDepth, ct).ConfigureAwait(false);
119+
_logger.Debug($"3: Getting order book snapshot for {marketDepth.Symbol}");
120+
121+
(bool Success, IBinanceOrderBook Data, Error Error) response;
122+
123+
switch (marketDepth.Symbol.ContractType)
124+
{
125+
case ContractType.Spot:
126+
WebCallResult<BinanceOrderBook> spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(
127+
marketDepth.Symbol.FullName, orderBookDepth, ct)
128+
.ConfigureAwait(false);
129+
130+
response = (spotResponse.Success, spotResponse.Data, spotResponse.Error);
131+
break;
132+
case ContractType.Perpetual:
133+
WebCallResult<BinanceFuturesOrderBook> perpResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync(
134+
marketDepth.Symbol.FullName, orderBookDepth, ct)
135+
.ConfigureAwait(false);
136+
137+
response = (perpResponse.Success, perpResponse.Data, perpResponse.Error);
138+
break;
139+
default:
140+
throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type.");
141+
}
142+
101143
if (!response.Success || response.Data == null)
102144
throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}");
103145

104-
BinanceOrderBook snapshot = response.Data;
146+
IBinanceOrderBook snapshot = response.Data;
105147
_logger.Debug($"Snapshot received: LastUpdateId={snapshot.LastUpdateId}");
106148

107149
// Step 4: Check if snapshot is valid
108150
// If buffered events exist and snapshot's lastUpdateId is strictly less than first event's U, retry
109-
BinanceEventOrderBook firstEvent = null;
151+
IBinanceEventOrderBook firstEvent = null;
110152
lock (_eventBuffer)
111153
{
112154
if (_eventBuffer.Count > 0)
113-
firstEvent = _eventBuffer.Peek() as BinanceEventOrderBook;
155+
firstEvent = _eventBuffer.Peek();
114156
}
115157

116158
if (firstEvent != null)
@@ -122,15 +164,34 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10,
122164
{
123165
_logger.Warn($"Snapshot too old: LastUpdateId={snapshot.LastUpdateId} < FirstEvent.U={firstEvent.FirstUpdateId}. Retrying...");
124166
// Snapshot is too old, need to get a new one
125-
response = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(marketDepth.Symbol, orderBookDepth, ct).ConfigureAwait(false);
167+
switch (marketDepth.Symbol.ContractType)
168+
{
169+
case ContractType.Spot:
170+
WebCallResult<BinanceOrderBook> spotResponse = await _restClient.SpotApi.ExchangeData.GetOrderBookAsync(
171+
marketDepth.Symbol.FullName, orderBookDepth, ct)
172+
.ConfigureAwait(false);
173+
174+
response = (spotResponse.Success, spotResponse.Data, spotResponse.Error);
175+
break;
176+
case ContractType.Perpetual:
177+
WebCallResult<BinanceFuturesOrderBook> perpResponse = await _restClient.UsdFuturesApi.ExchangeData.GetOrderBookAsync(
178+
marketDepth.Symbol.FullName, orderBookDepth, ct)
179+
.ConfigureAwait(false);
180+
181+
response = (perpResponse.Success, perpResponse.Data, perpResponse.Error);
182+
break;
183+
default:
184+
throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type.");
185+
}
186+
126187
if (!response.Success || response.Data == null)
127188
throw new InvalidOperationException($"Failed to get order book snapshot: {response.Error?.Message}");
128189
snapshot = response.Data;
129190
_logger.Debug($"New snapshot received: LastUpdateId={snapshot.LastUpdateId}");
130191

131192
lock (_eventBuffer)
132193
{
133-
firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() as BinanceEventOrderBook : null;
194+
firstEvent = _eventBuffer.Any() ? _eventBuffer.Peek() : null;
134195
}
135196
}
136197

@@ -155,13 +216,12 @@ public async Task BuildAsync(MarketDepth marketDepth, short orderBookDepth = 10,
155216
int appliedCount = 0;
156217
while (_eventBuffer.Any())
157218
{
158-
var bufferedEvent = _eventBuffer.Peek() as BinanceEventOrderBook;
219+
var bufferedEvent = _eventBuffer.Dequeue();
159220
if (bufferedEvent != null)
160221
{
161222
ApplyDepthUpdate(marketDepth, bufferedEvent);
162223
appliedCount++;
163224
}
164-
_eventBuffer.Dequeue();
165225
}
166226
_logger.Debug($"7: Applied {appliedCount} buffered events");
167227
}
@@ -182,11 +242,30 @@ public async Task StreamUpdatesAsync(MarketDepth marketDepth, TimeSpan? updateIn
182242
throw new ArgumentNullException(nameof(marketDepth));
183243

184244
// Step 1 & 2: Open WebSocket and buffer events
185-
var subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync(
186-
marketDepth.Symbol,
187-
updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds,
188-
data => OnDepthUpdate(marketDepth, data),
189-
ct);
245+
_logger.Debug($"1 & 2: Streaming updates: Opening WebSocket stream for {marketDepth.Symbol}");
246+
CallResult<UpdateSubscription> subscriptionResult;
247+
248+
switch (marketDepth.Symbol.ContractType)
249+
{
250+
case ContractType.Spot:
251+
subscriptionResult = await _webSocketClient.SpotStreams.SubscribeToOrderBookUpdatesAsync(
252+
marketDepth.Symbol.FullName,
253+
updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds,
254+
data => OnDepthUpdateSpot(marketDepth, data),
255+
ct)
256+
.ConfigureAwait(false);
257+
break;
258+
case ContractType.Perpetual:
259+
subscriptionResult = await _webSocketClient.UsdFuturesStreams.SubscribeToOrderBookUpdatesAsync(
260+
marketDepth.Symbol.FullName,
261+
updateInterval.HasValue ? (int)updateInterval.Value.TotalMilliseconds : (int)_defaultUpdateInterval.TotalMilliseconds,
262+
data => OnDepthUpdatePerp(marketDepth, data),
263+
ct)
264+
.ConfigureAwait(false);
265+
break;
266+
default:
267+
throw new ArgumentOutOfRangeException(nameof(marketDepth.Symbol.ContractType), "Unknown contract type.");
268+
}
190269

191270
_subscription = subscriptionResult.Data;
192271
}
@@ -201,17 +280,22 @@ public async Task StopStreamingAsync(CancellationToken ct = default)
201280
}
202281
}
203282

204-
private void OnDepthUpdate(MarketDepth marketDepth, DataEvent<IBinanceEventOrderBook> dataEvent)
283+
private void OnDepthUpdateSpot(MarketDepth marketDepth, DataEvent<IBinanceEventOrderBook> dataEvent) =>
284+
OnDepthUpdate(marketDepth, dataEvent.Data);
285+
286+
private void OnDepthUpdatePerp(MarketDepth marketDepth, DataEvent<IBinanceFuturesEventOrderBook> dataEvent) =>
287+
OnDepthUpdate(marketDepth, dataEvent.Data);
288+
289+
private void OnDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook data)
205290
{
206-
var data = dataEvent.Data as BinanceEventOrderBook;
207291
if (data == null) return;
208292

209293
lock (_eventBuffer)
210294
{
211295
if (!_isSnapshotLoaded)
212296
{
213297
// Step 2: Buffer events before snapshot is loaded
214-
_eventBuffer.Enqueue(dataEvent.Data);
298+
_eventBuffer.Enqueue(data);
215299
_logger.Debug($"Step 2: Buffered event U={data.FirstUpdateId}, u={data.LastUpdateId}. Buffer size: {_eventBuffer.Count}");
216300
return;
217301
}
@@ -221,7 +305,7 @@ private void OnDepthUpdate(MarketDepth marketDepth, DataEvent<IBinanceEventOrder
221305
}
222306
}
223307

224-
private void ApplyDepthUpdate(MarketDepth marketDepth, BinanceEventOrderBook eventData)
308+
private void ApplyDepthUpdate(MarketDepth marketDepth, IBinanceEventOrderBook eventData)
225309
{
226310
// Step 7: Apply update procedure
227311

@@ -253,4 +337,4 @@ private void ApplyDepthUpdate(MarketDepth marketDepth, BinanceEventOrderBook eve
253337
// 3. Set order book update ID
254338
_localOrderBookUpdateId = eventData.LastUpdateId;
255339
}
256-
}
340+
}

tests/BinanceBot.Market.Tests/Core/MarketDepthTests.cs

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@ public class MarketDepthTests
88
{
99
private static MarketDepth CreateTestMarketDepth() =>
1010
new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Spot));
11-
[Fact]
12-
public void Constructor_WithValidSymbol_CreatesInstance()
11+
12+
private static MarketDepth CreateTestMarketDepthPerpetual() =>
13+
new MarketDepth(new MarketSymbol("BTC", "USDT", ContractType.Perpetual));
14+
15+
[Theory]
16+
[InlineData(ContractType.Spot)]
17+
[InlineData(ContractType.Perpetual)]
18+
public void Constructor_WithValidSymbol_CreatesInstance(ContractType contractType)
1319
{
1420
// Arrange
15-
var symbol = new MarketSymbol("BTC", "USDT", ContractType.Spot);
21+
var symbol = new MarketSymbol("BTC", "USDT", contractType);
1622

1723
// Act
1824
var marketDepth = new MarketDepth(symbol);
1925

2026
// Assert
2127
Assert.Equal(symbol, marketDepth.Symbol);
28+
Assert.Equal(contractType, marketDepth.Symbol.ContractType);
2229
Assert.Null(marketDepth.LastUpdateTime);
2330
Assert.Empty(marketDepth.Asks);
2431
Assert.Empty(marketDepth.Bids);
@@ -58,6 +65,34 @@ public void UpdateDepth_WithValidData_UpdatesOrderBook()
5865
Assert.Equal(49900m, marketDepth.BestBid.Price);
5966
}
6067

68+
[Fact]
69+
public void UpdateDepth_WithValidData_UpdatesOrderBook_Perpetual()
70+
{
71+
// Arrange
72+
var marketDepth = CreateTestMarketDepthPerpetual();
73+
var asks = new List<BinanceOrderBookEntry>
74+
{
75+
new() { Price = 50000m, Quantity = 1.5m },
76+
new() { Price = 50100m, Quantity = 2.0m }
77+
};
78+
var bids = new List<BinanceOrderBookEntry>
79+
{
80+
new() { Price = 49900m, Quantity = 1.0m },
81+
new() { Price = 49800m, Quantity = 0.5m }
82+
};
83+
84+
// Act
85+
marketDepth.UpdateDepth(asks, bids, 123456);
86+
87+
// Assert
88+
Assert.Equal(ContractType.Perpetual, marketDepth.Symbol.ContractType);
89+
Assert.Equal(123456, marketDepth.LastUpdateTime);
90+
Assert.Equal(2, marketDepth.Asks.Count());
91+
Assert.Equal(2, marketDepth.Bids.Count());
92+
Assert.Equal(50000m, marketDepth.BestAsk.Price);
93+
Assert.Equal(49900m, marketDepth.BestBid.Price);
94+
}
95+
6196
[Fact]
6297
public void UpdateDepth_WithOldUpdateTime_IgnoresUpdate()
6398
{
@@ -148,6 +183,32 @@ public void BestPair_WhenOrderBookHasData_ReturnsPair()
148183
Assert.Equal(100m, bestPair.PriceSpread);
149184
}
150185

186+
[Fact]
187+
public void BestPair_WhenOrderBookHasData_ReturnsPair_Perpetual()
188+
{
189+
// Arrange
190+
var marketDepth = CreateTestMarketDepthPerpetual();
191+
var asks = new List<BinanceOrderBookEntry>
192+
{
193+
new() { Price = 50000m, Quantity = 1.5m }
194+
};
195+
var bids = new List<BinanceOrderBookEntry>
196+
{
197+
new() { Price = 49900m, Quantity = 1.0m }
198+
};
199+
marketDepth.UpdateDepth(asks, bids, 123456);
200+
201+
// Act
202+
var bestPair = marketDepth.BestPair;
203+
204+
// Assert
205+
Assert.NotNull(bestPair);
206+
Assert.Equal(50000m, bestPair.Ask.Price);
207+
Assert.Equal(49900m, bestPair.Bid.Price);
208+
Assert.Equal(100m, bestPair.PriceSpread);
209+
Assert.Equal(ContractType.Perpetual, marketDepth.Symbol.ContractType);
210+
}
211+
151212
[Fact]
152213
public void MarketDepthChanged_RaisesEvent_WhenDepthUpdated()
153214
{

0 commit comments

Comments
 (0)