1
0
mirror of https://github.com/JKorf/CryptoExchange.Net.git synced 2026-08-12 17:03:10 +00:00

Compare commits

..

45 Commits

Author SHA1 Message Date
Jkorf 12ca45050f Updated to version 10.8.0 2026-03-06 09:26:30 +01:00
Jkorf eaf092a334 Added ForcePathEndWithSlash setting to RequestDefinition, added encoding parameter to SetContent on REST Request, added RequestBodyContentEncoding and OmitContentTypeHeaderWithoutContent config to RestApiClient 2026-03-06 09:25:04 +01:00
Jkorf 33c0fb26a7 Updated to version 10.7.2 2026-03-02 10:53:37 +01:00
Jkorf dabbb5c868 Added small overlap in UserDataTracker polling logic to account for API endpoints not immediately having the data available 2026-03-02 10:47:25 +01:00
Jkorf 204bda8622 Updated to version 10.7.1 2026-02-25 11:00:43 +01:00
Jkorf 78e3523a4f Updated SocketConnection internal locking to fix potential deadlock 2026-02-25 09:26:12 +01:00
Jkorf 89a73747b0 Fix for test socket not working correctly with read/write socket connection lock 2026-02-24 13:15:08 +01:00
Jkorf 02b70398b3 Updated lock call SocketConnection to outside the try 2026-02-24 13:14:48 +01:00
Jkorf 73fcb47b17 Updated to version 10.7.0 2026-02-24 11:51:37 +01:00
Jkorf d41ca3459e Updated internal lock for subscription to ReaderWriterLockSlim on SocketConnection 2026-02-24 11:32:35 +01:00
Jkorf bea2b2bd7b Added Http options to Rest client options 2026-02-24 09:58:48 +01:00
Jan Korf b29cdc41f3 Shared interfaces pagination update (#274)
Updated INextPageToken parameter on Shared interfaces to PageRequest type, functionality unchanged
Added SupportsAscending and SupportsDescending properties to PaginatedEndpointOptions to expose supported data directions
Added MaxAge property to PaginatedEndpointOptions to expose the max age of data that can be requested
Added Direction property to Shared interfaces paginated requests to configure pagination data direction 
Removed PaginationSupport property from PaginatedEndpointOptions, replaced by above new properties
Updated Shared GetTradeHistoryRequest EndTime property to be optional
Updated I(Futures/Spot)OrderRestClient.GetClosed(Futures/Spot)OrdersOptions from PaginatedEndpointOptions<GetClosedOrdersRequest> to GetClosedOrdersOptions 
Updated I(Futures/Spot)OrderRestClient.Get(Futures/Spot)UserTradesOptions from PaginatedEndpointOptions<GetUserTradesRequest> to GetUserTradesOptions
Updated rate limiting PathStartFilter to ignore added or missing slash before the path
Fixed KlineTracker throwing exception if there is no data in the initial snapshot
2026-02-23 14:53:38 +01:00
JKorf 0ce2e778f4 Added check for invalid json in JsonSocketMessageHandler and virtual GetTypeIdentifierNonJson for handling non-json messages 2026-02-22 16:29:05 +01:00
JKorf 36c2411d46 Added parsing of REST response data up to 128 characters for error responses 2026-02-22 16:05:49 +01:00
Jkorf 6d3e72745a Removed check for OnlyTrackProvidedSymbols and empty initial tracking list 2026-02-17 14:54:57 +01:00
Jkorf 51c74baa26 Updated to version 10.6.2 2026-02-17 14:39:06 +01:00
Jkorf 419e01d009 Fix for websocket queries which don't expects response getting stuck in subscribing state 2026-02-17 14:35:02 +01:00
Jkorf 297eee0e1f Updated to version 10.6.1 2026-02-16 15:42:33 +01:00
Jkorf 6a9231b1e3 Fixed exception when stopping SymbolOrderBook instance when update is received while closing 2026-02-16 15:39:36 +01:00
Jkorf e40f2a15b6 Updated to version 10.6.0 2026-02-16 09:38:55 +01:00
Jkorf b94085a27a Added some code comments 2026-02-16 09:36:44 +01:00
Jkorf 5e083811df Added check SymbolOrderBook is still alive when trying to add updates to prevent unnoticed growing in the background when subscription isn't closed while book is 2026-02-16 09:33:39 +01:00
Jkorf 7dcf5cd6ea Added AddTrackedSymbolsAsync and RemoveTrackedSymbolAsync methods to UserDataTracker, updated minimal fromTime filter in UserDataTracker to 5 seconds 2026-02-16 09:11:01 +01:00
Jkorf 1471a4733f Updated symbol tracking logic on UserDataTracker, added check for startTime filter for polling being to close to current time 2026-02-13 10:12:43 +01:00
Jkorf f39d9f7cfb Updated to version 10.5.4 2026-02-12 11:43:20 +01:00
Jkorf 9fab8faa45 Fixed bug in polling time filter for UserDataTracker items 2026-02-12 11:35:04 +01:00
Jkorf 226f175343 Fixed type check ExchangeParameters GetValue 2026-02-11 14:50:39 +01:00
Jkorf 813bd9f5a1 Updated to version 10.5.3 2026-02-11 13:10:08 +01:00
Jkorf c8d2b4f09d Added check EnumConverter to detect undefined int value parsing 2026-02-11 12:57:03 +01:00
Jkorf 6560b82a3e Fixed orders getting incorrectly set to canceled state for UserDataTracker spot and futures orders 2026-02-11 11:44:32 +01:00
JKorf e151af8f37 Updated client versions examples 2026-02-10 18:38:45 +01:00
Jkorf bdf7a07c6f Updated to version 10.5.2 2026-02-10 16:04:17 +01:00
Jkorf a8ffe90bf2 Added call to ApiClient.HandleUnhandledMessage when no websocket message processor is found based on topic to allow additional processing 2026-02-10 15:59:35 +01:00
Jkorf 3372b9eb44 Set query completed after setting Result 2026-02-10 15:40:43 +01:00
Jkorf df25221960 Combined subscribe and re-subscribe logic 2026-02-10 15:40:13 +01:00
Jkorf 7c67a014f5 Added check for subscribe queries with TimeoutBehavior.Success to complete when subscription has received update 2026-02-10 14:31:42 +01:00
Jkorf 65291b3195 Updated to version 10.5.1 2026-02-10 11:53:23 +01:00
Jkorf ece6a9d27d Fixed trading mode selection for futures listen key method in FuturesUserDataTracker 2026-02-10 11:51:19 +01:00
Jkorf 1ed29b0474 Updated to version 10.5.0 2026-02-10 10:22:05 +01:00
Jkorf be2eb01353 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2026-02-10 10:15:46 +01:00
JKorf 39cd66596d Added some tests 2026-02-09 21:49:07 +01:00
Jkorf 63f51811a9 Updated logging unmatched websocket message 2026-02-09 16:29:24 +01:00
Jkorf abda065237 Updated websocket message forwarding logic 2026-02-09 12:57:52 +01:00
Jkorf 759c8b9a58 Fixed bug in UserDataTracker orders logic incorrectly setting order to canceled status 2026-02-09 09:07:17 +01:00
JKorf 8e18482781 Added keep alive for listenkeys to UserDataTracker 2026-02-08 17:02:09 +01:00
86 changed files with 3624 additions and 699 deletions
@@ -0,0 +1,465 @@
using CryptoExchange.Net.SharedApis;
using NUnit.Framework;
using System;
using System.Linq;
namespace CryptoExchange.Net.UnitTests
{
[TestFixture()]
public class ExchangeSymbolCacheTests
{
private SharedSpotSymbol[] CreateTestSymbols()
{
return new[]
{
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot),
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot),
new SharedSpotSymbol("BTC", "EUR", "BTCEUR", true, TradingMode.Spot),
new SharedSpotSymbol("ETH", "BTC", "ETHBTC", true, TradingMode.Spot),
new SharedSpotSymbol("XRP", "USDT", "XRPUSDT", false, TradingMode.Spot)
};
}
private SharedSpotSymbol[] CreateFuturesSymbols()
{
return new[]
{
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT-PERP", true, TradingMode.PerpetualLinear),
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT-PERP", true, TradingMode.PerpetualLinear)
};
}
[Test]
public void UpdateSymbolInfo_NewTopic_Should_AddToCache()
{
// arrange
var topicId = "NewExchange";
var symbols = CreateTestSymbols();
// act
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
var hasCached = ExchangeSymbolCache.HasCached(topicId);
// assert
Assert.That(hasCached, Is.True);
}
[Test]
public void UpdateSymbolInfo_Should_StoreAllSymbols()
{
// arrange
var topicId = "ExchangeWithSymbols";
var symbols = CreateTestSymbols();
// act
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// assert
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "BTCUSDT"), Is.True);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "ETHUSDT"), Is.True);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "BTCEUR"), Is.True);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "ETHBTC"), Is.True);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "XRPUSDT"), Is.True);
}
[Test]
public void UpdateSymbolInfo_CalledTwiceWithinAnHour_Should_NotUpdate()
{
// arrange
var topicId = "ExchangeNoUpdate";
var initialSymbols = new[]
{
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot)
};
var updatedSymbols = new[]
{
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot),
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot)
};
// act
ExchangeSymbolCache.UpdateSymbolInfo(topicId, initialSymbols);
ExchangeSymbolCache.UpdateSymbolInfo(topicId, updatedSymbols);
// assert - should still have only the initial symbol since less than 60 minutes passed
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "BTCUSDT"), Is.True);
// The second update should not have been applied
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "ETHUSDT"), Is.False);
}
[Test]
public void UpdateSymbolInfo_WithEmptyArray_Should_CreateEmptyCache()
{
// arrange
var topicId = "EmptyExchange";
var symbols = Array.Empty<SharedSpotSymbol>();
// act
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
var hasCached = ExchangeSymbolCache.HasCached(topicId);
// assert
Assert.That(hasCached, Is.False);
}
[Test]
public void HasCached_NonExistentTopic_Should_ReturnFalse()
{
// arrange
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
// act
var result = ExchangeSymbolCache.HasCached(nonExistentTopic);
// assert
Assert.That(result, Is.False);
}
[Test]
public void HasCached_ExistingTopicWithSymbols_Should_ReturnTrue()
{
// arrange
var topicId = "ExchangeWithData";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.HasCached(topicId);
// assert
Assert.That(result, Is.True);
}
[Test]
public void HasCached_ExistingTopicWithNoSymbols_Should_ReturnFalse()
{
// arrange
var topicId = "ExchangeNoData";
ExchangeSymbolCache.UpdateSymbolInfo(topicId, Array.Empty<SharedSpotSymbol>());
// act
var result = ExchangeSymbolCache.HasCached(topicId);
// assert
Assert.That(result, Is.False);
}
[Test]
public void SupportsSymbol_ByName_ExistingSymbol_Should_ReturnTrue()
{
// arrange
var topicId = "ExchangeSupports";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "BTCUSDT");
// assert
Assert.That(result, Is.True);
}
[Test]
public void SupportsSymbol_ByName_NonExistingSymbol_Should_ReturnFalse()
{
// arrange
var topicId = "ExchangeNoSupport";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "LINKUSDT");
// assert
Assert.That(result, Is.False);
}
[Test]
public void SupportsSymbol_ByName_NonExistentTopic_Should_ReturnFalse()
{
// arrange
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
// act
var result = ExchangeSymbolCache.SupportsSymbol(nonExistentTopic, "BTCUSDT");
// assert
Assert.That(result, Is.False);
}
[Test]
public void SupportsSymbol_BySharedSymbol_ExistingSymbol_Should_ReturnTrue()
{
// arrange
var topicId = "ExchangeSharedSymbol";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// act
var result = ExchangeSymbolCache.SupportsSymbol(topicId, sharedSymbol);
// assert
Assert.That(result, Is.True);
}
[Test]
public void SupportsSymbol_BySharedSymbol_NonExistingSymbol_Should_ReturnFalse()
{
// arrange
var topicId = "ExchangeNoSharedSymbol";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "LINK", "USDT");
// act
var result = ExchangeSymbolCache.SupportsSymbol(topicId, sharedSymbol);
// assert
Assert.That(result, Is.False);
}
[Test]
public void SupportsSymbol_BySharedSymbol_DifferentTradingMode_Should_ReturnFalse()
{
// arrange
var topicId = "ExchangeDifferentMode";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
var sharedSymbol = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
// act
var result = ExchangeSymbolCache.SupportsSymbol(topicId, sharedSymbol);
// assert
Assert.That(result, Is.False);
}
[Test]
public void SupportsSymbol_BySharedSymbol_NonExistentTopic_Should_ReturnFalse()
{
// arrange
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// act
var result = ExchangeSymbolCache.SupportsSymbol(nonExistentTopic, sharedSymbol);
// assert
Assert.That(result, Is.False);
}
[Test]
public void GetSymbolsForBaseAsset_ExistingBaseAsset_Should_ReturnMatchingSymbols()
{
// arrange
var topicId = "ExchangeBaseAsset";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "BTC");
// assert
Assert.That(result, Is.Not.Null);
Assert.That(result.Length, Is.EqualTo(2));
Assert.That(result.Any(x => x.QuoteAsset == "USDT"), Is.True);
Assert.That(result.Any(x => x.QuoteAsset == "EUR"), Is.True);
}
[Test]
public void GetSymbolsForBaseAsset_CaseInsensitive_Should_ReturnMatchingSymbols()
{
// arrange
var topicId = "ExchangeCaseInsensitive";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "btc");
// assert
Assert.That(result, Is.Not.Null);
Assert.That(result.Length, Is.EqualTo(2));
}
[Test]
public void GetSymbolsForBaseAsset_NonExistingBaseAsset_Should_ReturnEmptyArray()
{
// arrange
var topicId = "ExchangeNoBaseAsset";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "LINK");
// assert
Assert.That(result, Is.Not.Null);
Assert.That(result.Length, Is.EqualTo(0));
}
[Test]
public void GetSymbolsForBaseAsset_NonExistentTopic_Should_ReturnEmptyArray()
{
// arrange
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
// act
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(nonExistentTopic, "BTC");
// assert
Assert.That(result, Is.Not.Null);
Assert.That(result.Length, Is.EqualTo(0));
}
[Test]
public void ParseSymbol_ExistingSymbol_Should_ReturnSharedSymbol()
{
// arrange
var topicId = "ExchangeParse";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.ParseSymbol(topicId, "BTCUSDT");
// assert
Assert.That(result, Is.Not.Null);
Assert.That(result.BaseAsset, Is.EqualTo("BTC"));
Assert.That(result.QuoteAsset, Is.EqualTo("USDT"));
Assert.That(result.TradingMode, Is.EqualTo(TradingMode.Spot));
Assert.That(result.SymbolName, Is.EqualTo("BTCUSDT"));
}
[Test]
public void ParseSymbol_NonExistingSymbol_Should_ReturnNull()
{
// arrange
var topicId = "ExchangeNoParse";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.ParseSymbol(topicId, "LINKUSDT");
// assert
Assert.That(result, Is.Null);
}
[Test]
public void ParseSymbol_NullSymbolName_Should_ReturnNull()
{
// arrange
var topicId = "ExchangeNullSymbol";
var symbols = CreateTestSymbols();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.ParseSymbol(topicId, null);
// assert
Assert.That(result, Is.Null);
}
[Test]
public void ParseSymbol_NonExistentTopic_Should_ReturnNull()
{
// arrange
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
// act
var result = ExchangeSymbolCache.ParseSymbol(nonExistentTopic, "BTCUSDT");
// assert
Assert.That(result, Is.Null);
}
[Test]
public void MultipleTopics_Should_MaintainSeparateData()
{
// arrange
var topic1 = "Exchange1";
var topic2 = "Exchange2";
var symbols1 = new[]
{
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot)
};
var symbols2 = new[]
{
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot)
};
// act
ExchangeSymbolCache.UpdateSymbolInfo(topic1, symbols1);
ExchangeSymbolCache.UpdateSymbolInfo(topic2, symbols2);
// assert
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic1, "BTCUSDT"), Is.True);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic1, "ETHUSDT"), Is.False);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic2, "ETHUSDT"), Is.True);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic2, "BTCUSDT"), Is.False);
}
[Test]
public void UpdateSymbolInfo_WithDifferentTradingModes_Should_StoreCorrectly()
{
// arrange
var topicId = "ExchangeMixedModes";
var spotSymbols = CreateTestSymbols();
var futuresSymbols = CreateFuturesSymbols();
var allSymbols = spotSymbols.Concat(futuresSymbols).ToArray();
// act
ExchangeSymbolCache.UpdateSymbolInfo(topicId, allSymbols);
// assert
var spotSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var futuresSymbol = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, spotSymbol), Is.True);
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, futuresSymbol), Is.True);
}
[Test]
public void GetSymbolsForBaseAsset_Should_ReturnAllTradingModes()
{
// arrange
var topicId = "ExchangeAllModes";
var spotSymbols = CreateTestSymbols();
var futuresSymbols = CreateFuturesSymbols();
var allSymbols = spotSymbols.Concat(futuresSymbols).ToArray();
ExchangeSymbolCache.UpdateSymbolInfo(topicId, allSymbols);
// act
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "BTC");
// assert
Assert.That(result.Length, Is.GreaterThanOrEqualTo(2));
Assert.That(result.Any(x => x.TradingMode == TradingMode.Spot), Is.True);
Assert.That(result.Any(x => x.TradingMode == TradingMode.PerpetualLinear), Is.True);
}
[Test]
public void GetSymbolsForBaseAsset_WithMultipleMatchingSymbols_Should_ReturnAll()
{
// arrange
var topicId = "ExchangeMultiple";
var symbols = new[]
{
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot),
new SharedSpotSymbol("ETH", "BTC", "ETHBTC", true, TradingMode.Spot),
new SharedSpotSymbol("ETH", "EUR", "ETHEUR", true, TradingMode.Spot)
};
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
// act
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "ETH");
// assert
Assert.That(result.Length, Is.EqualTo(3));
Assert.That(result.All(x => x.BaseAsset == "ETH"), Is.True);
}
}
}
@@ -81,6 +81,25 @@ namespace CryptoExchange.Net.UnitTests
Assert.That(result.Error is ServerError);
}
[TestCase]
public async Task ReceivingErrorAndNotParsingErrorAndInvalidJson_Should_ContainData()
{
// arrange
var client = new TestRestClient();
var response = "<html>...</html>";
client.SetErrorWithResponse(response, System.Net.HttpStatusCode.BadRequest);
// act
var result = await client.Api1.Request<TestObject>();
// assert
ClassicAssert.IsFalse(result.Success);
Assert.That(result.Error != null);
Assert.That(result.Error is DeserializeError);
Assert.That(result.Error.Message.Contains(response));
}
[TestCase]
public async Task ReceivingErrorAndParsingError_Should_ResultInParsedError()
{
@@ -0,0 +1,633 @@
using CryptoExchange.Net.SharedApis;
using NUnit.Framework;
using System;
namespace CryptoExchange.Net.UnitTests
{
[TestFixture()]
public class SharedQuantityTests
{
[Test]
public void SharedQuantityReference_IsZero_AllNull_Should_ReturnTrue()
{
// arrange
var quantity = new SharedOrderQuantity(null, null, null);
// act & assert
Assert.That(quantity.IsZero, Is.True);
}
[Test]
public void SharedQuantityReference_IsZero_AllZero_Should_ReturnTrue()
{
// arrange
var quantity = new SharedOrderQuantity(0, 0, 0);
// act & assert
Assert.That(quantity.IsZero, Is.True);
}
[Test]
public void SharedQuantityReference_IsZero_BaseAssetSet_Should_ReturnFalse()
{
// arrange
var quantity = new SharedOrderQuantity(1.5m, null, null);
// act & assert
Assert.That(quantity.IsZero, Is.False);
}
[Test]
public void SharedQuantityReference_IsZero_QuoteAssetSet_Should_ReturnFalse()
{
// arrange
var quantity = new SharedOrderQuantity(null, 100m, null);
// act & assert
Assert.That(quantity.IsZero, Is.False);
}
[Test]
public void SharedQuantityReference_IsZero_ContractsSet_Should_ReturnFalse()
{
// arrange
var quantity = new SharedOrderQuantity(null, null, 10m);
// act & assert
Assert.That(quantity.IsZero, Is.False);
}
[Test]
public void SharedQuantityReference_IsZero_NegativeValue_Should_ReturnTrue()
{
// arrange
var quantity = new SharedOrderQuantity(-1m, 0, 0);
// act & assert
Assert.That(quantity.IsZero, Is.True);
}
[Test]
public void SharedQuantity_DefaultConstructor_Should_SetAllPropertiesToNull()
{
// arrange & act
var quantity = new SharedQuantity();
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
Assert.That(quantity.IsZero, Is.True);
}
[Test]
public void SharedQuantity_Base_Should_SetBaseAssetQuantity()
{
// arrange
var expectedQuantity = 1.5m;
// act
var quantity = SharedQuantity.Base(expectedQuantity);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedQuantity));
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedQuantity_Base_WithZero_Should_SetZeroQuantity()
{
// arrange & act
var quantity = SharedQuantity.Base(0m);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(0m));
Assert.That(quantity.IsZero, Is.True);
}
[Test]
public void SharedQuantity_Base_WithLargeValue_Should_SetCorrectly()
{
// arrange
var largeValue = 999999.123456789m;
// act
var quantity = SharedQuantity.Base(largeValue);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(largeValue));
}
[Test]
public void SharedQuantity_Quote_Should_SetQuoteAssetQuantity()
{
// arrange
var expectedQuantity = 100m;
// act
var quantity = SharedQuantity.Quote(expectedQuantity);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(expectedQuantity));
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedQuantity_Quote_WithDecimal_Should_PreserveDecimals()
{
// arrange
var expectedQuantity = 50.123456m;
// act
var quantity = SharedQuantity.Quote(expectedQuantity);
// assert
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(expectedQuantity));
}
[Test]
public void SharedQuantity_Contracts_Should_SetContractQuantity()
{
// arrange
var expectedQuantity = 10m;
// act
var quantity = SharedQuantity.Contracts(expectedQuantity);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.EqualTo(expectedQuantity));
}
[Test]
public void SharedQuantity_Contracts_WithFractionalValue_Should_SetCorrectly()
{
// arrange
var expectedQuantity = 2.5m;
// act
var quantity = SharedQuantity.Contracts(expectedQuantity);
// assert
Assert.That(quantity.QuantityInContracts, Is.EqualTo(expectedQuantity));
}
[Test]
public void SharedQuantity_BaseFromQuote_Should_CalculateCorrectly()
{
// arrange
var quoteQuantity = 100m;
var price = 50m;
var expectedBase = 2m; // 100 / 50 = 2
// act
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedBase));
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedQuantity_BaseFromQuote_WithCustomDecimals_Should_RoundCorrectly()
{
// arrange
var quoteQuantity = 100m;
var price = 3m;
var decimalPlaces = 2;
// act
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, decimalPlaces);
// assert
// 100 / 3 = 33.333... should round to 33.33
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(33.33m));
}
[Test]
public void SharedQuantity_BaseFromQuote_WithLotSize_Should_AdjustToLotSize()
{
// arrange
var quoteQuantity = 100m;
var price = 7m;
var lotSize = 0.1m;
// act
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, 8, lotSize);
// assert
// 100 / 7 = 14.285714... should adjust to nearest 0.1 = 14.3
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(14.3m));
}
[Test]
public void SharedQuantity_BaseFromQuote_WithHighPrecision_Should_HandleCorrectly()
{
// arrange
var quoteQuantity = 1000m;
var price = 0.00001m;
var decimalPlaces = 8;
// act
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, decimalPlaces);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
}
[Test]
public void SharedQuantity_QuoteFromBase_Should_CalculateCorrectly()
{
// arrange
var baseQuantity = 2m;
var price = 50m;
var expectedQuote = 100m; // 2 * 50 = 100
// act
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedQuote));
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedQuantity_QuoteFromBase_WithCustomDecimals_Should_RoundCorrectly()
{
// arrange
var baseQuantity = 1.234567m;
var price = 10m;
var decimalPlaces = 2;
// act
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, decimalPlaces);
// assert
// 1.234567 * 10 = 12.34567 should round to 12.35
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(12.35m));
}
[Test]
public void SharedQuantity_QuoteFromBase_WithLotSize_Should_AdjustToLotSize()
{
// arrange
var baseQuantity = 3.456m;
var price = 10m;
var lotSize = 1m;
// act
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, 8, lotSize);
// assert
// 3.456 * 10 = 34.56 should adjust to nearest 1 = 35
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(35m));
}
[Test]
public void SharedQuantity_QuoteFromBase_WithSmallValues_Should_HandleCorrectly()
{
// arrange
var baseQuantity = 0.001m;
var price = 0.1m;
var decimalPlaces = 8;
// act
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, decimalPlaces);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(0.0001m));
}
[Test]
public void SharedQuantity_ContractsFromBase_Should_CalculateCorrectly()
{
// arrange
var baseQuantity = 100m;
var contractSize = 10m;
var expectedContracts = 10m; // 100 / 10 = 10
// act
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedContracts));
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedQuantity_ContractsFromBase_WithCustomDecimals_Should_RoundCorrectly()
{
// arrange
var baseQuantity = 100m;
var contractSize = 3m;
var decimalPlaces = 2;
// act
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize, decimalPlaces);
// assert
// 100 / 3 = 33.333... should round to 33.33
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(33.33m));
}
[Test]
public void SharedQuantity_ContractsFromBase_WithLotSize_Should_AdjustToLotSize()
{
// arrange
var baseQuantity = 100m;
var contractSize = 7m;
var lotSize = 0.5m;
// act
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize, 8, lotSize);
// assert
// 100 / 7 = 14.285714... should adjust to nearest 0.5 = 14.5
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(14.5m));
}
[Test]
public void SharedQuantity_ContractsFromBase_WithFractionalContract_Should_HandleCorrectly()
{
// arrange
var baseQuantity = 1m;
var contractSize = 0.1m;
// act
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(10m));
}
[Test]
public void SharedQuantity_ContractsFromQuote_Should_CalculateCorrectly()
{
// arrange
var quoteQuantity = 1000m;
var contractSize = 10m;
var price = 50m;
var expectedContracts = 2m; // 1000 / 50 / 10 = 2
// act
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedContracts));
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedQuantity_ContractsFromQuote_WithCustomDecimals_Should_RoundCorrectly()
{
// arrange
var quoteQuantity = 100m;
var contractSize = 3m;
var price = 7m;
var decimalPlaces = 2;
// act
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, decimalPlaces);
// assert
// 100 / 7 / 3 = 4.761904... should round to 4.76
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(4.76m));
}
[Test]
public void SharedQuantity_ContractsFromQuote_WithLotSize_Should_AdjustToLotSize()
{
// arrange
var quoteQuantity = 1000m;
var contractSize = 7m;
var price = 13m;
var lotSize = 0.5m;
// act
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, 8, lotSize);
// assert
// 1000 / 13 / 7 = 10.989... should adjust to nearest 0.5 = 11.0
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(11.0m));
}
[Test]
public void SharedQuantity_ContractsFromQuote_WithComplexValues_Should_CalculateCorrectly()
{
// arrange
var quoteQuantity = 5000m;
var contractSize = 0.01m;
var price = 25000m;
var decimalPlaces = 4;
// act
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, decimalPlaces);
// assert
// 5000 / 25000 / 0.01 = 20
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(20m));
}
[Test]
public void SharedOrderQuantity_DefaultConstructor_Should_SetAllPropertiesToNull()
{
// arrange & act
var quantity = new SharedOrderQuantity();
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
Assert.That(quantity.IsZero, Is.True);
}
[Test]
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetBaseAsset()
{
// arrange
var baseAsset = 5m;
// act
var quantity = new SharedOrderQuantity(baseAssetQuantity: baseAsset);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(baseAsset));
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetQuoteAsset()
{
// arrange
var quoteAsset = 100m;
// act
var quantity = new SharedOrderQuantity(quoteAssetQuantity: quoteAsset);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(quoteAsset));
Assert.That(quantity.QuantityInContracts, Is.Null);
}
[Test]
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetContracts()
{
// arrange
var contracts = 10m;
// act
var quantity = new SharedOrderQuantity(contractQuantity: contracts);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.EqualTo(contracts));
}
[Test]
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetAllValues()
{
// arrange
var baseAsset = 1m;
var quoteAsset = 50m;
var contracts = 5m;
// act
var quantity = new SharedOrderQuantity(baseAsset, quoteAsset, contracts);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(baseAsset));
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(quoteAsset));
Assert.That(quantity.QuantityInContracts, Is.EqualTo(contracts));
Assert.That(quantity.IsZero, Is.False);
}
[Test]
public void SharedOrderQuantity_ParameterizedConstructor_WithNullValues_Should_HandleCorrectly()
{
// arrange & act
var quantity = new SharedOrderQuantity(null, null, null);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
Assert.That(quantity.QuantityInContracts, Is.Null);
Assert.That(quantity.IsZero, Is.True);
}
[Test]
public void SharedQuantity_RecordEquality_SameValues_Should_BeEqual()
{
// arrange
var quantity1 = SharedQuantity.Base(10m);
var quantity2 = SharedQuantity.Base(10m);
// act & assert
Assert.That(quantity1, Is.EqualTo(quantity2));
}
[Test]
public void SharedQuantity_RecordEquality_DifferentValues_Should_NotBeEqual()
{
// arrange
var quantity1 = SharedQuantity.Base(10m);
var quantity2 = SharedQuantity.Base(20m);
// act & assert
Assert.That(quantity1, Is.Not.EqualTo(quantity2));
}
[Test]
public void SharedQuantity_RecordEquality_DifferentTypes_Should_NotBeEqual()
{
// arrange
var quantity1 = SharedQuantity.Base(10m);
var quantity2 = SharedQuantity.Quote(10m);
// act & assert
Assert.That(quantity1, Is.Not.EqualTo(quantity2));
}
[Test]
public void SharedOrderQuantity_RecordEquality_SameValues_Should_BeEqual()
{
// arrange
var quantity1 = new SharedOrderQuantity(5m, 100m, 2m);
var quantity2 = new SharedOrderQuantity(5m, 100m, 2m);
// act & assert
Assert.That(quantity1, Is.EqualTo(quantity2));
}
[Test]
public void SharedQuantity_BaseFromQuote_WithDefaultParameters_Should_UseDefaults()
{
// arrange
var quoteQuantity = 100m;
var price = 3m;
// act
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price);
// assert
// Default decimalPlaces = 8, default lotSize = 0.00000001
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
}
[Test]
public void SharedQuantity_QuoteFromBase_WithDefaultParameters_Should_UseDefaults()
{
// arrange
var baseQuantity = 1.234567m;
var price = 10m;
// act
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
}
[Test]
public void SharedQuantity_ContractsFromBase_WithDefaultParameters_Should_UseDefaults()
{
// arrange
var baseQuantity = 100m;
var contractSize = 3m;
// act
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
}
[Test]
public void SharedQuantity_ContractsFromQuote_WithDefaultParameters_Should_UseDefaults()
{
// arrange
var quoteQuantity = 1000m;
var contractSize = 10m;
var price = 50m;
// act
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price);
// assert
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
}
}
}
@@ -0,0 +1,400 @@
using CryptoExchange.Net.SharedApis;
using NUnit.Framework;
using System;
namespace CryptoExchange.Net.UnitTests
{
[TestFixture()]
public class SharedSymbolTests
{
[Test]
public void SharedSymbol_Constructor_Should_SetAllProperties()
{
// arrange
var tradingMode = TradingMode.Spot;
var baseAsset = "BTC";
var quoteAsset = "USDT";
// act
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset);
// assert
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
Assert.That(symbol.DeliverTime, Is.Null);
Assert.That(symbol.SymbolName, Is.Null);
}
[Test]
public void SharedSymbol_Constructor_WithDeliveryTime_Should_SetDeliveryTime()
{
// arrange
var tradingMode = TradingMode.DeliveryLinear;
var baseAsset = "BTC";
var quoteAsset = "USDT";
var deliveryTime = new DateTime(2026, 6, 25, 0, 0, 0, DateTimeKind.Utc);
// act
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, deliveryTime);
// assert
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
Assert.That(symbol.DeliverTime, Is.EqualTo(deliveryTime));
Assert.That(symbol.SymbolName, Is.Null);
}
[Test]
public void SharedSymbol_Constructor_WithNullDeliveryTime_Should_SetToNull()
{
// arrange
var tradingMode = TradingMode.Spot;
var baseAsset = "ETH";
var quoteAsset = "BTC";
// act
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, deliverTime: null);
// assert
Assert.That(symbol.DeliverTime, Is.Null);
}
[TestCase(TradingMode.Spot)]
[TestCase(TradingMode.PerpetualLinear)]
[TestCase(TradingMode.PerpetualInverse)]
[TestCase(TradingMode.DeliveryLinear)]
[TestCase(TradingMode.DeliveryInverse)]
public void SharedSymbol_Constructor_WithDifferentTradingModes_Should_SetCorrectly(TradingMode tradingMode)
{
// arrange
var baseAsset = "BTC";
var quoteAsset = "USDT";
// act
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset);
// assert
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
}
[Test]
public void SharedSymbol_ConstructorWithSymbolName_Should_SetSymbolName()
{
// arrange
var tradingMode = TradingMode.Spot;
var baseAsset = "BTC";
var quoteAsset = "USDT";
var symbolName = "BTC-USDT";
// act
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
// assert
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
Assert.That(symbol.SymbolName, Is.EqualTo(symbolName));
Assert.That(symbol.DeliverTime, Is.Null);
}
[Test]
public void SharedSymbol_ConstructorWithSymbolName_WithCustomFormat_Should_SetCorrectly()
{
// arrange
var tradingMode = TradingMode.PerpetualLinear;
var baseAsset = "ETH";
var quoteAsset = "USDT";
var symbolName = "ETHUSDT-PERP";
// act
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
// assert
Assert.That(symbol.SymbolName, Is.EqualTo(symbolName));
}
[Test]
public void SharedSymbol_ConstructorWithSymbolName_WithEmptyString_Should_SetEmptyString()
{
// arrange
var tradingMode = TradingMode.Spot;
var baseAsset = "BTC";
var quoteAsset = "USDT";
var symbolName = "";
// act
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
// assert
Assert.That(symbol.SymbolName, Is.EqualTo(""));
}
[Test]
public void GetSymbol_WithSymbolNameSet_Should_ReturnSymbolName()
{
// arrange
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "CUSTOM-BTC-USDT");
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
(b, q, t, d) => $"{b}{q}");
// act
var result = symbol.GetSymbol(formatFunc);
// assert
Assert.That(result, Is.EqualTo("CUSTOM-BTC-USDT"));
}
[Test]
public void GetSymbol_WithSymbolNameNull_Should_UseFormatFunction()
{
// arrange
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
(b, q, t, d) => $"{b}/{q}");
// act
var result = symbol.GetSymbol(formatFunc);
// assert
Assert.That(result, Is.EqualTo("BTC/USDT"));
}
[Test]
public void GetSymbol_WithComplexFormatFunction_Should_ApplyCorrectly()
{
// arrange
var symbol = new SharedSymbol(TradingMode.PerpetualLinear, "ETH", "USDT");
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
(b, q, t, d) => t == TradingMode.PerpetualLinear ? $"{b}{q}-PERP" : $"{b}{q}");
// act
var result = symbol.GetSymbol(formatFunc);
// assert
Assert.That(result, Is.EqualTo("ETHUSDT-PERP"));
}
[Test]
public void GetSymbol_WithDeliveryTime_Should_PassDeliveryTimeToFormatter()
{
// arrange
var deliveryTime = new DateTime(2026, 6, 25);
var symbol = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime);
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
(b, q, t, d) => d.HasValue ? $"{b}{q}_{d.Value:yyyyMMdd}" : $"{b}{q}");
// act
var result = symbol.GetSymbol(formatFunc);
// assert
Assert.That(result, Is.EqualTo("BTCUSDT_20260625"));
}
[Test]
public void GetSymbol_WithTradingMode_Should_PassTradingModeToFormatter()
{
// arrange
var symbol = new SharedSymbol(TradingMode.PerpetualInverse, "BTC", "USD");
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
(b, q, t, d) =>
{
return t switch
{
TradingMode.Spot => $"{b}{q}",
TradingMode.PerpetualLinear => $"{b}{q}-PERP",
TradingMode.PerpetualInverse => $"{b}{q}I-PERP",
_ => $"{b}{q}"
};
});
// act
var result = symbol.GetSymbol(formatFunc);
// assert
Assert.That(result, Is.EqualTo("BTCUSDI-PERP"));
}
[Test]
public void GetSymbol_WithEmptySymbolName_Should_UseFormatFunction()
{
// arrange
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "");
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
(b, q, t, d) => $"{b}-{q}");
// act
var result = symbol.GetSymbol(formatFunc);
// assert
Assert.That(result, Is.EqualTo("BTC-USDT"));
}
[Test]
public void GetSymbol_WithWhitespaceSymbolName_Should_ReturnWhitespace()
{
// arrange
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", " ");
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
(b, q, t, d) => $"{b}-{q}");
// act
var result = symbol.GetSymbol(formatFunc);
// assert
Assert.That(result, Is.EqualTo(" "));
}
[Test]
public void SharedSymbol_RecordEquality_SameValues_Should_BeEqual()
{
// arrange
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// act & assert
Assert.That(symbol1, Is.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_RecordEquality_DifferentBaseAsset_Should_NotBeEqual()
{
// arrange
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var symbol2 = new SharedSymbol(TradingMode.Spot, "ETH", "USDT");
// act & assert
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_RecordEquality_DifferentQuoteAsset_Should_NotBeEqual()
{
// arrange
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "EUR");
// act & assert
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_RecordEquality_DifferentTradingMode_Should_NotBeEqual()
{
// arrange
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
var symbol2 = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
// act & assert
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_RecordEquality_DifferentDeliveryTime_Should_NotBeEqual()
{
// arrange
var deliveryTime1 = new DateTime(2026, 6, 25);
var deliveryTime2 = new DateTime(2026, 9, 25);
var symbol1 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime1);
var symbol2 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime2);
// act & assert
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_RecordEquality_DifferentSymbolName_Should_NotBeEqual()
{
// arrange
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTCUSDT");
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTC-USDT");
// act & assert
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_RecordEquality_OneWithSymbolNameOneWithout_Should_NotBeEqual()
{
// NOTE; although this should probably be equal it's considered not because the SymbolName property isn't equal
// Overridding equality to ignore SymbolName would be possible but would break the default record equality behavior and cause confusion
// arrange
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTCUSDT");
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// act & assert
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_RecordEquality_WithAllPropertiesSet_Should_BeEqual()
{
// arrange
var deliveryTime = new DateTime(2026, 6, 25);
var symbol1 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime)
{
SymbolName = "BTCUSDT-0625"
};
var symbol2 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime)
{
SymbolName = "BTCUSDT-0625"
};
// act & assert
Assert.That(symbol1, Is.EqualTo(symbol2));
}
[Test]
public void SharedSymbol_Properties_Should_BeSettable()
{
// arrange
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
// act
symbol.BaseAsset = "ETH";
symbol.QuoteAsset = "EUR";
symbol.TradingMode = TradingMode.PerpetualLinear;
symbol.SymbolName = "CUSTOM";
symbol.DeliverTime = DateTime.UtcNow;
// assert
Assert.That(symbol.BaseAsset, Is.EqualTo("ETH"));
Assert.That(symbol.QuoteAsset, Is.EqualTo("EUR"));
Assert.That(symbol.TradingMode, Is.EqualTo(TradingMode.PerpetualLinear));
Assert.That(symbol.SymbolName, Is.EqualTo("CUSTOM"));
Assert.That(symbol.DeliverTime, Is.Not.Null);
}
[Test]
public void SharedSymbol_WithSpecialCharactersInAssets_Should_HandleCorrectly()
{
// arrange
var baseAsset = "BTC-123";
var quoteAsset = "USDT_2.0";
// act
var symbol = new SharedSymbol(TradingMode.Spot, baseAsset, quoteAsset);
// assert
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
}
[Test]
public void SharedSymbol_WithLongAssetNames_Should_HandleCorrectly()
{
// arrange
var baseAsset = "VERYLONGASSETNAMEFORTESTING";
var quoteAsset = "ANOTHERVERYLONGASSETNAME";
// act
var symbol = new SharedSymbol(TradingMode.Spot, baseAsset, quoteAsset);
// assert
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
}
}
}
@@ -56,7 +56,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
var request = new Mock<IRequest>();
request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com"));
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Returns(Task.FromResult(response.Object));
request.Setup(c => c.SetContent(It.IsAny<string>(), It.IsAny<string>())).Callback(new Action<string, string>((content, type) => { request.Setup(r => r.Content).Returns(content); }));
request.Setup(c => c.SetContent(It.IsAny<string>(), It.IsAny<Encoding>(), It.IsAny<string>())).Callback(new Action<string, Encoding, string>((content, encoding, type) => { request.Setup(r => r.Content).Returns(content); }));
request.Setup(c => c.AddHeader(It.IsAny<string>(), It.IsAny<string>())).Callback<string, string>((key, val) => headers.Add(key, new string[] { val }));
request.Setup(c => c.GetHeaders()).Returns(() => headers);
@@ -19,11 +19,14 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
private ErrorMapping _errorMapping = new ErrorMapping([]);
public override JsonSerializerOptions Options => new JsonSerializerOptions();
public override ValueTask<Error> ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream)
public override async ValueTask<Error> ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream)
{
var errorData = JsonSerializer.Deserialize<TestError>(responseStream);
var result = await GetJsonDocument(responseStream).ConfigureAwait(false);
if (result.Item1 != null)
return result.Item1;
return new ValueTask<Error>(new ServerError(errorData.ErrorCode, _errorMapping.GetErrorInfo(errorData.ErrorCode.ToString(), errorData.ErrorMessage)));
var errorData = result.Item2.Deserialize<TestError>();
return new ServerError(errorData.ErrorCode, _errorMapping.GetErrorInfo(errorData.ErrorCode.ToString(), errorData.ErrorMessage));
}
}
}
+41 -23
View File
@@ -77,6 +77,16 @@ namespace CryptoExchange.Net.Clients
{ new HttpMethod("Patch"), HttpMethodParameterPosition.InBody },
};
/// <summary>
/// Encoding/charset for the ContentType header
/// </summary>
protected Encoding? RequestBodyContentEncoding { get; set; } = Encoding.UTF8;
/// <summary>
/// Whether to omit the ContentType header if there is no content
/// </summary>
protected bool OmitContentTypeHeaderWithoutContent { get; set; } = false;
/// <inheritdoc />
public new RestExchangeOptions ClientOptions => (RestExchangeOptions)base.ClientOptions;
@@ -374,7 +384,11 @@ namespace CryptoExchange.Net.Clients
if (!string.IsNullOrEmpty(queryString) && !queryString.StartsWith("?"))
queryString = $"?{queryString}";
var uri = new Uri(baseAddress.AppendPath(definition.Path) + queryString);
var path = baseAddress.AppendPath(definition.Path);
if (definition.ForcePathEndWithSlash == true && !path.EndsWith("/"))
path += "/";
var uri = new Uri(path + queryString);
var request = RequestFactory.Create(ClientOptions.HttpVersion, definition.Method, uri, requestId);
request.Accept = MessageHandler.AcceptHeader;
@@ -398,14 +412,14 @@ namespace CryptoExchange.Net.Clients
var bodyContent = requestConfiguration.GetBodyContent();
if (bodyContent != null)
{
request.SetContent(bodyContent, contentType);
request.SetContent(bodyContent, RequestBodyContentEncoding, contentType);
}
else
{
if (requestConfiguration.BodyParameters != null && requestConfiguration.BodyParameters.Count != 0)
WriteParamBody(request, requestConfiguration.BodyParameters, contentType);
else
request.SetContent(RequestBodyEmptyContent, contentType);
else if (OmitContentTypeHeaderWithoutContent != true)
request.SetContent(RequestBodyEmptyContent, RequestBodyContentEncoding, contentType);
}
}
@@ -437,23 +451,19 @@ namespace CryptoExchange.Net.Clients
responseStream = await response.GetResponseStreamAsync(cancellationToken).ConfigureAwait(false);
string? originalData = null;
var outputOriginalData = ApiOptions.OutputOriginalData ?? ClientOptions.OutputOriginalData;
if (outputOriginalData || MessageHandler.RequiresSeekableStream)
if (outputOriginalData || MessageHandler.RequiresSeekableStream || !response.IsSuccessStatusCode)
{
// If we want to return the original string data from the stream, but still want to process it
// we'll need to copy it as the stream isn't seekable, and thus we can only read it once
var memoryStream = new MemoryStream();
await responseStream.CopyToAsync(memoryStream).ConfigureAwait(false);
using var reader = new StreamReader(memoryStream, Encoding.UTF8, false, 4096, true);
if (outputOriginalData)
// Create a seekable stream from the response stream if:
// 1. We need to output the original data
// 2. The message handler requires a seekable stream
// 3. The response indicates error and we want to output (part of) the returned data
responseStream = await CopyStreamAsync(responseStream).ConfigureAwait(false);
using var reader = new StreamReader(responseStream, Encoding.UTF8, false, 4096, true);
if (outputOriginalData)
{
memoryStream.Position = 0;
originalData = await reader.ReadToEndAsync().ConfigureAwait(false);
responseStream.Position = 0;
}
// Continue processing from the memory stream since the response stream is already read and we can't seek it
responseStream.Close();
memoryStream.Position = 0;
responseStream = memoryStream;
}
if (!response.IsSuccessStatusCode && !requestDefinition.TryParseOnNonSuccess)
@@ -479,13 +489,12 @@ namespace CryptoExchange.Net.Clients
else
{
// Handle a 'normal' error response. Can still be either a json error message or some random HTML or other string
try
{
error = await MessageHandler.ParseErrorResponse(
(int)response.StatusCode,
response.ResponseHeaders,
responseStream).ConfigureAwait(false);
(int)response.StatusCode,
response.ResponseHeaders,
responseStream).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -651,13 +660,13 @@ namespace CryptoExchange.Net.Clients
stringData = stringSerializer.Serialize(value);
else
stringData = stringSerializer.Serialize(parameters);
request.SetContent(stringData, contentType);
request.SetContent(stringData, RequestBodyContentEncoding, contentType);
}
else if (contentType == Constants.FormContentHeader)
{
// Write the parameters as form data in the body
var stringData = parameters.ToFormData();
request.SetContent(stringData, contentType);
request.SetContent(stringData, RequestBodyContentEncoding, contentType);
}
}
@@ -769,6 +778,15 @@ namespace CryptoExchange.Net.Clients
}
}
private async Task<Stream> CopyStreamAsync(Stream responseStream)
{
var memoryStream = new MemoryStream();
await responseStream.CopyToAsync(memoryStream).ConfigureAwait(false);
responseStream.Close();
memoryStream.Position = 0;
return memoryStream;
}
private bool ShouldCache(RequestDefinition definition)
=> ClientOptions.CachingEnabled
&& definition.Method == HttpMethod.Get
+3 -49
View File
@@ -304,55 +304,9 @@ namespace CryptoExchange.Net.Clients
return new CallResult<UpdateSubscription>(new ServerError(new ErrorInfo(ErrorType.WebsocketPaused, "Socket is paused")));
}
void HandleSubscriptionComplete(bool success, object? response)
{
if (!success)
return;
subscription.HandleSubQueryResponse(socketConnection, response);
subscription.Status = SubscriptionStatus.Subscribed;
if (ct != default)
{
subscription.CancellationTokenRegistration = ct.Register(async () =>
{
_logger.CancellationTokenSetClosingSubscription(socketConnection.SocketId, subscription.Id);
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
}, false);
}
}
subscription.Status = SubscriptionStatus.Subscribing;
var subQuery = subscription.CreateSubscriptionQuery(socketConnection);
if (subQuery != null)
{
subQuery.OnComplete = () => HandleSubscriptionComplete(subQuery.Result?.Success ?? false, subQuery.Response);
// Send the request and wait for answer
var subResult = await socketConnection.SendAndWaitQueryAsync(subQuery, ct).ConfigureAwait(false);
if (!subResult)
{
var isTimeout = subResult.Error is CancellationRequestedError;
if (isTimeout && subscription.Status == SubscriptionStatus.Subscribed)
{
// No response received, but the subscription did receive updates. We'll assume success
}
else
{
_logger.FailedToSubscribe(socketConnection.SocketId, subResult.Error?.ToString());
// If this was a server process error we still might need to send an unsubscribe to prevent messages coming in later
subscription.Status = SubscriptionStatus.Pending;
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
return new CallResult<UpdateSubscription>(subResult.Error!);
}
}
if (!subQuery.ExpectsResponse)
HandleSubscriptionComplete(true, null);
}
else
{
HandleSubscriptionComplete(true, null);
}
var subscribeResult = await socketConnection.TrySubscribeAsync(subscription, true, ct).ConfigureAwait(false);
if (!subscribeResult)
return new CallResult<UpdateSubscription>(subscribeResult.Error!);
_logger.SubscriptionCompletedSuccessfully(socketConnection.SocketId, subscription.Id);
return new CallResult<UpdateSubscription>(new UpdateSubscription(socketConnection, subscription));
@@ -168,7 +168,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
if (!_unknownValuesWarned.Contains(stringValue))
{
_unknownValuesWarned.Add(stringValue!);
LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {enumType.FullName}, Value: {stringValue}, Known values: {string.Join(", ", _mappingToEnum!.Select(m => m.Value))}. If you think {stringValue} should added please open an issue on the Github repo");
LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should added please open an issue on the Github repo");
}
}
@@ -246,6 +246,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
{
// If no explicit mapping is found try to parse string
result = (T)Enum.Parse(objectType, value, true);
if (!Enum.IsDefined(objectType, result))
{
result = default;
return false;
}
return true;
}
catch (Exception)
@@ -18,6 +18,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers
public abstract class JsonRestMessageHandler : IRestMessageHandler
{
private static MediaTypeWithQualityHeaderValue _acceptJsonContent = new MediaTypeWithQualityHeaderValue(Constants.JsonContentHeader);
private const int _errorResponseSnippetLimit = 128;
/// <summary>
/// Empty rate limit error
@@ -80,7 +81,20 @@ namespace CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers
}
catch (Exception ex)
{
return (new ServerError(new ErrorInfo(ErrorType.DeserializationFailed, false, "Deserialization failed, invalid JSON"), ex), null);
var errorMsg = "Deserialization failed, invalid JSON";
if (stream.CanSeek)
{
var dataSnippet = new char[_errorResponseSnippetLimit];
stream.Seek(0, SeekOrigin.Begin);
var written = new StreamReader(stream).ReadBlock(dataSnippet, 0, _errorResponseSnippetLimit);
var data = new string(dataSnippet, 0, written);
errorMsg += $": {data}";
if (data.Length == _errorResponseSnippetLimit)
errorMsg += " (truncated)";
}
var error = new DeserializeError(errorMsg, ex);
return (error, null);
}
}
@@ -165,6 +165,14 @@ namespace CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers
return null;
}
/// <summary>
/// Return type identifier for non-json messages
/// </summary>
protected virtual string? GetTypeIdentifierNonJson(ReadOnlySpan<byte> data, WebSocketMessageType? webSocketMessageType)
{
return null;
}
/// <inheritdoc />
public virtual string? GetTypeIdentifier(ReadOnlySpan<byte> data, WebSocketMessageType? webSocketMessageType)
{
@@ -173,6 +181,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers
int? arrayIndex = null;
_searchResult.Clear();
if (data[0] != 0x5B && data[0] != 0x7B)
{
// Message doesn't start with `{` or `[`, not valid for processing as json
return GetTypeIdentifierNonJson(data, webSocketMessageType);
}
var reader = new Utf8JsonReader(data);
while (reader.Read())
{
+3 -3
View File
@@ -6,9 +6,9 @@
<PackageId>CryptoExchange.Net</PackageId>
<Authors>JKorf</Authors>
<Description>CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations.</Description>
<PackageVersion>10.4.1</PackageVersion>
<AssemblyVersion>10.4.1</AssemblyVersion>
<FileVersion>10.4.1</FileVersion>
<PackageVersion>10.8.0</PackageVersion>
<AssemblyVersion>10.8.0</AssemblyVersion>
<FileVersion>10.8.0</FileVersion>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageTags>OKX;OKX.Net;Mexc;Mexc.Net;Kucoin;Kucoin.Net;Kraken;Kraken.Net;Huobi;Huobi.Net;CoinEx;CoinEx.Net;Bybit;Bybit.Net;Bitget;Bitget.Net;Bitfinex;Bitfinex.Net;Binance;Binance.Net;CryptoCurrency;CryptoCurrency Exchange;CryptoExchange.Net</PackageTags>
<RepositoryType>git</RepositoryType>
+34 -3
View File
@@ -4,6 +4,7 @@ using CryptoExchange.Net.SharedApis;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Threading;
@@ -310,11 +311,11 @@ namespace CryptoExchange.Net
/// <param name="request">The request parameters</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
public static async IAsyncEnumerable<ExchangeWebResult<T[]>> ExecutePages<T, U>(Func<U, INextPageToken?, CancellationToken, Task<ExchangeWebResult<T[]>>> paginatedFunc, U request, [EnumeratorCancellation]CancellationToken ct = default)
public static async IAsyncEnumerable<ExchangeWebResult<T[]>> ExecutePages<T, U>(Func<U, PageRequest?, CancellationToken, Task<ExchangeWebResult<T[]>>> paginatedFunc, U request, [EnumeratorCancellation]CancellationToken ct = default)
{
var result = new List<T>();
ExchangeWebResult<T[]> batch;
INextPageToken? nextPageToken = null;
PageRequest? nextPageToken = null;
while (true)
{
batch = await paginatedFunc(request, nextPageToken, ct).ConfigureAwait(false);
@@ -323,12 +324,42 @@ namespace CryptoExchange.Net
break;
result.AddRange(batch.Data);
nextPageToken = batch.NextPageToken;
nextPageToken = batch.NextPageRequest;
if (nextPageToken == null)
break;
}
}
/// <summary>
/// Apply filters to the data set
/// </summary>
/// <typeparam name="T">Type</typeparam>
/// <param name="data">Data set</param>
/// <param name="timeSelector">Time selector for the data</param>
/// <param name="startTime">Start time filter</param>
/// <param name="endTime">End time filter</param>
/// <param name="direction">Data direction</param>
public static IEnumerable<T> ApplyFilter<T>(
IEnumerable<T> data,
Func<T, DateTime> timeSelector,
DateTime? startTime,
DateTime? endTime,
DataDirection direction)
{
if (direction == DataDirection.Ascending)
data = data.OrderBy(timeSelector);
else
data = data.OrderByDescending(timeSelector);
if (startTime != null)
data = data.Where(x => timeSelector(x) >= startTime.Value);
if (endTime != null)
data = data.Where(x => timeSelector(x) < endTime.Value);
return data;
}
/// <summary>
/// Apply the rules (price and quantity step size and decimals precision, min/max quantity) from the symbol to the quantity and price
/// </summary>
+2 -3
View File
@@ -1,6 +1,7 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -43,9 +44,7 @@ namespace CryptoExchange.Net.Interfaces
/// <summary>
/// Set string content
/// </summary>
/// <param name="data"></param>
/// <param name="contentType"></param>
void SetContent(string data, string contentType);
void SetContent(string data, Encoding? encoding, string contentType);
/// <summary>
/// Add a header to the request
+15 -9
View File
@@ -1,4 +1,5 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Options;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
@@ -105,31 +106,36 @@ namespace CryptoExchange.Net
/// <summary>
/// Create a new HttpMessageHandler instance
/// </summary>
public static HttpMessageHandler CreateHttpClientMessageHandler(ApiProxy? proxy, TimeSpan? keepAliveInterval)
public static HttpMessageHandler CreateHttpClientMessageHandler(RestExchangeOptions options)
{
#if NET5_0_OR_GREATER
var socketHandler = new SocketsHttpHandler();
try
{
if (keepAliveInterval != null && keepAliveInterval != TimeSpan.Zero)
if (options.HttpKeepAliveInterval != null && options.HttpKeepAliveInterval != TimeSpan.Zero)
{
socketHandler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always;
socketHandler.KeepAlivePingDelay = keepAliveInterval.Value;
socketHandler.KeepAlivePingDelay = options.HttpKeepAliveInterval.Value;
socketHandler.KeepAlivePingTimeout = TimeSpan.FromSeconds(10);
}
socketHandler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
socketHandler.DefaultProxyCredentials = CredentialCache.DefaultCredentials;
socketHandler.EnableMultipleHttp2Connections = options.HttpEnableMultipleHttp2Connections;
socketHandler.PooledConnectionLifetime = options.HttpPooledConnectionLifetime;
socketHandler.PooledConnectionIdleTimeout = options.HttpPooledConnectionIdleTimeout;
socketHandler.MaxConnectionsPerServer = options.HttpMaxConnectionsPerServer;
}
catch (PlatformNotSupportedException) { }
catch (NotImplementedException) { } // Mono runtime throws NotImplementedException
if (proxy != null)
if (options.Proxy != null)
{
socketHandler.Proxy = new WebProxy
{
Address = new Uri($"{proxy.Host}:{proxy.Port}"),
Credentials = proxy.Password == null ? null : new NetworkCredential(proxy.Login, proxy.Password)
Address = new Uri($"{options.Proxy.Host}:{options.Proxy.Port}"),
Credentials = options.Proxy.Password == null ? null : new NetworkCredential(options.Proxy.Login, options.Proxy.Password)
};
}
return socketHandler;
@@ -143,12 +149,12 @@ namespace CryptoExchange.Net
catch (PlatformNotSupportedException) { }
catch (NotImplementedException) { } // Mono runtime throws NotImplementedException
if (proxy != null)
if (options.Proxy != null)
{
httpHandler.Proxy = new WebProxy
{
Address = new Uri($"{proxy.Host}:{proxy.Port}"),
Credentials = proxy.Password == null ? null : new NetworkCredential(proxy.Login, proxy.Password)
Address = new Uri($"{options.Proxy.Host}:{options.Proxy.Port}"),
Credentials = options.Proxy.Password == null ? null : new NetworkCredential(options.Proxy.Login, options.Proxy.Password)
};
}
return httpHandler;
@@ -37,7 +37,7 @@ namespace CryptoExchange.Net.Logging.Extensions
private static readonly Action<ILogger, int, string, Exception?> _sendingPeriodic;
private static readonly Action<ILogger, int, string, string, Exception?> _periodicSendFailed;
private static readonly Action<ILogger, int, int, string, Exception?> _sendingData;
private static readonly Action<ILogger, int, string, string, Exception?> _receivedMessageNotMatchedToAnyListener;
private static readonly Action<ILogger, int, string, string, string, Exception?> _receivedMessageNotMatchedToAnyListener;
private static readonly Action<ILogger, int, int, int, Exception?> _sendingByteData;
static SocketConnectionLoggingExtension()
@@ -177,10 +177,10 @@ namespace CryptoExchange.Net.Logging.Extensions
new EventId(2028, "SendingData"),
"[Sckt {SocketId}] [Req {RequestId}] sending message: {Data}");
_receivedMessageNotMatchedToAnyListener = LoggerMessage.Define<int, string, string>(
_receivedMessageNotMatchedToAnyListener = LoggerMessage.Define<int, string, string, string>(
LogLevel.Warning,
new EventId(2029, "ReceivedMessageNotMatchedToAnyListener"),
"[Sckt {SocketId}] received message not matched to any listener. ListenId: {ListenId}, current listeners: [{ListenIds}]");
"[Sckt {SocketId}] received message not matched to any listener. TypeIdentifier: {TypeIdentifier}, ListenId: {ListenId}, current listeners: [{ListenIds}]");
_failedToParse = LoggerMessage.Define<int, string>(
LogLevel.Warning,
@@ -326,9 +326,9 @@ namespace CryptoExchange.Net.Logging.Extensions
_sendingData(logger, socketId, requestId, data, null);
}
public static void ReceivedMessageNotMatchedToAnyListener(this ILogger logger, int socketId, string listenId, string listenIds)
public static void ReceivedMessageNotMatchedToAnyListener(this ILogger logger, int socketId, string typeIdentifier, string listenId, string listenIds)
{
_receivedMessageNotMatchedToAnyListener(logger, socketId, listenId, listenIds, null);
_receivedMessageNotMatchedToAnyListener(logger, socketId, typeIdentifier, listenId, listenIds, null);
}
public static void SendingByteData(this ILogger logger, int socketId, int requestId, int length)
+6 -6
View File
@@ -531,11 +531,11 @@ namespace CryptoExchange.Net.Objects
/// <param name="exchange">The exchange</param>
/// <param name="tradeMode">Trade mode the result applies to</param>
/// <param name="data">Data</param>
/// <param name="nextPageToken">Next page token</param>
/// <param name="nextPageRequest">Next page request</param>
/// <returns></returns>
public ExchangeWebResult<K> AsExchangeResult<K>(string exchange, TradingMode tradeMode, [AllowNull] K data, INextPageToken? nextPageToken = null)
public ExchangeWebResult<K> AsExchangeResult<K>(string exchange, TradingMode tradeMode, [AllowNull] K data, PageRequest? nextPageRequest = null)
{
return new ExchangeWebResult<K>(exchange, tradeMode, As<K>(data), nextPageToken);
return new ExchangeWebResult<K>(exchange, tradeMode, As<K>(data), nextPageRequest);
}
/// <summary>
@@ -545,11 +545,11 @@ namespace CryptoExchange.Net.Objects
/// <param name="exchange">The exchange</param>
/// <param name="tradeModes">Trade modes the result applies to</param>
/// <param name="data">Data</param>
/// <param name="nextPageToken">Next page token</param>
/// <param name="nextPageRequest">Next page token</param>
/// <returns></returns>
public ExchangeWebResult<K> AsExchangeResult<K>(string exchange, TradingMode[]? tradeModes, [AllowNull] K data, INextPageToken? nextPageToken = null)
public ExchangeWebResult<K> AsExchangeResult<K>(string exchange, TradingMode[]? tradeModes, [AllowNull] K data, PageRequest? nextPageRequest = null)
{
return new ExchangeWebResult<K>(exchange, tradeModes, As<K>(data), nextPageToken);
return new ExchangeWebResult<K>(exchange, tradeModes, As<K>(data), nextPageRequest);
}
/// <summary>
+9 -1
View File
@@ -211,7 +211,15 @@ namespace CryptoExchange.Net.Objects
/// <summary>
/// ctor
/// </summary>
public DeserializeError(string? message = null, Exception? exception = null) : base(null, _errorInfo with { Message = (message?.Length > 0 ? _errorInfo.Message + ": " + message : _errorInfo.Message) }, exception) { }
public DeserializeError(string? message = null, Exception? exception = null)
: base(null,
_errorInfo with
{
Message = message?.Length > 0
? message
: _errorInfo.Message
},
exception) { }
}
/// <summary>
@@ -32,10 +32,29 @@ namespace CryptoExchange.Net.Objects.Options
#else
= new Version(1, 1);
#endif
/// <summary>
/// Http client keep alive interval for keeping connections open
/// Http client keep alive interval for keeping connections open. Only applied when using dotnet8.0 or higher and dependency injection
/// </summary>
public TimeSpan? HttpKeepAliveInterval { get; set; } = TimeSpan.FromSeconds(15);
#if NET5_0_OR_GREATER
/// <summary>
/// Enable multiple simultaneous HTTP 2 connections. Only applied when using dependency injection
/// </summary>
public bool HttpEnableMultipleHttp2Connections { get; set; } = false;
/// <summary>
/// Lifetime of pooled HTTP connections; the time before a connection is recreated. Only applied when using dependency injection
/// </summary>
public TimeSpan HttpPooledConnectionLifetime { get; set; } = TimeSpan.FromMinutes(15);
/// <summary>
/// Idle timeout of pooled HTTP connections; the time before an open connection is closed when there are no requests. Only applied when using dependency injection
/// </summary>
public TimeSpan HttpPooledConnectionIdleTimeout { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Max number of connections per server. Only applied when using dependency injection
/// </summary>
public int HttpMaxConnectionsPerServer { get; set; } = int.MaxValue;
#endif
/// <summary>
/// Set the values of this options on the target options
@@ -54,6 +73,12 @@ namespace CryptoExchange.Net.Objects.Options
item.CachingMaxAge = CachingMaxAge;
item.HttpVersion = HttpVersion;
item.HttpKeepAliveInterval = HttpKeepAliveInterval;
#if NET5_0_OR_GREATER
item.HttpMaxConnectionsPerServer = HttpMaxConnectionsPerServer;
item.HttpPooledConnectionLifetime = HttpPooledConnectionLifetime;
item.HttpPooledConnectionIdleTimeout = HttpPooledConnectionIdleTimeout;
item.HttpEnableMultipleHttp2Connections = HttpEnableMultipleHttp2Connections;
#endif
return item;
}
}
@@ -72,6 +72,11 @@ namespace CryptoExchange.Net.Objects
/// </summary>
public int? ConnectionId { get; set; }
/// <summary>
/// Whether the endpoint path should always include the trailing `/`
/// </summary>
public bool? ForcePathEndWithSlash { get; set; }
/// <summary>
/// ctor
/// </summary>
@@ -47,6 +47,7 @@ namespace CryptoExchange.Net.Objects
/// <param name="arraySerialization">Array serialization type</param>
/// <param name="preventCaching">Prevent request caching</param>
/// <param name="tryParseOnNonSuccess">Try parse the response even when status is not success</param>
/// <param name="forcePathEndWithSlash">Force trailing `/`</param>
/// <returns></returns>
public RequestDefinition GetOrCreate(
HttpMethod method,
@@ -59,8 +60,9 @@ namespace CryptoExchange.Net.Objects
HttpMethodParameterPosition? parameterPosition = null,
ArrayParametersSerialization? arraySerialization = null,
bool? preventCaching = null,
bool? tryParseOnNonSuccess = null)
=> GetOrCreate(method + path, method, path, rateLimitGate, weight, authenticated, limitGuard, requestBodyFormat, parameterPosition, arraySerialization, preventCaching, tryParseOnNonSuccess);
bool? tryParseOnNonSuccess = null,
bool? forcePathEndWithSlash = null)
=> GetOrCreate(method + path, method, path, rateLimitGate, weight, authenticated, limitGuard, requestBodyFormat, parameterPosition, arraySerialization, preventCaching, tryParseOnNonSuccess, forcePathEndWithSlash);
/// <summary>
/// Get a definition if it is already in the cache or create a new definition and add it to the cache
@@ -77,6 +79,7 @@ namespace CryptoExchange.Net.Objects
/// <param name="arraySerialization">Array serialization type</param>
/// <param name="preventCaching">Prevent request caching</param>
/// <param name="tryParseOnNonSuccess">Try parse the response even when status is not success</param>
/// <param name="forcePathEndWithSlash">Force trailing `/`</param>
/// <returns></returns>
public RequestDefinition GetOrCreate(
string identifier,
@@ -90,7 +93,8 @@ namespace CryptoExchange.Net.Objects
HttpMethodParameterPosition? parameterPosition = null,
ArrayParametersSerialization? arraySerialization = null,
bool? preventCaching = null,
bool? tryParseOnNonSuccess = null)
bool? tryParseOnNonSuccess = null,
bool? forcePathEndWithSlash = null)
{
if (!_definitions.TryGetValue(identifier, out var def))
@@ -105,7 +109,8 @@ namespace CryptoExchange.Net.Objects
RequestBodyFormat = requestBodyFormat,
ParameterPosition = parameterPosition,
PreventCaching = preventCaching ?? false,
TryParseOnNonSuccess = tryParseOnNonSuccess ?? false
TryParseOnNonSuccess = tryParseOnNonSuccess ?? false,
ForcePathEndWithSlash = forcePathEndWithSlash ?? false
};
_definitions.TryAdd(identifier, def);
}
@@ -170,6 +170,6 @@ namespace CryptoExchange.Net.Objects.Sockets
}
/// <inheritdoc />
public override string ToString() => base.ToString().TrimEnd('-') + Data?.ToString();
public override string ToString() => base.ToString().TrimEnd(' ', '-') + " - " + Data?.ToString();
}
}
@@ -332,7 +332,6 @@ namespace CryptoExchange.Net.OrderBook
public async Task StopAsync()
{
_logger.OrderBookStopping(Api, Symbol);
Status = OrderBookStatus.Disconnected;
_cts?.Cancel();
_queueEvent.Set();
if (_processTask != null)
@@ -345,6 +344,7 @@ namespace CryptoExchange.Net.OrderBook
_subscription.ConnectionRestored -= HandleConnectionRestored;
}
Status = OrderBookStatus.Disconnected;
_logger.OrderBookStopped(Api, Symbol);
}
@@ -448,6 +448,9 @@ namespace CryptoExchange.Net.OrderBook
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
if (Status == OrderBookStatus.Disposed || Status == OrderBookStatus.Disconnected)
throw new InvalidOperationException("Trying to set snapshot while book is not working");
_processQueue.Enqueue(
new OrderBookSnapshot
{
@@ -475,6 +478,9 @@ namespace CryptoExchange.Net.OrderBook
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
if (Status == OrderBookStatus.Disposed || Status == OrderBookStatus.Disconnected)
throw new InvalidOperationException("Trying to update order book while book is not working");
_processQueue.Enqueue(
new OrderBookUpdate
{
@@ -505,6 +511,9 @@ namespace CryptoExchange.Net.OrderBook
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
if (Status == OrderBookStatus.Disposed || Status == OrderBookStatus.Disconnected)
throw new InvalidOperationException("Trying to update order book while book is not working");
_processQueue.Enqueue(
new OrderBookUpdate
{
@@ -531,6 +540,9 @@ namespace CryptoExchange.Net.OrderBook
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
if (Status == OrderBookStatus.Disposed || Status == OrderBookStatus.Disconnected)
throw new InvalidOperationException("Trying to update order book while book is not working");
var highest = Math.Max(bids.Any() ? bids.Max(b => b.Sequence) : 0, asks.Any() ? asks.Max(a => a.Sequence) : 0);
var lowest = Math.Min(bids.Any() ? bids.Min(b => b.Sequence) : long.MaxValue, asks.Any() ? asks.Min(a => a.Sequence) : long.MaxValue);
@@ -554,6 +566,9 @@ namespace CryptoExchange.Net.OrderBook
/// <param name="sequenceNumber">The sequence number of the message if it's a separate message with separate number</param>
protected void AddChecksum(int checksum, long? sequenceNumber = null)
{
if (Status == OrderBookStatus.Disposed || Status == OrderBookStatus.Disconnected)
throw new InvalidOperationException("Trying to add checksum while book is not working");
_processQueue.Enqueue(new OrderBookChecksum() { Checksum = checksum, SequenceNumber = sequenceNumber });
_queueEvent.Set();
}
@@ -17,11 +17,11 @@ namespace CryptoExchange.Net.RateLimiting.Filters
/// <param name="path"></param>
public PathStartFilter(string path)
{
_path = path;
_path = path.TrimStart('/');
}
/// <inheritdoc />
public bool Passes(RateLimitItemType type, RequestDefinition definition, string host, string? apiKey)
=> definition.Path.StartsWith(_path, StringComparison.OrdinalIgnoreCase);
=> definition.Path.TrimStart('/').StartsWith(_path, StringComparison.OrdinalIgnoreCase);
}
}
+4 -5
View File
@@ -19,9 +19,6 @@ namespace CryptoExchange.Net.Requests
/// <summary>
/// Create request object for web request
/// </summary>
/// <param name="request"></param>
/// <param name="client"></param>
/// <param name="requestId"></param>
public Request(HttpRequestMessage request, HttpClient client, int requestId)
{
_httpClient = client;
@@ -55,10 +52,12 @@ namespace CryptoExchange.Net.Requests
public int RequestId { get; }
/// <inheritdoc />
public void SetContent(string data, string contentType)
public void SetContent(string data, Encoding? encoding, string contentType)
{
Content = data;
_request.Content = new StringContent(data, Encoding.UTF8, contentType);
_request.Content = new StringContent(data, encoding ?? Encoding.UTF8, contentType);
if (encoding == null)
_request.Content.Headers.ContentType!.CharSet = null;
}
/// <inheritdoc />
+12 -5
View File
@@ -12,14 +12,16 @@ namespace CryptoExchange.Net.Requests
public class RequestFactory : IRequestFactory
{
private HttpClient? _httpClient;
private RestExchangeOptions? _options;
/// <inheritdoc />
public void Configure(RestExchangeOptions options, HttpClient? client = null)
{
if (client == null)
client = CreateClient(options.Proxy, options.RequestTimeout, options.HttpKeepAliveInterval);
client = CreateClient(options);
_httpClient = client;
_options = options;
}
/// <inheritdoc />
@@ -39,15 +41,20 @@ namespace CryptoExchange.Net.Requests
/// <inheritdoc />
public void UpdateSettings(ApiProxy? proxy, TimeSpan requestTimeout, TimeSpan? httpKeepAliveInterval)
{
_httpClient = CreateClient(proxy, requestTimeout, httpKeepAliveInterval);
var newOptions = new RestExchangeOptions();
_options!.Set(newOptions);
newOptions.Proxy = proxy;
newOptions.RequestTimeout = requestTimeout;
newOptions.HttpKeepAliveInterval = httpKeepAliveInterval;
_httpClient = CreateClient(newOptions);
}
private static HttpClient CreateClient(ApiProxy? proxy, TimeSpan requestTimeout, TimeSpan? httpKeepAliveInterval)
private static HttpClient CreateClient(RestExchangeOptions options)
{
var handler = LibraryHelpers.CreateHttpClientMessageHandler(proxy, httpKeepAliveInterval);
var handler = LibraryHelpers.CreateHttpClientMessageHandler(options);
var client = new HttpClient(handler)
{
Timeout = requestTimeout
Timeout = options.RequestTimeout
};
return client;
}
@@ -1,111 +0,0 @@
using System;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// A token which a request can use to retrieve the next page if there are more pages in the result set
/// </summary>
public interface INextPageToken
{
}
/// <summary>
/// A datetime offset token
/// </summary>
public record DateTimeToken: INextPageToken
{
/// <summary>
/// Last result time
/// </summary>
public DateTime LastTime { get; set; }
/// <summary>
/// ctor
/// </summary>
public DateTimeToken(DateTime timestamp)
{
LastTime = timestamp;
}
}
/// <summary>
/// A current page index token
/// </summary>
public record PageToken: INextPageToken
{
/// <summary>
/// The next page index
/// </summary>
public int Page { get; set; }
/// <summary>
/// Page size
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// ctor
/// </summary>
public PageToken(int page, int pageSize)
{
Page = page;
PageSize = pageSize;
}
}
/// <summary>
/// A id offset token
/// </summary>
public record FromIdToken : INextPageToken
{
/// <summary>
/// The last id from previous result
/// </summary>
public string FromToken { get; set; }
/// <summary>
/// ctor
/// </summary>
public FromIdToken(string fromToken)
{
FromToken = fromToken;
}
}
/// <summary>
/// A cursor token
/// </summary>
public record CursorToken : INextPageToken
{
/// <summary>
/// The next page cursor
/// </summary>
public string Cursor { get; set; }
/// <summary>
/// ctor
/// </summary>
public CursorToken(string cursor)
{
Cursor = cursor;
}
}
/// <summary>
/// A result offset token
/// </summary>
public record OffsetToken : INextPageToken
{
/// <summary>
/// Offset in the result set
/// </summary>
public int Offset { get; set; }
/// <summary>
/// ctor
/// </summary>
public OffsetToken(int offset)
{
Offset = offset;
}
}
}
@@ -16,8 +16,8 @@ namespace CryptoExchange.Net.SharedApis
/// Get funding rate records
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedFundingRate[]>> GetFundingRateHistoryAsync(GetFundingRateHistoryRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedFundingRate[]>> GetFundingRateHistoryAsync(GetFundingRateHistoryRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -73,14 +73,14 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Spot get closed orders request options
/// </summary>
PaginatedEndpointOptions<GetClosedOrdersRequest> GetClosedFuturesOrdersOptions { get; }
GetClosedOrdersOptions GetClosedFuturesOrdersOptions { get; }
/// <summary>
/// Get info on closed futures orders
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedFuturesOrder[]>> GetClosedFuturesOrdersAsync(GetClosedOrdersRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedFuturesOrder[]>> GetClosedFuturesOrdersAsync(GetClosedOrdersRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
/// <summary>
/// Futures get order trades request options
@@ -96,14 +96,14 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Futures user trades request options
/// </summary>
PaginatedEndpointOptions<GetUserTradesRequest> GetFuturesUserTradesOptions { get; }
GetUserTradesOptions GetFuturesUserTradesOptions { get; }
/// <summary>
/// Get futures user trade records
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedUserTrade[]>> GetFuturesUserTradesAsync(GetUserTradesRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedUserTrade[]>> GetFuturesUserTradesAsync(GetUserTradesRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
/// <summary>
/// Futures cancel order request options
@@ -16,8 +16,8 @@ namespace CryptoExchange.Net.SharedApis
/// Get index price kline/candlestick data
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedFuturesKline[]>> GetIndexPriceKlinesAsync(GetKlinesRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedFuturesKline[]>> GetIndexPriceKlinesAsync(GetKlinesRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -16,8 +16,8 @@ namespace CryptoExchange.Net.SharedApis
/// Get mark price kline/candlestick data
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedFuturesKline[]>> GetMarkPriceKlinesAsync(GetKlinesRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedFuturesKline[]>> GetMarkPriceKlinesAsync(GetKlinesRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -16,8 +16,8 @@ namespace CryptoExchange.Net.SharedApis
/// Get position history
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedPositionHistory[]>> GetPositionHistoryAsync(GetPositionHistoryRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedPositionHistory[]>> GetPositionHistoryAsync(GetPositionHistoryRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -30,9 +30,9 @@ namespace CryptoExchange.Net.SharedApis
/// Get deposit records
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
Task<ExchangeWebResult<SharedDeposit[]>> GetDepositsAsync(GetDepositsRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedDeposit[]>> GetDepositsAsync(GetDepositsRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -17,9 +17,9 @@ namespace CryptoExchange.Net.SharedApis
/// Get kline/candlestick data
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
Task<ExchangeWebResult<SharedKline[]>> GetKlinesAsync(GetKlinesRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedKline[]>> GetKlinesAsync(GetKlinesRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -17,9 +17,9 @@ namespace CryptoExchange.Net.SharedApis
/// Get public trade history
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
Task<ExchangeWebResult<SharedTrade[]>> GetTradeHistoryAsync(GetTradeHistoryRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedTrade[]>> GetTradeHistoryAsync(GetTradeHistoryRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -17,9 +17,9 @@ namespace CryptoExchange.Net.SharedApis
/// Get withdrawal records
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
Task<ExchangeWebResult<SharedWithdrawal[]>> GetWithdrawalsAsync(GetWithdrawalsRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedWithdrawal[]>> GetWithdrawalsAsync(GetWithdrawalsRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -72,14 +72,14 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Spot get closed orders request options
/// </summary>
PaginatedEndpointOptions<GetClosedOrdersRequest> GetClosedSpotOrdersOptions { get; }
GetClosedOrdersOptions GetClosedSpotOrdersOptions { get; }
/// <summary>
/// Get info on closed spot orders
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedSpotOrder[]>> GetClosedSpotOrdersAsync(GetClosedOrdersRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedSpotOrder[]>> GetClosedSpotOrdersAsync(GetClosedOrdersRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
/// <summary>
/// Spot get order trades request options
@@ -95,14 +95,14 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Spot user trades request options
/// </summary>
PaginatedEndpointOptions<GetUserTradesRequest> GetSpotUserTradesOptions { get; }
GetUserTradesOptions GetSpotUserTradesOptions { get; }
/// <summary>
/// Get spot user trade records
/// </summary>
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination token from the previous request to continue pagination</param>
/// <param name="nextPageToken">The pagination request from the previous request result `NextPageRequest` property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<ExchangeWebResult<SharedUserTrade[]>> GetSpotUserTradesAsync(GetUserTradesRequest request, INextPageToken? nextPageToken = null, CancellationToken ct = default);
Task<ExchangeWebResult<SharedUserTrade[]>> GetSpotUserTradesAsync(GetUserTradesRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
/// <summary>
/// Spot cancel order request options
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
namespace CryptoExchange.Net.SharedApis
@@ -99,6 +100,9 @@ namespace CryptoExchange.Net.SharedApis
if (val == null)
return default;
if (val.Value is T typeVal)
return typeVal;
try
{
Type t = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
@@ -24,9 +24,9 @@ namespace CryptoExchange.Net.SharedApis
public TradingMode[]? DataTradeMode { get; }
/// <summary>
/// Token to retrieve the next page with
/// Next page request, can be passed to the next request on the same endpoint to get the next page
/// </summary>
public INextPageToken? NextPageToken { get; }
public PageRequest? NextPageRequest { get; }
/// <summary>
/// ctor
@@ -46,7 +46,7 @@ namespace CryptoExchange.Net.SharedApis
string exchange,
TradingMode dataTradeMode,
WebCallResult<T> result,
INextPageToken? nextPageToken = null) :
PageRequest? nextPageToken = null) :
base(result.ResponseStatusCode,
result.HttpVersion,
result.ResponseHeaders,
@@ -64,7 +64,7 @@ namespace CryptoExchange.Net.SharedApis
{
DataTradeMode = new[] { dataTradeMode };
Exchange = exchange;
NextPageToken = nextPageToken;
NextPageRequest = nextPageToken;
}
/// <summary>
@@ -74,7 +74,7 @@ namespace CryptoExchange.Net.SharedApis
string exchange,
TradingMode[]? dataTradeModes,
WebCallResult<T> result,
INextPageToken? nextPageToken = null) :
PageRequest? nextPageRequest = null) :
base(result.ResponseStatusCode,
result.HttpVersion,
result.ResponseHeaders,
@@ -92,7 +92,7 @@ namespace CryptoExchange.Net.SharedApis
{
DataTradeMode = dataTradeModes;
Exchange = exchange;
NextPageToken = nextPageToken;
NextPageRequest = nextPageRequest;
}
/// <summary>
@@ -115,7 +115,7 @@ namespace CryptoExchange.Net.SharedApis
ResultDataSource dataSource,
[AllowNull] T data,
Error? error,
INextPageToken? nextPageToken = null) : base(
PageRequest? nextPageToken = null) : base(
code,
httpVersion,
responseHeaders,
@@ -133,7 +133,7 @@ namespace CryptoExchange.Net.SharedApis
{
DataTradeMode = dataTradeModes;
Exchange = exchange;
NextPageToken = nextPageToken;
NextPageRequest = nextPageToken;
}
/// <summary>
@@ -144,7 +144,7 @@ namespace CryptoExchange.Net.SharedApis
/// <returns></returns>
public new ExchangeWebResult<K> As<K>([AllowNull] K data)
{
return new ExchangeWebResult<K>(Exchange, DataTradeMode, ResponseStatusCode, HttpVersion, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, DataSource, data, Error, NextPageToken);
return new ExchangeWebResult<K>(Exchange, DataTradeMode, ResponseStatusCode, HttpVersion, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, DataSource, data, Error, NextPageRequest);
}
/// <inheritdoc />
@@ -1,4 +1,5 @@
using CryptoExchange.Net.Objects;
using System;
using System.Text;
namespace CryptoExchange.Net.SharedApis
@@ -8,24 +9,36 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public class GetClosedOrdersOptions : PaginatedEndpointOptions<GetClosedOrdersRequest>
{
/// <summary>
/// Whether the start/end time filter is supported
/// </summary>
public bool TimeFilterSupported { get; set; }
/// <summary>
/// ctor
/// </summary>
public GetClosedOrdersOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit) : base(paginationType, timeFilterSupported, maxLimit, true)
public GetClosedOrdersOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, true)
{
TimeFilterSupported = timeFilterSupported;
}
/// <inheritdoc />
public override Error? ValidateRequest(string exchange, GetClosedOrdersRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes)
{
if (!TimeFilterSupported && request.StartTime != null)
return ArgumentError.Invalid(nameof(GetClosedOrdersRequest.StartTime), $"Time filter is not supported");
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} period data is available");
if (!TimePeriodFilterSupport)
{
// When going descending we can still allow startTime filter to limit the results
var now = DateTime.UtcNow;
if ((request.Direction != DataDirection.Descending && request.StartTime != null)
|| (request.EndTime != null && now - request.EndTime > TimeSpan.FromSeconds(5)))
{
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
}
}
return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes);
}
@@ -34,7 +47,7 @@ namespace CryptoExchange.Net.SharedApis
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Time filter supported: {TimeFilterSupported}");
sb.AppendLine($"Time filter supported: {TimePeriodFilterSupport}");
return sb.ToString();
}
}
@@ -1,4 +1,5 @@
using CryptoExchange.Net.Objects;
using System;
using System.Text;
namespace CryptoExchange.Net.SharedApis
@@ -8,24 +9,36 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public class GetDepositsOptions : PaginatedEndpointOptions<GetDepositsRequest>
{
/// <summary>
/// Whether the start/end time filter is supported
/// </summary>
public bool TimeFilterSupported { get; set; }
/// <summary>
/// ctor
/// </summary>
public GetDepositsOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit) : base(paginationType, timeFilterSupported, maxLimit, true)
public GetDepositsOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, true)
{
TimeFilterSupported = timeFilterSupported;
}
/// <inheritdoc />
public override Error? ValidateRequest(string exchange, GetDepositsRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes)
{
if (!TimeFilterSupported && request.StartTime != null)
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} period data is available");
if (!TimePeriodFilterSupport)
{
// When going descending we can still allow startTime filter to limit the results
var now = DateTime.UtcNow;
if ((request.Direction != DataDirection.Descending && request.StartTime != null)
|| (request.EndTime != null && now - request.EndTime > TimeSpan.FromSeconds(5)))
{
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
}
}
return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes);
}
@@ -34,7 +47,7 @@ namespace CryptoExchange.Net.SharedApis
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Time filter supported: {TimeFilterSupported}");
sb.AppendLine($"Time filter supported: {TimePeriodFilterSupport}");
return sb.ToString();
}
}
@@ -1,4 +1,8 @@
namespace CryptoExchange.Net.SharedApis
using CryptoExchange.Net.Objects;
using System;
using System.Text;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Options for requesting funding rate history
@@ -8,8 +12,43 @@
/// <summary>
/// ctor
/// </summary>
public GetFundingRateHistoryOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit, bool needsAuthentication) : base(paginationType, timeFilterSupported, maxLimit, needsAuthentication)
public GetFundingRateHistoryOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit, bool needsAuthentication)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, needsAuthentication)
{
}
/// <inheritdoc />
public override Error? ValidateRequest(string exchange, GetFundingRateHistoryRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes)
{
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} period data is available");
if (!TimePeriodFilterSupport)
{
// When going descending we can still allow startTime filter to limit the results
var now = DateTime.UtcNow;
if ((request.Direction != DataDirection.Descending && request.StartTime != null)
|| (request.EndTime != null && now - request.EndTime > TimeSpan.FromSeconds(5)))
{
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
}
}
return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes);
}
/// <inheritdoc />
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Time filter supported: {TimePeriodFilterSupport}");
return sb.ToString();
}
}
}
@@ -18,15 +18,12 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of data points which can be requested
/// </summary>
public int? MaxTotalDataPoints { get; set; }
/// <summary>
/// The max age of the data that can be requested
/// </summary>
public TimeSpan? MaxAge { get; set; }
/// <summary>
/// ctor
/// </summary>
public GetKlinesOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit, bool needsAuthentication) : base(paginationType, timeFilterSupported, maxLimit, needsAuthentication)
public GetKlinesOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit, bool needsAuthentication)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, needsAuthentication)
{
SupportIntervals = new[]
{
@@ -50,7 +47,8 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// ctor
/// </summary>
public GetKlinesOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit, bool needsAuthentication, params SharedKlineInterval[] intervals) : base(paginationType, timeFilterSupported, maxLimit, needsAuthentication)
public GetKlinesOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit, bool needsAuthentication, params SharedKlineInterval[] intervals)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, needsAuthentication)
{
SupportIntervals = intervals;
}
@@ -68,12 +66,29 @@ namespace CryptoExchange.Net.SharedApis
if (!IsSupported(request.Interval))
return ArgumentError.Invalid(nameof(GetKlinesRequest.Interval), "Interval not supported");
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} klines are available");
if (request.Limit > MaxLimit)
return ArgumentError.Invalid(nameof(GetKlinesRequest.Limit), $"Only {MaxLimit} klines can be retrieved per request");
if (!TimePeriodFilterSupport)
{
// When going descending we can still allow startTime filter to limit the results
var now = DateTime.UtcNow;
if ((request.Direction == DataDirection.Ascending && request.StartTime != null)
|| (request.EndTime != null && now - request.EndTime > TimeSpan.FromSeconds(5)))
{
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
}
}
if (MaxTotalDataPoints.HasValue)
{
if (request.Limit > MaxTotalDataPoints.Value)
@@ -93,6 +108,7 @@ namespace CryptoExchange.Net.SharedApis
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Time filter supported: {TimePeriodFilterSupport}");
sb.AppendLine($"Supported SharedKlineInterval values: {string.Join(", ", SupportIntervals)}");
if (MaxAge != null)
sb.AppendLine($"Max age of data: {MaxAge}");
@@ -1,4 +1,8 @@
namespace CryptoExchange.Net.SharedApis
using CryptoExchange.Net.Objects;
using System;
using System.Text;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Options for requesting position history
@@ -8,8 +12,43 @@
/// <summary>
/// ctor
/// </summary>
public GetPositionHistoryOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit) : base(paginationType, timeFilterSupported, maxLimit, true)
public GetPositionHistoryOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, true)
{
}
/// <inheritdoc />
public override Error? ValidateRequest(string exchange, GetPositionHistoryRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes)
{
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} period data is available");
if (!TimePeriodFilterSupport)
{
// When going descending we can still allow startTime filter to limit the results
var now = DateTime.UtcNow;
if ((request.Direction != DataDirection.Descending && request.StartTime != null)
|| (request.EndTime != null && now - request.EndTime > TimeSpan.FromSeconds(5)))
{
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
}
}
return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes);
}
/// <inheritdoc />
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Time filter supported: {TimePeriodFilterSupport}");
return sb.ToString();
}
}
}
@@ -9,34 +9,27 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public class GetTradeHistoryOptions : PaginatedEndpointOptions<GetTradeHistoryRequest>
{
/// <summary>
/// The max age of data that can be requested
/// </summary>
public TimeSpan? MaxAge { get; set; }
/// <summary>
/// ctor
/// </summary>
public GetTradeHistoryOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit, bool needsAuthentication) : base(paginationType, timeFilterSupported, maxLimit, needsAuthentication)
public GetTradeHistoryOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit, bool needsAuthentication)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, needsAuthentication)
{
}
/// <inheritdoc />
public override Error? ValidateRequest(string exchange, GetTradeHistoryRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes)
{
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetTradeHistoryRequest.StartTime), $"Only the most recent {MaxAge} trades are available");
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} period data is available");
return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes);
}
/// <inheritdoc />
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
if (MaxAge != null)
sb.AppendLine($"Max age of data: {MaxAge}");
return sb.ToString();
}
}
}
@@ -0,0 +1,54 @@
using CryptoExchange.Net.Objects;
using System;
using System.Text;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Options for requesting user trades
/// </summary>
public class GetUserTradesOptions : PaginatedEndpointOptions<GetUserTradesRequest>
{
/// <summary>
/// ctor
/// </summary>
public GetUserTradesOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, true)
{
}
/// <inheritdoc />
public override Error? ValidateRequest(string exchange, GetUserTradesRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes)
{
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} period data is available");
if (!TimePeriodFilterSupport)
{
// When going descending we can still allow startTime filter to limit the results
var now = DateTime.UtcNow;
if ((request.Direction != DataDirection.Descending && request.StartTime != null)
|| (request.EndTime != null && now - request.EndTime > TimeSpan.FromSeconds(5)))
{
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
}
}
return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes);
}
/// <inheritdoc />
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Time filter supported: {TimePeriodFilterSupport}");
return sb.ToString();
}
}
}
@@ -1,4 +1,5 @@
using CryptoExchange.Net.Objects;
using System;
using System.Text;
namespace CryptoExchange.Net.SharedApis
@@ -8,24 +9,36 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public class GetWithdrawalsOptions : PaginatedEndpointOptions<GetWithdrawalsRequest>
{
/// <summary>
/// Whether the start/end time filter is supported
/// </summary>
public bool TimeFilterSupported { get; set; }
/// <summary>
/// ctor
/// </summary>
public GetWithdrawalsOptions(SharedPaginationSupport paginationType, bool timeFilterSupported, int maxLimit) : base(paginationType, timeFilterSupported, maxLimit, true)
public GetWithdrawalsOptions(bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit)
: base(supportsAscending, supportsDescending, timeFilterSupported, maxLimit, true)
{
TimeFilterSupported = timeFilterSupported;
}
/// <inheritdoc />
public override Error? ValidateRequest(string exchange, GetWithdrawalsRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes)
{
if (!TimeFilterSupported && request.StartTime != null)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.StartTime), $"Time filter is not supported");
if (!SupportsAscending && request.Direction == DataDirection.Ascending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Ascending direction is not supported");
if (!SupportsDescending && request.Direction == DataDirection.Descending)
return ArgumentError.Invalid(nameof(GetWithdrawalsRequest.Direction), $"Descending direction is not supported");
if (MaxAge.HasValue && request.StartTime < DateTime.UtcNow.Add(-MaxAge.Value))
return ArgumentError.Invalid(nameof(GetKlinesRequest.StartTime), $"Only the most recent {MaxAge} period data is available");
if (!TimePeriodFilterSupport)
{
// When going descending we can still allow startTime filter to limit the results
var now = DateTime.UtcNow;
if ((request.Direction != DataDirection.Descending && request.StartTime != null)
|| (request.EndTime != null && now - request.EndTime > TimeSpan.FromSeconds(5)))
{
return ArgumentError.Invalid(nameof(GetDepositsRequest.StartTime), $"Time filter is not supported");
}
}
return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes);
}
@@ -34,7 +47,7 @@ namespace CryptoExchange.Net.SharedApis
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Time filter supported: {TimeFilterSupported}");
sb.AppendLine($"Time filter supported: {TimePeriodFilterSupport}");
return sb.ToString();
}
}
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text;
namespace CryptoExchange.Net.SharedApis
@@ -14,9 +15,13 @@ namespace CryptoExchange.Net.SharedApis
#endif
{
/// <summary>
/// Type of pagination supported
/// Whether ascending data retrieval and pagination is available
/// </summary>
public SharedPaginationSupport PaginationSupport { get; }
public bool SupportsAscending { get; set; }
/// <summary>
/// Whether ascending data retrieval and pagination is available
/// </summary>
public bool SupportsDescending { get; set; }
/// <summary>
/// Whether filtering based on start/end time is supported
@@ -28,12 +33,23 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public int MaxLimit { get; set; }
/// <summary>
/// Max age of data that can be requested
/// </summary>
public TimeSpan? MaxAge { get; set; }
/// <summary>
/// ctor
/// </summary>
public PaginatedEndpointOptions(SharedPaginationSupport paginationType, bool timePeriodSupport, int maxLimit, bool needsAuthentication) : base(needsAuthentication)
public PaginatedEndpointOptions(
bool supportsAscending,
bool supportsDescending,
bool timePeriodSupport,
int maxLimit,
bool needsAuthentication) : base(needsAuthentication)
{
PaginationSupport = paginationType;
SupportsAscending = supportsAscending;
SupportsDescending = supportsDescending;
TimePeriodFilterSupport = timePeriodSupport;
MaxLimit = maxLimit;
}
@@ -42,9 +58,11 @@ namespace CryptoExchange.Net.SharedApis
public override string ToString(string exchange)
{
var sb = new StringBuilder(base.ToString(exchange));
sb.AppendLine($"Pagination type: {PaginationSupport}");
sb.AppendLine($"Ascending retrieval supported: {SupportsAscending}");
sb.AppendLine($"Descending retrieval supported: {SupportsDescending}");
sb.AppendLine($"Time period filter support: {TimePeriodFilterSupport}");
sb.AppendLine($"Max limit: {MaxLimit}");
sb.AppendLine($"Max age: {MaxAge}");
return sb.ToString();
}
}
@@ -0,0 +1,17 @@
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Data direction
/// </summary>
public enum DataDirection
{
/// <summary>
/// Old to new order
/// </summary>
Ascending,
/// <summary>
/// New to old order
/// </summary>
Descending
}
}
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Next page request info
/// </summary>
public class PageRequest
{
/// <summary>
/// Pagination cursor
/// </summary>
public string? Cursor { get; set; }
/// <summary>
/// Page number
/// </summary>
public int? Page { get; set; }
/// <summary>
/// Result offset
/// </summary>
public int? Offset { get; set; }
/// <summary>
/// From id filter
/// </summary>
public string? FromId { get; set; }
/// <summary>
/// Start time filter
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// End time filter
/// </summary>
public DateTime? EndTime { get; set; }
}
}
@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Pagination methods
/// </summary>
public static class Pagination
{
/// <summary>
/// Get pagination parameters
/// </summary>
/// <param name="direction">The data direction</param>
/// <param name="limit">Result limit</param>
/// <param name="requestStartTime">User request start time</param>
/// <param name="requestEndTime">User request end time</param>
/// <param name="paginationRequest">Provided page request</param>
/// <param name="setOtherTimeLimiter">Whether to set start time if direction is descending, or end time if direction is ascending</param>
/// <param name="maxPeriod">Max period the time filters can span</param>
/// <returns></returns>
public static PaginationParameters GetPaginationParameters(
DataDirection direction,
int limit,
DateTime? requestStartTime,
DateTime requestEndTime,
PageRequest? paginationRequest,
bool setOtherTimeLimiter = true,
TimeSpan? maxPeriod = null
)
{
var startTime = paginationRequest?.StartTime ?? requestStartTime;
var endTime = paginationRequest?.EndTime ?? requestEndTime;
if (maxPeriod != null)
{
if (direction == DataDirection.Ascending)
{
if (startTime == null)
{
startTime = endTime.Add(-maxPeriod.Value);
}
else
{
endTime = startTime.Value.Add(maxPeriod.Value);
if (endTime > DateTime.UtcNow)
endTime = DateTime.UtcNow;
}
}
else
{
startTime = endTime.Add(-maxPeriod.Value);
}
}
return new PaginationParameters
{
Limit = limit,
StartTime = direction == DataDirection.Ascending || setOtherTimeLimiter ? startTime : null,
EndTime = direction == DataDirection.Descending || setOtherTimeLimiter ? endTime : null,
Direction = direction,
FromId = paginationRequest?.FromId,
Offset = paginationRequest?.Offset,
Page = paginationRequest?.Page,
Cursor = paginationRequest?.Cursor
};
}
/// <summary>
/// Get the next page request parameters from result kline data
/// </summary>
/// <param name="nextPageRequest">Callback for returning the next page request</param>
/// <param name="resultCount">Number of results in data</param>
/// <param name="timestamps">Timestamps of the result data</param>
/// <param name="requestStartTime">User request start time</param>
/// <param name="requestEndTime">User request end time</param>
/// <param name="lastPaginationData">The last used pagination data</param>
/// <param name="interval">Kline interval</param>
/// <returns></returns>
public static PageRequest? GetNextPageRequestKlines(
Func<PageRequest?> nextPageRequest,
int resultCount,
IEnumerable<DateTime> timestamps,
DateTime? requestStartTime,
DateTime requestEndTime,
PaginationParameters lastPaginationData,
SharedKlineInterval interval
)
{
if (HasNextPageKlines(resultCount, timestamps, requestStartTime, requestEndTime, lastPaginationData.Limit, lastPaginationData.Direction, interval))
{
var result = nextPageRequest();
if (result != null)
{
result.StartTime ??= lastPaginationData.StartTime;
result.EndTime ??= lastPaginationData.EndTime;
return result;
}
}
return null;
}
/// <summary>
/// Get the next page request parameters from result data
/// </summary>
/// <param name="nextPageRequest">Callback for returning the next page request</param>
/// <param name="resultCount">Number of results in data</param>
/// <param name="timestamps">Timestamps of the result data</param>
/// <param name="requestStartTime">User request start time</param>
/// <param name="requestEndTime">User request end time</param>
/// <param name="lastPaginationData">The last used pagination data</param>
/// <param name="maxPeriod">Max period the time filters can span</param>
/// <param name="maxAge">Max age of the data</param>
/// <returns></returns>
public static PageRequest? GetNextPageRequest(
Func<PageRequest?> nextPageRequest,
int resultCount,
IEnumerable<DateTime> timestamps,
DateTime? requestStartTime,
DateTime requestEndTime,
PaginationParameters lastPaginationData,
TimeSpan? maxPeriod = null,
TimeSpan? maxAge = null
)
{
if (HasNextPage(resultCount, timestamps, requestStartTime, requestEndTime, lastPaginationData.Limit, lastPaginationData.Direction))
{
var result = nextPageRequest();
if (result != null)
{
result.StartTime ??= lastPaginationData.StartTime;
result.EndTime ??= lastPaginationData.EndTime;
return result;
}
}
if (maxPeriod != null)
{
if (HasNextPeriod(requestStartTime, requestEndTime, lastPaginationData.Direction, lastPaginationData, maxPeriod.Value, maxAge))
{
var (startTime, endTime) = GetNextPeriod(requestStartTime, requestEndTime, lastPaginationData.Direction, lastPaginationData, maxPeriod.Value, maxAge);
return new PageRequest
{
StartTime = startTime,
EndTime = endTime
};
}
}
return null;
}
/// <summary>
/// Check whether there is (potentially) another page available
/// </summary>
/// <param name="resultCount">Number of result entries</param>
/// <param name="timestamps">Timestamps</param>
/// <param name="requestStartTime">User request start time</param>
/// <param name="requestEndTime">User request end time</param>
/// <param name="limit">Max number of results requested</param>
/// <param name="direction">Data direction</param>
/// <param name="interval">Kline interval</param>
/// <returns></returns>
public static bool HasNextPageKlines(
int resultCount,
IEnumerable<DateTime> timestamps,
DateTime? requestStartTime,
DateTime requestEndTime,
int limit,
DataDirection direction,
SharedKlineInterval interval
)
{
if (resultCount < limit)
return false;
if (direction == DataDirection.Ascending)
{
if (timestamps.Max().AddSeconds((int)interval) >= requestEndTime)
return false;
return true;
}
else
{
if (timestamps.Min().AddSeconds((int)interval) < requestStartTime)
return false;
return true;
}
}
/// <summary>
/// Check whether there is (potentially) another page available
/// </summary>
/// <param name="resultCount">Number of result entries</param>
/// <param name="timestamps">Timestamps</param>
/// <param name="requestStartTime">User request start time</param>
/// <param name="requestEndTime">User request end time</param>
/// <param name="limit">Max number of results requested</param>
/// <param name="direction">Data direction</param>
/// <returns></returns>
public static bool HasNextPage(
int resultCount,
IEnumerable<DateTime> timestamps,
DateTime? requestStartTime,
DateTime requestEndTime,
int limit,
DataDirection direction)
{
if (resultCount < limit)
return false;
if (!timestamps.Any())
return false;
if (direction == DataDirection.Ascending)
{
if (timestamps.Max() >= requestEndTime)
return false;
return true;
}
else
{
if (timestamps.Min() < requestStartTime)
return false;
return true;
}
}
/// <summary>
/// Get the next page PageRequest
/// </summary>
public static PageRequest NextPageFromPage(PaginationParameters lastPaginationData)
{
return new PageRequest { Page = (lastPaginationData.Page ?? 1) + 1 };
}
/// <summary>
/// Get the next offset PageRequest
/// </summary>
public static PageRequest NextPageFromOffset(PaginationParameters lastPaginationData, int resultCount)
{
return new PageRequest { Offset = (lastPaginationData.Offset ?? 0) + resultCount };
}
/// <summary>
/// Get the next page cursor PageRequest
/// </summary>
public static PageRequest NextPageFromCursor(string nextCursor)
{
return new PageRequest { Cursor = nextCursor };
}
/// <summary>
/// Get the next id PageRequest
/// </summary>
public static PageRequest NextPageFromId(long nextFromId)
{
return new PageRequest { FromId = nextFromId.ToString() };
}
/// <summary>
/// Get the next id PageRequest
/// </summary>
public static PageRequest NextPageFromId(string nextFromId)
{
return new PageRequest { FromId = nextFromId };
}
/// <summary>
/// Get the next start/end time PageRequest
/// </summary>
public static PageRequest NextPageFromTime(PaginationParameters lastPaginationData, DateTime lastTimestamp, bool setOtherTimeLimiter = true)
{
if (lastPaginationData.Direction == DataDirection.Ascending)
return new PageRequest { StartTime = lastTimestamp.AddMilliseconds(1), EndTime = setOtherTimeLimiter ? lastPaginationData.EndTime : null };
else
return new PageRequest { EndTime = lastTimestamp.AddMilliseconds(-1), StartTime = setOtherTimeLimiter ? lastPaginationData.StartTime : null };
}
/// <summary>
/// Get the next start/end time klines PageRequest
/// </summary>
public static PageRequest NextPageFromTimeKlines(DataDirection direction, GetKlinesRequest request, DateTime lastTimestamp, int limit)
{
if (direction == DataDirection.Ascending)
{
var nextStartTime = lastTimestamp.AddSeconds((int)request.Interval);
var endTime = nextStartTime.AddSeconds(limit * (int)request.Interval);
var requestEndTime = request.EndTime ?? DateTime.UtcNow;
if (endTime > requestEndTime)
endTime = requestEndTime;
return new PageRequest { StartTime = nextStartTime, EndTime = endTime };
}
else
{
var nextEndTime = lastTimestamp.AddSeconds(-(int)request.Interval);
var startTime = nextEndTime.AddSeconds(-(limit * (int)request.Interval));
var requestStartTime = request.StartTime ?? DateTime.UtcNow;
if (startTime < requestStartTime)
startTime = requestStartTime;
return new PageRequest { StartTime = startTime, EndTime = nextEndTime };
}
}
/// <summary>
/// Whether another time period is to be requested
/// </summary>
/// <param name="requestStartTime">User request start time</param>
/// <param name="requestEndTime">User request end time</param>
/// <param name="direction">Data direction</param>
/// <param name="lastPaginationParameters">Pagination parameters used</param>
/// <param name="period">Max time period a request can span</param>
/// <param name="maxAge">Max age of data that can be requested</param>
public static bool HasNextPeriod(
DateTime? requestStartTime,
DateTime requestEndTime,
DataDirection direction,
PaginationParameters lastPaginationParameters,
TimeSpan period,
TimeSpan? maxAge)
{
if (direction == DataDirection.Ascending && lastPaginationParameters.StartTime == null)
throw new InvalidOperationException("Invalid pagination data; no start time for ascending pagination");
if (direction == DataDirection.Ascending)
{
return (requestEndTime - lastPaginationParameters.EndTime!.Value).TotalSeconds > 1;
}
else
{
var lastPageStartTime = lastPaginationParameters.StartTime ?? lastPaginationParameters.EndTime!.Value.Add(-period);
if (requestStartTime != null)
{
var nextPeriodDuration = lastPageStartTime - requestStartTime.Value;
return nextPeriodDuration.TotalSeconds > 1;
}
else
{
var nextStartTime = lastPageStartTime - period;
if (maxAge != null)
{
var minStartTime = DateTime.UtcNow - maxAge.Value;
if ((nextStartTime.Add(period) - minStartTime).TotalSeconds < 1)
return false;
}
var nextPeriodDuration = lastPageStartTime - nextStartTime;
return (nextPeriodDuration).TotalSeconds > 1;
}
}
}
/// <summary>
/// Get the start/end time for the next data period
/// </summary>
/// <param name="requestStartTime">User request start time</param>
/// <param name="requestEndTime">User request end time</param>
/// <param name="direction">Data direction</param>
/// <param name="lastPaginationParameters">Pagination parameters used</param>
/// <param name="period">Max time period a request can span</param>
/// <param name="maxAge">Max age of data that can be requested</param>
public static (DateTime? startTime, DateTime? endTime) GetNextPeriod(
DateTime? requestStartTime,
DateTime requestEndTime,
DataDirection direction,
PaginationParameters lastPaginationParameters,
TimeSpan period,
TimeSpan? maxAge
)
{
DateTime? nextStartTime = null;
DateTime? nextEndTime = null;
if (direction == DataDirection.Ascending)
{
if (lastPaginationParameters.StartTime != null)
nextStartTime = lastPaginationParameters.StartTime.Value.Add(period);
if (lastPaginationParameters.EndTime != null)
nextEndTime = lastPaginationParameters.EndTime.Value.Add(period);
}
else
{
if (lastPaginationParameters.StartTime != null)
nextStartTime = lastPaginationParameters.StartTime.Value.Add(-period);
if (lastPaginationParameters.EndTime != null)
nextEndTime = lastPaginationParameters.EndTime.Value.Add(-period);
}
if (nextStartTime != null && nextStartTime < requestStartTime)
nextStartTime = requestStartTime;
if (nextStartTime != null && maxAge != null && nextStartTime < DateTime.UtcNow - maxAge)
{
nextStartTime = DateTime.UtcNow.Add(-maxAge.Value);
// Add 30 seconds to max sure the client/server time offset and latency doesn't push the timestamp over the limit
nextStartTime = nextStartTime.Value.Add(TimeSpan.FromSeconds(30));
}
if (nextEndTime != null && nextEndTime > requestEndTime)
nextEndTime = requestEndTime;
return (nextStartTime, nextEndTime);
}
}
}
@@ -0,0 +1,43 @@
using System;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Pagination parameters
/// </summary>
public record PaginationParameters
{
/// <summary>
/// Data direction
/// </summary>
public DataDirection Direction { get; set; }
/// <summary>
/// Start time filter
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// End time filter
/// </summary>
public DateTime? EndTime { get; set; }
/// <summary>
/// Id filter
/// </summary>
public string? FromId { get; set; }
/// <summary>
/// Result offset
/// </summary>
public int? Offset { get; set; }
/// <summary>
/// Page number
/// </summary>
public int? Page { get; set; }
/// <summary>
/// Pagination cursor
/// </summary>
public string? Cursor { get; set; }
/// <summary>
/// Max number of results
/// </summary>
public int Limit { get; set; }
}
}
@@ -19,6 +19,10 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of results
/// </summary>
public int? Limit { get; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -27,12 +31,14 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetClosedOrdersRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
public GetClosedOrdersRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
{
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -23,6 +23,10 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of results
/// </summary>
public int? Limit { get; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -31,13 +35,15 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetDepositsRequest(string? asset = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
public GetDepositsRequest(string? asset = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
{
Asset = asset;
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -19,6 +19,10 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of results
/// </summary>
public int? Limit { get; set; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -27,12 +31,14 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetFundingRateHistoryRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
public GetFundingRateHistoryRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
{
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -23,6 +23,10 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of results
/// </summary>
public int? Limit { get; set; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -32,13 +36,15 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetKlinesRequest(SharedSymbol symbol, SharedKlineInterval interval, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
public GetKlinesRequest(SharedSymbol symbol, SharedKlineInterval interval, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
{
Interval = interval;
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -27,6 +27,10 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of results
/// </summary>
public int? Limit { get; set; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -35,13 +39,15 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetPositionHistoryRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
public GetPositionHistoryRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
{
Symbol = symbol;
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
/// <summary>
@@ -51,13 +57,15 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetPositionHistoryRequest(TradingMode? tradeMode = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
public GetPositionHistoryRequest(TradingMode? tradeMode = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
{
TradingMode = tradeMode;
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -10,15 +10,19 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Filter by start time
/// </summary>
public DateTime StartTime { get; }
public DateTime StartTime { get; set; }
/// <summary>
/// Filter by end time
/// </summary>
public DateTime EndTime { get; }
public DateTime? EndTime { get; set; }
/// <summary>
/// Max number of results
/// </summary>
public int? Limit { get; }
public int? Limit { get; set; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -27,12 +31,14 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetTradeHistoryRequest(SharedSymbol symbol, DateTime startTime, DateTime endTime, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
public GetTradeHistoryRequest(SharedSymbol symbol, DateTime startTime, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
{
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -19,6 +19,10 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of results
/// </summary>
public int? Limit { get; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -27,12 +31,14 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetUserTradesRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
public GetUserTradesRequest(SharedSymbol symbol, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(symbol, exchangeParameters)
{
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -23,6 +23,10 @@ namespace CryptoExchange.Net.SharedApis
/// Max number of results
/// </summary>
public int? Limit { get; }
/// <summary>
/// Data direction
/// </summary>
public DataDirection? Direction { get; set; }
/// <summary>
/// ctor
@@ -31,13 +35,15 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="startTime">Filter by start time</param>
/// <param name="endTime">Filter by end time</param>
/// <param name="limit">Max number of results</param>
/// <param name="direction">Data direction</param>
/// <param name="exchangeParameters">Exchange specific parameters</param>
public GetWithdrawalsRequest(string? asset = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
public GetWithdrawalsRequest(string? asset = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, DataDirection? direction = null, ExchangeParameters? exchangeParameters = null) : base(exchangeParameters)
{
Asset = asset;
StartTime = startTime;
EndTime = endTime;
Limit = limit;
Direction = direction;
}
}
}
@@ -87,7 +87,8 @@ namespace CryptoExchange.Net.Sockets.Default
get
{
UpdateReceivedMessages();
return Math.Round(_prevSlotBytesReceived * (_lastBytesReceivedUpdate - _prevSlotBytesReceivedUpdate).TotalSeconds / 1000);
var seconds = (_lastBytesReceivedUpdate - _prevSlotBytesReceivedUpdate).TotalSeconds;
return seconds > 0 ? Math.Round(_prevSlotBytesReceived / seconds / 1000) : 0;
}
}
@@ -8,7 +8,9 @@ using CryptoExchange.Net.Sockets.Default.Interfaces;
using CryptoExchange.Net.Sockets.Interfaces;
using Microsoft.Extensions.Logging;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Net.WebSockets;
@@ -123,8 +125,7 @@ namespace CryptoExchange.Net.Sockets.Default
{
get
{
lock (_listenersLock)
return _listeners.OfType<Subscription>().Count(h => h.UserSubscription);
return _listeners.OfType<Subscription>().Count(h => h.UserSubscription);
}
}
@@ -135,8 +136,7 @@ namespace CryptoExchange.Net.Sockets.Default
{
get
{
lock (_listenersLock)
return _listeners.OfType<Subscription>().Where(h => h.UserSubscription).ToArray();
return _listeners.OfType<Subscription>().Where(h => h.UserSubscription).ToArray();
}
}
@@ -240,8 +240,7 @@ namespace CryptoExchange.Net.Sockets.Default
{
get
{
lock (_listenersLock)
return _listeners.OfType<Subscription>().Select(x => x.Topic).Where(t => t != null).ToArray()!;
return _listeners.OfType<Subscription>().Select(x => x.Topic).Where(t => t != null).ToArray()!;
}
}
@@ -252,8 +251,7 @@ namespace CryptoExchange.Net.Sockets.Default
{
get
{
lock (_listenersLock)
return _listeners.OfType<Query>().Where(x => !x.Completed).Count();
return _listeners.OfType<Query>().Where(x => !x.Completed).Count();
}
}
@@ -264,7 +262,7 @@ namespace CryptoExchange.Net.Sockets.Default
#else
private readonly object _listenersLock = new object();
#endif
private readonly List<IMessageProcessor> _listeners;
private ReadOnlyCollection<IMessageProcessor> _listeners;
private readonly ILogger _logger;
private SocketStatus _status;
@@ -313,7 +311,7 @@ namespace CryptoExchange.Net.Sockets.Default
_socket.OnError += HandleErrorAsync;
_socket.GetReconnectionUrl = GetReconnectionUrlAsync;
_listeners = new List<IMessageProcessor>();
_listeners = new ReadOnlyCollection<IMessageProcessor>([]);
_serializer = apiClient.CreateSerializer();
}
@@ -340,21 +338,18 @@ namespace CryptoExchange.Net.Sockets.Default
if (ApiClient._socketConnections.ContainsKey(SocketId))
ApiClient._socketConnections.TryRemove(SocketId, out _);
lock (_listenersLock)
foreach (var subscription in _listeners.OfType<Subscription>().Where(l => l.UserSubscription && !l.IsClosingConnection))
{
foreach (var subscription in _listeners.OfType<Subscription>().Where(l => l.UserSubscription && !l.IsClosingConnection))
{
subscription.IsClosingConnection = true;
subscription.Reset();
}
foreach (var query in _listeners.OfType<Query>().ToList())
{
query.Fail(new WebError("Connection interrupted"));
_listeners.Remove(query);
}
subscription.IsClosingConnection = true;
subscription.Reset();
}
var queryList = _listeners.OfType<Query>().ToList();
foreach (var query in queryList)
query.Fail(new WebError("Connection interrupted"));
RemoveMessageProcessors(queryList);
_ = Task.Run(() => ConnectionClosed?.Invoke());
return Task.CompletedTask;
}
@@ -369,17 +364,14 @@ namespace CryptoExchange.Net.Sockets.Default
Authenticated = false;
_lastSequenceNumber = 0;
lock (_listenersLock)
{
foreach (var subscription in _listeners.OfType<Subscription>().Where(l => l.UserSubscription))
subscription.Reset();
foreach (var subscription in _listeners.OfType<Subscription>().Where(l => l.UserSubscription))
subscription.Reset();
foreach (var query in _listeners.OfType<Query>().ToList())
{
query.Fail(new WebError("Connection interrupted"));
_listeners.Remove(query);
}
}
var queryList = _listeners.OfType<Query>().ToList();
foreach (var query in queryList)
query.Fail(new WebError("Connection interrupted"));
RemoveMessageProcessors(queryList);
_ = Task.Run(() => ConnectionLost?.Invoke());
return Task.CompletedTask;
@@ -401,14 +393,11 @@ namespace CryptoExchange.Net.Sockets.Default
{
Status = SocketStatus.Resubscribing;
lock (_listenersLock)
{
foreach (var query in _listeners.OfType<Query>().ToList())
{
query.Fail(new WebError("Connection interrupted"));
_listeners.Remove(query);
}
}
var queryList = _listeners.OfType<Query>().ToList();
foreach (var query in queryList)
query.Fail(new WebError("Connection interrupted"));
RemoveMessageProcessors(queryList);
// Can't wait for this as it would cause a deadlock
_ = Task.Run(async () =>
@@ -463,12 +452,7 @@ namespace CryptoExchange.Net.Sockets.Default
/// <returns></returns>
protected virtual Task HandleRequestRateLimitedAsync(int requestId)
{
Query? query;
lock (_listenersLock)
{
query = _listeners.OfType<Query>().FirstOrDefault(x => x.Id == requestId);
}
var query = _listeners.OfType<Query>().FirstOrDefault(x => x.Id == requestId);
if (query == null)
return Task.CompletedTask;
@@ -492,12 +476,7 @@ namespace CryptoExchange.Net.Sockets.Default
/// <param name="requestId">Id of the request sent</param>
protected virtual Task HandleRequestSentAsync(int requestId)
{
Query? query;
lock (_listenersLock)
{
query = _listeners.OfType<Query>().FirstOrDefault(x => x.Id == requestId);
}
var query = _listeners.OfType<Query>().FirstOrDefault(x => x.Id == requestId);
if (query == null)
return Task.CompletedTask;
@@ -543,22 +522,19 @@ namespace CryptoExchange.Net.Sockets.Default
}
Type? deserializationType = null;
lock (_listenersLock)
foreach (var subscription in _listeners)
{
foreach (var subscription in _listeners)
foreach (var route in subscription.MessageRouter.Routes)
{
foreach (var route in subscription.MessageRouter.Routes)
{
if (!route.TypeIdentifier.Equals(typeIdentifier, StringComparison.Ordinal))
continue;
if (!route.TypeIdentifier.Equals(typeIdentifier, StringComparison.Ordinal))
continue;
deserializationType = route.DeserializationType;
break;
}
if (deserializationType != null)
break;
deserializationType = route.DeserializationType;
break;
}
if (deserializationType != null)
break;
}
if (deserializationType == null)
@@ -605,63 +581,69 @@ namespace CryptoExchange.Net.Sockets.Default
var topicFilter = messageConverter.GetTopicFilter(result);
bool processed = false;
lock (_listenersLock)
foreach (var processor in _listeners)
{
var currentCount = _listeners.Count;
for(var i = 0; i < _listeners.Count; i++)
bool isQuery = false;
Query? query = null;
if (processor is Query cquery)
{
if (_listeners.Count != currentCount)
{
// Possible a query added or removed. If added it's not a problem, if removed it is
if (_listeners.Count < currentCount)
throw new Exception("Listeners list adjusted, can't continue processing");
}
var processor = _listeners[i];
bool isQuery = false;
Query? query = null;
if (processor is Query cquery)
{
isQuery = true;
query = cquery;
}
var complete = false;
foreach (var route in processor.MessageRouter.Routes)
{
if (route.TypeIdentifier != typeIdentifier)
continue;
if (topicFilter == null
|| route.TopicFilter == null
|| route.TopicFilter.Equals(topicFilter, StringComparison.Ordinal))
{
processed = true;
if (isQuery && query!.Completed)
continue;
processor.Handle(this, receiveTime, originalData, result, route);
if (isQuery && !route.MultipleReaders)
{
complete = true;
break;
}
}
}
if (complete)
break;
isQuery = true;
query = cquery;
}
var complete = false;
foreach (var route in processor.MessageRouter.Routes)
{
if (route.TypeIdentifier != typeIdentifier)
continue;
// Forward message rules:
// | Message Topic | Route Topic Filter | Topics Match | Forward | Description
// | N | N | - | Y | No topic filter applied
// | N | Y | - | N | Route only listens to specific topic
// | Y | N | - | Y | Route listens to all message regardless of topic
// | Y | Y | Y | Y | Route listens to specific message topic
// | Y | Y | N | N | Route listens to different topic
if (topicFilter == null)
{
if (route.TopicFilter != null)
// No topic on message, but route is filtering on topic
continue;
}
else
{
if (route.TopicFilter != null && !route.TopicFilter.Equals(topicFilter, StringComparison.Ordinal))
// Message has a topic, and the route has a filter for another topic
continue;
}
processed = true;
if (isQuery && query!.Completed)
continue;
processor.Handle(this, receiveTime, originalData, result, route);
if (isQuery && !route.MultipleReaders)
{
complete = true;
break;
}
}
if (complete)
break;
}
if (!processed)
{
lock (_listenersLock)
if (!ApiClient.HandleUnhandledMessage(this, typeIdentifier, data))
{
_logger.ReceivedMessageNotMatchedToAnyListener(SocketId, topicFilter!,
string.Join(",", _listeners.Select(x => string.Join(",", x.MessageRouter.Routes.Select(x => x.TopicFilter != null ? string.Join(",", x.TopicFilter) : "[null]")))));
_logger.ReceivedMessageNotMatchedToAnyListener(
SocketId,
typeIdentifier,
topicFilter!,
string.Join(",", _listeners.Select(x => string.Join(",", x.MessageRouter.Routes.Where(x => x.TypeIdentifier == typeIdentifier).Select(x => x.TopicFilter != null ? string.Join(",", x.TopicFilter) : "[null]")))));
}
}
}
@@ -706,13 +688,10 @@ namespace CryptoExchange.Net.Sockets.Default
if (ApiClient._socketConnections.ContainsKey(SocketId))
ApiClient._socketConnections.TryRemove(SocketId, out _);
lock (_listenersLock)
foreach (var subscription in _listeners.OfType<Subscription>())
{
foreach (var subscription in _listeners.OfType<Subscription>())
{
if (subscription.CancellationTokenRegistration.HasValue)
subscription.CancellationTokenRegistration.Value.Dispose();
}
if (subscription.CancellationTokenRegistration.HasValue)
subscription.CancellationTokenRegistration.Value.Dispose();
}
await _socket.CloseAsync().ConfigureAwait(false);
@@ -742,20 +721,12 @@ namespace CryptoExchange.Net.Sockets.Default
if (subscription.CancellationTokenRegistration.HasValue)
subscription.CancellationTokenRegistration.Value.Dispose();
bool anyDuplicateSubscription;
lock (_listenersLock)
anyDuplicateSubscription = _listeners.OfType<Subscription>().Any(x => x != subscription && x.MessageRouter.Routes.All(l => subscription.MessageRouter.ContainsCheck(l)));
bool shouldCloseConnection;
lock (_listenersLock)
shouldCloseConnection = _listeners.OfType<Subscription>().All(r => !r.UserSubscription || r.Status == SubscriptionStatus.Closing || r.Status == SubscriptionStatus.Closed) && !DedicatedRequestConnection.IsDedicatedRequestConnection;
bool anyDuplicateSubscription = _listeners.OfType<Subscription>().Any(x => x != subscription && x.MessageRouter.Routes.All(l => subscription.MessageRouter.ContainsCheck(l)));
bool shouldCloseConnection = _listeners.OfType<Subscription>().All(r => !r.UserSubscription || r.Status == SubscriptionStatus.Closing || r.Status == SubscriptionStatus.Closed) && !DedicatedRequestConnection.IsDedicatedRequestConnection;
if (!anyDuplicateSubscription)
{
bool needUnsub;
lock (_listenersLock)
needUnsub = _listeners.Contains(subscription) && !shouldCloseConnection;
var needUnsub = _listeners.Contains(subscription) && !shouldCloseConnection;
if (needUnsub && _socket.IsOpen)
await UnsubscribeAsync(subscription).ConfigureAwait(false);
}
@@ -779,8 +750,7 @@ namespace CryptoExchange.Net.Sockets.Default
await CloseAsync().ConfigureAwait(false);
}
lock (_listenersLock)
_listeners.Remove(subscription);
RemoveMessageProcessor(subscription);
subscription.Status = SubscriptionStatus.Closed;
}
@@ -804,8 +774,7 @@ namespace CryptoExchange.Net.Sockets.Default
if (Status != SocketStatus.None && Status != SocketStatus.Connected)
return false;
lock (_listenersLock)
_listeners.Add(subscription);
AddMessageProcessor(subscription);
if (subscription.UserSubscription)
_logger.AddingNewSubscription(SocketId, subscription.Id, UserSubscriptionCount);
@@ -818,8 +787,7 @@ namespace CryptoExchange.Net.Sockets.Default
/// <param name="id"></param>
public Subscription? GetSubscription(int id)
{
lock (_listenersLock)
return _listeners.OfType<Subscription>().SingleOrDefault(s => s.Id == id);
return _listeners.OfType<Subscription>().SingleOrDefault(s => s.Id == id);
}
/// <summary>
@@ -867,15 +835,12 @@ namespace CryptoExchange.Net.Sockets.Default
private async Task SendAndWaitIntAsync(Query query, CancellationToken ct = default)
{
lock (_listenersLock)
_listeners.Add(query);
AddMessageProcessor(query);
var sendResult = await SendAsync(query.Id, query.Request, query.Weight).ConfigureAwait(false);
if (!sendResult)
{
query.Fail(sendResult.Error!);
lock (_listenersLock)
_listeners.Remove(query);
RemoveMessageProcessor(query);
return;
}
@@ -906,8 +871,7 @@ namespace CryptoExchange.Net.Sockets.Default
}
finally
{
lock (_listenersLock)
_listeners.Remove(query);
RemoveMessageProcessor(query);
}
}
@@ -930,7 +894,7 @@ namespace CryptoExchange.Net.Sockets.Default
return SendStringAsync(requestId, str, weight);
str = stringSerializer.Serialize(obj);
return SendAsync(requestId, str, weight);
return SendStringAsync(requestId, str, weight);
}
throw new Exception("Unknown serializer when sending message");
@@ -1013,9 +977,7 @@ namespace CryptoExchange.Net.Sockets.Default
if (!DedicatedRequestConnection.IsDedicatedRequestConnection)
{
bool anySubscriptions;
lock (_listenersLock)
anySubscriptions = _listeners.OfType<Subscription>().Any(s => s.UserSubscription);
var anySubscriptions = _listeners.OfType<Subscription>().Any(s => s.UserSubscription);
if (!anySubscriptions)
{
// No need to resubscribe anything
@@ -1025,13 +987,8 @@ namespace CryptoExchange.Net.Sockets.Default
}
}
bool anyAuthenticated;
lock (_listenersLock)
{
anyAuthenticated = _listeners.OfType<Subscription>().Any(s => s.Authenticated)
bool anyAuthenticated = _listeners.OfType<Subscription>().Any(s => s.Authenticated)
|| DedicatedRequestConnection.IsDedicatedRequestConnection && DedicatedRequestConnection.Authenticated;
}
if (anyAuthenticated)
{
// If we reconnected a authenticated connection we need to re-authenticate
@@ -1054,50 +1011,15 @@ namespace CryptoExchange.Net.Sockets.Default
if (!_socket.IsOpen)
return new CallResult(new WebError("Socket not connected"));
List<Subscription> subList;
lock (_listenersLock)
subList = _listeners.OfType<Subscription>().Where(x => x.Active).Skip(batch * batchSize).Take(batchSize).ToList();
var subList = _listeners.OfType<Subscription>().Where(x => x.Active).Skip(batch * batchSize).Take(batchSize).ToList();
if (subList.Count == 0)
break;
var taskList = new List<Task<CallResult>>();
foreach (var subscription in subList)
{
subscription.ConnectionInvocations = 0;
if (!subscription.Active)
// Can be closed during resubscribing
continue;
subscription.Status = SubscriptionStatus.Subscribing;
var result = await ApiClient.RevitalizeRequestAsync(subscription).ConfigureAwait(false);
if (!result)
{
_logger.FailedRequestRevitalization(SocketId, result.Error?.ToString());
subscription.Status = SubscriptionStatus.Pending;
return result;
}
var subQuery = subscription.CreateSubscriptionQuery(this);
if (subQuery == null)
{
subscription.Status = SubscriptionStatus.Subscribed;
continue;
}
subQuery.OnComplete = () =>
{
subscription.Status = subQuery.Result!.Success ? SubscriptionStatus.Subscribed : SubscriptionStatus.Pending;
subscription.HandleSubQueryResponse(this, subQuery.Response);
};
taskList.Add(SendAndWaitQueryAsync(subQuery));
if (!subQuery.ExpectsResponse)
{
// If there won't be an answer we can immediately set this
subscription.Status = SubscriptionStatus.Subscribed;
subscription.HandleSubQueryResponse(this, null);
}
var subscribeTask = TrySubscribeAsync(subscription, false, default);
taskList.Add(subscribeTask);
}
await Task.WhenAll(taskList).ConfigureAwait(false);
@@ -1114,6 +1036,71 @@ namespace CryptoExchange.Net.Sockets.Default
return CallResult.SuccessResult;
}
/// <summary>
/// Try to subscribe a new subscription by sending the subscribe query and wait for the result as needed
/// </summary>
/// <param name="subscription">The subscription</param>
/// <param name="newSubscription">Whether this is a new subscription, or an existing subscription (resubscribing on reconnected socket)</param>
/// <param name="subCancelToken">Cancellation token</param>
protected internal async Task<CallResult> TrySubscribeAsync(Subscription subscription, bool newSubscription, CancellationToken subCancelToken)
{
subscription.ConnectionInvocations = 0;
if (!newSubscription)
{
if (!subscription.Active)
// Can be closed during resubscribing
return CallResult.SuccessResult;
var result = await ApiClient.RevitalizeRequestAsync(subscription).ConfigureAwait(false);
if (!result)
{
_logger.FailedRequestRevitalization(SocketId, result.Error?.ToString());
subscription.Status = SubscriptionStatus.Pending;
return result;
}
}
subscription.Status = SubscriptionStatus.Subscribing;
var subQuery = subscription.CreateSubscriptionQuery(this);
if (subQuery == null)
{
// No sub query, so successful
subscription.Status = SubscriptionStatus.Subscribed;
return CallResult.SuccessResult;
}
var subCompleteHandler = () =>
{
subscription.Status = subQuery.Result!.Success ? SubscriptionStatus.Subscribed : SubscriptionStatus.Pending;
subscription.HandleSubQueryResponse(this, subQuery.Response);
if (newSubscription && subQuery.Result.Success && subCancelToken != default)
{
subscription.CancellationTokenRegistration = subCancelToken.Register(async () =>
{
_logger.CancellationTokenSetClosingSubscription(SocketId, subscription.Id);
await CloseAsync(subscription).ConfigureAwait(false);
}, false);
}
};
subQuery.OnComplete = subCompleteHandler;
var subQueryResult = await SendAndWaitQueryAsync(subQuery).ConfigureAwait(false);
if (!subQueryResult)
{
_logger.FailedToSubscribe(SocketId, subQueryResult.Error?.ToString());
// If this was a server process error or timeout we still send an unsubscribe to prevent messages coming in later
if (newSubscription)
await CloseAsync(subscription).ConfigureAwait(false);
return new CallResult<UpdateSubscription>(subQueryResult.Error!);
}
if (!subQuery.ExpectsResponse)
subCompleteHandler();
return subQueryResult;
}
internal async Task UnsubscribeAsync(Subscription subscription)
{
var unsubscribeRequest = subscription.CreateUnsubscriptionQuery(this);
@@ -1206,6 +1193,37 @@ namespace CryptoExchange.Net.Sockets.Default
});
}
private void AddMessageProcessor(IMessageProcessor processor)
{
lock (_listenersLock)
{
var updatedList = new List<IMessageProcessor>(_listeners);
updatedList.Add(processor);
_listeners = updatedList.AsReadOnly();
}
}
private void RemoveMessageProcessor(IMessageProcessor processor)
{
lock (_listenersLock)
{
var updatedList = new List<IMessageProcessor>(_listeners);
updatedList.Remove(processor);
_listeners = updatedList.AsReadOnly();
}
}
private void RemoveMessageProcessors(IEnumerable<IMessageProcessor> processors)
{
lock (_listenersLock)
{
var updatedList = new List<IMessageProcessor>(_listeners);
foreach (var processor in processors)
updatedList.Remove(processor);
_listeners = updatedList.AsReadOnly();
}
}
}
}
@@ -174,6 +174,14 @@ namespace CryptoExchange.Net.Sockets.Default
{
ConnectionInvocations++;
TotalInvocations++;
if (SubscriptionQuery != null && !SubscriptionQuery.Completed && SubscriptionQuery.TimeoutBehavior == TimeoutBehavior.Succeed)
{
// The subscription query is one where it is successful if there is no error returned
// Since we've received a data update for the subscription we can assume the subscribe query was successful
// Call timeout to complete
SubscriptionQuery.Timeout();
}
return route.Handle(connection, receiveTime, originalData, data);
}
+3 -2
View File
@@ -127,8 +127,8 @@ namespace CryptoExchange.Net.Sockets
}
else
{
Completed = true;
Result = CallResult.SuccessResult;
Completed = true;
_event.Set();
}
}
@@ -216,12 +216,12 @@ namespace CryptoExchange.Net.Sockets
if (Completed)
return;
Completed = true;
if (TimeoutBehavior == TimeoutBehavior.Fail)
Result = new CallResult<THandlerResponse>(new TimeoutError());
else
Result = new CallResult<THandlerResponse>(default, null, default);
Completed = true;
_event.Set();
OnComplete?.Invoke();
}
@@ -234,6 +234,7 @@ namespace CryptoExchange.Net.Sockets
Result = new CallResult<THandlerResponse>(error);
Completed = true;
_event.Set();
OnComplete?.Invoke();
}
@@ -46,7 +46,7 @@ namespace CryptoExchange.Net.Testing.Implementations
Content = Encoding.UTF8.GetString(data);
}
public void SetContent(string data, string contentType)
public void SetContent(string data, Encoding? encoding, string contentType)
{
Content = data;
}
@@ -112,6 +112,8 @@ namespace CryptoExchange.Net.Testing.Implementations
public async Task ReconnectAsync()
{
await Task.Delay(1).ConfigureAwait(false);
if (OnReconnecting != null)
await OnReconnecting().ConfigureAwait(false);
@@ -332,7 +332,8 @@ namespace CryptoExchange.Net.Trackers.Klines
_data.Add(item.OpenTime, item);
}
_firstTimestamp = _data.Min(v => v.Key);
_firstTimestamp = _data.Count == 0 ? null : _data.Min(v => v.Key);
ApplyWindow(false);
_logger.KlineTrackerInitialDataSet(SymbolName, _data.Last().Key);
}
@@ -375,7 +376,7 @@ namespace CryptoExchange.Net.Trackers.Klines
}
}
_firstTimestamp = _data.Min(x => x.Key);
_firstTimestamp = _data.Count == 0 ? null : _data.Min(x => x.Key);
_changed = true;
SetSyncStatus();
@@ -259,11 +259,11 @@ namespace CryptoExchange.Net.Trackers.Trades
var startTime = Period == null ? DateTime.UtcNow.AddMinutes(-5) : DateTime.UtcNow.Add(-Period.Value);
var request = new GetTradeHistoryRequest(Symbol, startTime, DateTime.UtcNow);
var data = new List<SharedTrade>();
await foreach(var result in ExchangeHelpers.ExecutePages(_historyRestClient.GetTradeHistoryAsync, request).ConfigureAwait(false))
await foreach (var result in ExchangeHelpers.ExecutePages(_historyRestClient.GetTradeHistoryAsync, request).ConfigureAwait(false))
{
if (!result)
return result;
if (Limit != null && data.Count > Limit)
break;
@@ -16,13 +16,6 @@ namespace CryptoExchange.Net.Trackers.UserData.Interfaces
/// </summary>
bool Connected { get; }
/// <summary>
/// Currently tracked symbols. Data for these symbols will be requested when polling.
/// Websocket updates will be available for all symbols regardless.
/// When new data is received for a symbol which is not yet being tracked it will be added to this list and polled in the future unless the `OnlyTrackProvidedSymbols` option is set in the configuration.
/// </summary>
IEnumerable<SharedSymbol> TrackedSymbols { get; }
/// <summary>
/// On connection status change. Might trigger multiple times with the same status depending on the underlying subscriptions.
/// </summary>
@@ -2,6 +2,7 @@
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.Interfaces
@@ -26,6 +27,13 @@ namespace CryptoExchange.Net.Trackers.UserData.Interfaces
/// </summary>
public string Exchange { get; }
/// <summary>
/// Currently tracked symbols. Data for these symbols will be requested when polling.
/// Websocket updates will be available for all symbols regardless.
/// When new data is received for a symbol which is not yet being tracked it will be added to this list and polled in the future unless the `OnlyTrackProvidedSymbols` option is set in the configuration.
/// </summary>
IEnumerable<SharedSymbol> TrackedSymbols { get; }
/// <summary>
/// Balances tracker
/// </summary>
@@ -57,5 +65,18 @@ namespace CryptoExchange.Net.Trackers.UserData.Interfaces
/// </summary>
/// <returns></returns>
Task StopAsync();
/// <summary>
/// Add symbols to the list of symbols for which data is being tracked
/// </summary>
/// <param name="symbols">Symbols to add</param>
void AddTrackedSymbolsAsync(IEnumerable<SharedSymbol> symbols);
/// <summary>
/// Remove a symbol from the list of symbols for which data is being tracked.
/// Note that the symbol will be added again if new data for that symbol is received, unless the OnlyTrackProvidedSymbols option has been set to true.
/// </summary>
/// <param name="symbol">Symbol to remove</param>
void RemoveTrackedSymbolAsync(SharedSymbol symbol);
}
}
@@ -2,6 +2,7 @@
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.Interfaces
@@ -26,6 +27,13 @@ namespace CryptoExchange.Net.Trackers.UserData.Interfaces
/// </summary>
public string Exchange { get; }
/// <summary>
/// Currently tracked symbols. Data for these symbols will be requested when polling.
/// Websocket updates will be available for all symbols regardless.
/// When new data is received for a symbol which is not yet being tracked it will be added to this list and polled in the future unless the `OnlyTrackProvidedSymbols` option is set in the configuration.
/// </summary>
IEnumerable<SharedSymbol> TrackedSymbols { get; }
/// <summary>
/// Balances tracker
/// </summary>
@@ -53,5 +61,18 @@ namespace CryptoExchange.Net.Trackers.UserData.Interfaces
/// </summary>
/// <returns></returns>
Task StopAsync();
/// <summary>
/// Add symbols to the list of symbols for which data is being tracked
/// </summary>
/// <param name="symbols">Symbols to add</param>
void AddTrackedSymbolsAsync(IEnumerable<SharedSymbol> symbols);
/// <summary>
/// Remove a symbol from the list of symbols for which data is being tracked.
/// Note that the symbol will be added again if new data for that symbol is received, unless the OnlyTrackProvidedSymbols option has been set to true.
/// </summary>
/// <param name="symbol">Symbol to remove</param>
void RemoveTrackedSymbolAsync(SharedSymbol symbol);
}
}
@@ -22,12 +22,13 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// </summary>
public BalanceTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IBalanceRestClient restClient,
IBalanceSocketClient? socketClient,
SharedAccountType accountType,
TrackerItemConfig config,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Balances, restClient.Exchange, config, false, null)
) : base(logger, symbolTracker, UserDataType.Balances, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
@@ -19,6 +19,8 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
private readonly IFuturesOrderSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly bool _requiresSymbolParameterOpenOrders;
private readonly Dictionary<string, int> _openOrderNotReturnedTimes = new();
private readonly TimeSpan _pollOverlapPeriod = TimeSpan.FromSeconds(3);
internal event Func<UpdateSource, SharedUserTrade[], Task>? OnTradeUpdate;
@@ -27,13 +29,14 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// </summary>
public FuturesOrderTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IFuturesOrderRestClient restClient,
IFuturesOrderSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Orders, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
) : base(logger, symbolTracker, UserDataType.Orders, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
@@ -45,6 +48,20 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
_requiresSymbolParameterOpenOrders = restClient.GetOpenFuturesOrdersOptions.RequiredOptionalParameters.Any(x => x.Name == "Symbol");
}
internal void ClearDataForSymbol(SharedSymbol symbol)
{
foreach (var order in _store)
{
if (order.Value.SharedSymbol!.TradingMode == symbol.TradingMode
&& order.Value.SharedSymbol.BaseAsset == symbol.BaseAsset
&& order.Value.SharedSymbol.QuoteAsset == symbol.QuoteAsset
&& order.Value.SharedSymbol.DeliverTime == symbol.DeliverTime)
{
_store.TryRemove(order.Key, out _);
}
}
}
/// <inheritdoc />
protected override bool Update(SharedFuturesOrder existingItem, SharedFuturesOrder updateItem)
{
@@ -129,6 +146,14 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
// status changed from open to not open
return true;
if (existingItem.Status != SharedOrderStatus.Open
&& updateItem.Status != SharedOrderStatus.Open
&& existingItem.Status != updateItem.Status)
{
_logger.LogWarning("Invalid order update detected for order {OrderId}; current status: {OldStatus}, new status: {NewStatus}", existingItem.OrderId, existingItem.Status, updateItem.Status);
return false;
}
if (existingItem.Status != SharedOrderStatus.Open && updateItem.Status == SharedOrderStatus.Open)
// status changed from not open to open; stale
return false;
@@ -225,7 +250,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
else
{
foreach (var symbol in _symbols.ToList())
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var openOrdersResult = await _restClient.GetOpenFuturesOrdersAsync(new GetOpenOrdersRequest(symbol, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!openOrdersResult.Success)
@@ -243,11 +268,30 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
}
}
foreach (var symbol in _symbols.ToList())
if (!_firstPollDone && anyError)
return anyError;
// Check all current open orders
// Keep track of the orders no longer returned in the open list
// Order should be set to canceled state when it's no longer returned in the open list
// but also is not returned in the closed list
foreach (var order in Values.Where(x => x.Status == SharedOrderStatus.Open))
{
var fromTimeOrders = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
if (openOrders.Any(x => x.OrderId == order.OrderId))
continue;
if (!_openOrderNotReturnedTimes.ContainsKey(order.OrderId))
_openOrderNotReturnedTimes[order.OrderId] = 0;
_openOrderNotReturnedTimes[order.OrderId] += 1;
}
var updatedPollTime = DateTime.UtcNow;
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
DateTime? fromTimeOrders = GetClosedOrdersRequestStartTime(symbol);
var closedOrdersResult = await _restClient.GetClosedFuturesOrdersAsync(new GetClosedOrdersRequest(symbol, startTime: fromTimeOrders, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!closedOrdersResult.Success)
{
@@ -259,22 +303,26 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
else
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
// Filter orders to only include where close time is after the start time
var relevantOrders = closedOrdersResult.Data.Where(x =>
x.UpdateTime != null && x.UpdateTime >= _startTime // Updated after the tracker start time
|| x.CreateTime != null && x.CreateTime >= _startTime // Created after the tracker start time
|| x.CreateTime == null && x.UpdateTime == null // Unknown time
(x.UpdateTime != null && x.UpdateTime >= _startTime) // Updated after the tracker start time
|| (x.CreateTime != null && x.CreateTime >= _startTime) // Created after the tracker start time
|| (x.CreateTime == null && x.UpdateTime == null) // Unknown time
|| (Values.Any(e => e.OrderId == x.OrderId && x.Status == SharedOrderStatus.Open)) // Or we're currently tracking this open order
).ToArray();
// Check for orders which are no longer returned in either open/closed and assume they're canceled without fill
var openOrdersNotReturned = Values.Where(x =>
x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset // Orders for the same symbol
&& x.QuantityFilled?.IsZero == true // With no filled value
&& !openOrders.Any(r => r.OrderId == x.OrderId) // Not returned in open orders
&& !relevantOrders.Any(r => r.OrderId == x.OrderId) // Not return in closed orders
// Orders for the same symbol
x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset
// With no filled value
&& x.QuantityFilled?.IsZero == true
// Not returned in open orders
&& !openOrders.Any(r => r.OrderId == x.OrderId)
// Not returned in closed orders
&& !relevantOrders.Any(r => r.OrderId == x.OrderId)
// Open order has not been returned in the open list at least 2 times
&& (_openOrderNotReturnedTimes.TryGetValue(x.OrderId, out var notReturnedTimes) ? notReturnedTimes >= 2 : false)
).ToList();
var additionalUpdates = new List<SharedFuturesOrder>();
@@ -292,7 +340,64 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
}
if (!anyError)
{
_lastPollTime = updatedPollTime;
_lastDataTimeBeforeDisconnect = null;
}
return anyError;
}
private DateTime? GetClosedOrdersRequestStartTime(SharedSymbol symbol)
{
// Determine the timestamp from which we need to check order status
// Use the timestamp we last know the correct state of the data
DateTime? fromTime = null;
string? source = null;
// Use the last timestamp we we received data from the websocket as state should be correct at that time
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.Add(-_pollOverlapPeriod);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime.Value.Add(-_pollOverlapPeriod);
source = "LastPollTime";
}
// If we known open orders with a create time before this time we need to use that timestamp to make sure that order is included in the response
var trackedOrdersMinOpenTime = Values
.Where(x => x.Status == SharedOrderStatus.Open && x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset)
.OrderBy(x => x.CreateTime)
.FirstOrDefault()?.CreateTime;
if (trackedOrdersMinOpenTime.HasValue && (fromTime == null || trackedOrdersMinOpenTime.Value < fromTime))
{
// Could be improved by only requesting the specific open orders if there are only a few that would be better than trying to request a long
// history if the open order is far back
fromTime = trackedOrdersMinOpenTime.Value.AddSeconds(-1);
source = "OpenOrder";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
if (DateTime.UtcNow - fromTime < TimeSpan.FromSeconds(5))
{
// Set it to at least 5 seconds in the past to prevent issues when local time isn't in sync
fromTime = DateTime.UtcNow.AddSeconds(-5);
}
_logger.LogTrace("{DataType}.{Symbol} UserDataTracker poll startTime filter based on {Source}: {Time:yyyy-MM-dd HH:mm:ss.fff}",
DataType, $"{symbol.BaseAsset}/{symbol.QuoteAsset}", source, fromTime);
return fromTime!.Value;
}
}
}
@@ -18,6 +18,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
private readonly IFuturesOrderRestClient _restClient;
private readonly IUserTradeSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly TimeSpan _pollOverlapPeriod = TimeSpan.FromSeconds(3);
internal Func<string[]>? GetTrackedOrderIds { get; set; }
@@ -26,13 +27,14 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// </summary>
public FuturesUserTradeTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IFuturesOrderRestClient restClient,
IUserTradeSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Trades, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
) : base(logger, symbolTracker, UserDataType.Trades, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
@@ -42,6 +44,20 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
_exchangeParameters = exchangeParameters;
}
internal void ClearDataForSymbol(SharedSymbol symbol)
{
foreach (var order in _store)
{
if (order.Value.SharedSymbol!.TradingMode == symbol.TradingMode
&& order.Value.SharedSymbol.BaseAsset == symbol.BaseAsset
&& order.Value.SharedSymbol.QuoteAsset == symbol.QuoteAsset
&& order.Value.SharedSymbol.DeliverTime == symbol.DeliverTime)
{
_store.TryRemove(order.Key, out _);
}
}
}
/// <inheritdoc />
protected override string GetKey(SharedUserTrade item) => item.Id;
/// <inheritdoc />
@@ -55,10 +71,10 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
protected override async Task<bool> DoPollAsync()
{
var anyError = false;
foreach (var symbol in _symbols)
var fromTimeTrades = GetTradesRequestStartTime();
var updatedPollTime = DateTime.UtcNow;
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var fromTimeTrades = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
var tradesResult = await _restClient.GetFuturesUserTradesAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!tradesResult.Success)
{
@@ -80,9 +96,53 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
}
if (!anyError)
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
}
return anyError;
}
private DateTime? GetTradesRequestStartTime()
{
// Determine the timestamp from which we need to request trades from
// Use the timestamp we last know the correct state of the data
DateTime? fromTime = null;
string? source = null;
// Use the last timestamp we we received data from the websocket as state should be correct at that time.
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.Add(-_pollOverlapPeriod);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime.Value.Add(-_pollOverlapPeriod);
source = "LastPollTime";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
var now = DateTime.UtcNow;
if (now - fromTime < TimeSpan.FromSeconds(5))
{
// Set it to at least 5 seconds in the past to prevent issues when local time isn't in sync
fromTime = DateTime.UtcNow.AddSeconds(-5);
}
_logger.LogTrace("{DataType} UserDataTracker poll startTime filter based on {Source}: {Time:yyyy-MM-dd HH:mm:ss.fff}", DataType, source, fromTime);
return fromTime!.Value;
}
/// <inheritdoc />
protected override Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey)
{
@@ -29,6 +29,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// </summary>
public PositionTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IFuturesOrderRestClient restClient,
IPositionSocketClient? socketClient,
TrackerItemConfig config,
@@ -36,7 +37,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
bool onlyTrackProvidedSymbols,
bool websocketPositionUpdatesAreFullSnapshots,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Positions, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
) : base(logger, symbolTracker, UserDataType.Positions, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
@@ -118,9 +119,9 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
toRemove ??= new List<SharedPosition>();
toRemove.Add(item);
_logger.LogTrace("Ignoring {DataType} update for {Key}, no SharedSymbol set", DataType, item.Symbol);
}
else if (_onlyTrackProvidedSymbols
&& !_symbols.Any(y => y.TradingMode == symbolModel.SharedSymbol!.TradingMode && y.BaseAsset == symbolModel.SharedSymbol.BaseAsset && y.QuoteAsset == symbolModel.SharedSymbol.QuoteAsset))
else if (!_symbolTracker.ShouldProcess(symbolModel.SharedSymbol))
{
toRemove ??= new List<SharedPosition>();
toRemove.Add(item);
@@ -131,8 +132,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
if (toRemove != null)
@event = @event.Except(toRemove).ToArray();
if (!_onlyTrackProvidedSymbols)
UpdateSymbolsList(@event.Where(x => x.PositionSize > 0).OfType<SharedSymbolModel>().Select(x => x.SharedSymbol!));
_symbolTracker.UpdateTrackedSymbols(@event.Where(x => x.PositionSize > 0).OfType<SharedSymbolModel>().Select(x => x.SharedSymbol!));
// Update local store
@@ -19,6 +19,8 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
private readonly ISpotOrderSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly bool _requiresSymbolParameterOpenOrders;
private readonly Dictionary<string, int> _openOrderNotReturnedTimes = new();
private readonly TimeSpan _pollOverlapPeriod = TimeSpan.FromSeconds(3);
internal event Func<UpdateSource, SharedUserTrade[], Task>? OnTradeUpdate;
@@ -27,13 +29,14 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// </summary>
public SpotOrderTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
ISpotOrderRestClient restClient,
ISpotOrderSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Orders, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
) : base(logger, symbolTracker, UserDataType.Orders, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
@@ -45,6 +48,19 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
_requiresSymbolParameterOpenOrders = restClient.GetOpenSpotOrdersOptions.RequiredOptionalParameters.Any(x => x.Name == "Symbol");
}
internal void ClearDataForSymbol(SharedSymbol symbol)
{
foreach(var order in _store)
{
if (order.Value.SharedSymbol!.TradingMode == symbol.TradingMode
&& order.Value.SharedSymbol.BaseAsset == symbol.BaseAsset
&& order.Value.SharedSymbol.QuoteAsset == symbol.QuoteAsset)
{
_store.TryRemove(order.Key, out _);
}
}
}
/// <inheritdoc />
protected override bool Update(SharedSpotOrder existingItem, SharedSpotOrder updateItem)
{
@@ -140,6 +156,14 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
// status changed from open to not open
return true;
if (existingItem.Status != SharedOrderStatus.Open
&& updateItem.Status != SharedOrderStatus.Open
&& existingItem.Status != updateItem.Status)
{
_logger.LogWarning("Invalid order update detected for order {OrderId}; current status: {OldStatus}, new status: {NewStatus}", existingItem.OrderId, existingItem.Status, updateItem.Status);
return false;
}
if (existingItem.Status != SharedOrderStatus.Open && updateItem.Status == SharedOrderStatus.Open)
// status changed from not open to open; stale
return false;
@@ -236,7 +260,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
else
{
foreach (var symbol in _symbols.ToList())
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var openOrdersResult = await _restClient.GetOpenSpotOrdersAsync(new GetOpenOrdersRequest(symbol, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!openOrdersResult.Success)
@@ -258,10 +282,27 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
if (!_firstPollDone && anyError)
return anyError;
foreach (var symbol in _symbols.ToList())
// Check all current open orders
// Keep track of the orders no longer returned in the open list
// Order should be set to canceled state when it's no longer returned in the open list
// but also is not returned in the closed list
foreach (var order in Values.Where(x => x.Status == SharedOrderStatus.Open))
{
var fromTimeOrders = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
if (openOrders.Any(x => x.OrderId == order.OrderId))
continue;
if (!_openOrderNotReturnedTimes.ContainsKey(order.OrderId))
_openOrderNotReturnedTimes[order.OrderId] = 0;
_openOrderNotReturnedTimes[order.OrderId] += 1;
}
var updatedPollTime = DateTime.UtcNow;
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
DateTime? fromTimeOrders = GetClosedOrdersRequestStartTime(symbol);
var closedOrdersResult = await _restClient.GetClosedSpotOrdersAsync(new GetClosedOrdersRequest(symbol, startTime: fromTimeOrders, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!closedOrdersResult.Success)
{
@@ -273,22 +314,26 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
else
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
// Filter orders to only include where close time is after the start time
var relevantOrders = closedOrdersResult.Data.Where(x =>
x.UpdateTime != null && x.UpdateTime >= _startTime // Updated after the tracker start time
|| x.CreateTime != null && x.CreateTime >= _startTime // Created after the tracker start time
|| x.CreateTime == null && x.UpdateTime == null // Unknown time
(x.UpdateTime != null && x.UpdateTime >= _startTime) // Updated after the tracker start time
|| (x.CreateTime != null && x.CreateTime >= _startTime) // Created after the tracker start time
|| (x.CreateTime == null && x.UpdateTime == null) // Unknown time
|| (Values.Any(e => e.OrderId == x.OrderId && x.Status == SharedOrderStatus.Open)) // Or we're currently tracking this open order
).ToArray();
// Check for orders which are no longer returned in either open/closed and assume they're canceled without fill
var openOrdersNotReturned = Values.Where(x =>
x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset // Orders for the same symbol
&& x.QuantityFilled?.IsZero == true // With no filled value
&& !openOrders.Any(r => r.OrderId == x.OrderId) // Not returned in open orders
&& !relevantOrders.Any(r => r.OrderId == x.OrderId) // Not return in closed orders
// Orders for the same symbol
x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset
// With no filled value
&& x.QuantityFilled?.IsZero == true
// Not returned in open orders
&& !openOrders.Any(r => r.OrderId == x.OrderId)
// Not returned in closed orders
&& !relevantOrders.Any(r => r.OrderId == x.OrderId)
// Open order has not been returned in the open list at least 2 times
&& (_openOrderNotReturnedTimes.TryGetValue(x.OrderId, out var notReturnedTimes) ? notReturnedTimes >= 2 : false)
).ToList();
var additionalUpdates = new List<SharedSpotOrder>();
@@ -306,7 +351,64 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
}
if (!anyError)
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
}
return anyError;
}
private DateTime? GetClosedOrdersRequestStartTime(SharedSymbol symbol)
{
// Determine the timestamp from which we need to check order status
// Use the timestamp we last know the correct state of the data
DateTime? fromTime = null;
string? source = null;
// Use the last timestamp we we received data from the websocket as state should be correct at that time.
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.Add(-_pollOverlapPeriod);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime.Value.Add(-_pollOverlapPeriod);
source = "LastPollTime";
}
// If we known open orders with a create time before this time we need to use that timestamp to make sure that order is included in the response
var trackedOrdersMinOpenTime = Values
.Where(x => x.Status == SharedOrderStatus.Open && x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset)
.OrderBy(x => x.CreateTime)
.FirstOrDefault()?.CreateTime;
if (trackedOrdersMinOpenTime.HasValue && (fromTime == null || trackedOrdersMinOpenTime.Value < fromTime))
{
// Could be improved by only requesting the specific open orders if there are only a few that would be better than trying to request a long
// history if the open order is far back
fromTime = trackedOrdersMinOpenTime.Value.AddSeconds(-1);
source = "OpenOrder";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
if (DateTime.UtcNow - fromTime < TimeSpan.FromSeconds(5))
{
// Set it to at least 5 seconds in the past to prevent issues when local time isn't in sync
fromTime = DateTime.UtcNow.AddSeconds(-5);
}
_logger.LogTrace("{DataType}.{Symbol} UserDataTracker poll startTime filter based on {Source}: {Time:yyyy-MM-dd HH:mm:ss.fff}",
DataType, $"{symbol.BaseAsset}/{symbol.QuoteAsset}", source, fromTime);
return fromTime!.Value;
}
}
}
@@ -18,6 +18,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
private readonly ISpotOrderRestClient _restClient;
private readonly IUserTradeSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly TimeSpan _pollOverlapPeriod = TimeSpan.FromSeconds(3);
internal Func<string[]>? GetTrackedOrderIds { get; set; }
@@ -26,13 +27,14 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// </summary>
public SpotUserTradeTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
ISpotOrderRestClient restClient,
IUserTradeSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Trades, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
) : base(logger, symbolTracker, UserDataType.Trades, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
@@ -42,6 +44,19 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
_exchangeParameters = exchangeParameters;
}
internal void ClearDataForSymbol(SharedSymbol symbol)
{
foreach (var trade in _store)
{
if (trade.Value.SharedSymbol!.TradingMode == symbol.TradingMode
&& trade.Value.SharedSymbol.BaseAsset == symbol.BaseAsset
&& trade.Value.SharedSymbol.QuoteAsset == symbol.QuoteAsset)
{
_store.TryRemove(trade.Key, out _);
}
}
}
/// <inheritdoc />
protected override string GetKey(SharedUserTrade item) => item.Id;
/// <inheritdoc />
@@ -55,10 +70,10 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
protected override async Task<bool> DoPollAsync()
{
var anyError = false;
foreach (var symbol in _symbols)
var fromTimeTrades = GetTradesRequestStartTime();
var updatedPollTime = DateTime.UtcNow;
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var fromTimeTrades = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
var tradesResult = await _restClient.GetSpotUserTradesAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!tradesResult.Success)
{
@@ -70,8 +85,6 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
else
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
// Filter trades to only include where timestamp is after the start time OR it's part of an order we're tracking
var relevantTrades = tradesResult.Data.Where(x => x.Timestamp >= _startTime || (GetTrackedOrderIds?.Invoke() ?? []).Any(o => o == x.OrderId)).ToArray();
@@ -80,9 +93,52 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
}
if (!anyError)
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
}
return anyError;
}
private DateTime? GetTradesRequestStartTime()
{
// Determine the timestamp from which we need to request trades from
// Use the timestamp we last know the correct state of the data
DateTime? fromTime = null;
string? source = null;
// Use the last timestamp we we received data from the websocket as state should be correct at that time.
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.Add(-_pollOverlapPeriod);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime.Value.Add(-_pollOverlapPeriod);
source = "LastPollTime";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
if (DateTime.UtcNow - fromTime < TimeSpan.FromSeconds(5))
{
// Set it to at least 5 seconds in the past to prevent issues when local time isn't in sync
fromTime = DateTime.UtcNow.AddSeconds(-5);
}
_logger.LogTrace("{DataType} UserDataTracker poll startTime filter based on {Source}: {Time:yyyy-MM-dd HH:mm:ss.fff}", DataType, source, fromTime);
return fromTime!.Value;
}
/// <inheritdoc />
protected override Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey)
{
@@ -203,21 +203,14 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// </summary>
protected ConcurrentDictionary<string, T> _store = new ConcurrentDictionary<string, T>(StringComparer.InvariantCultureIgnoreCase);
/// <summary>
/// Tracked symbols list
/// </summary>
protected readonly List<SharedSymbol> _symbols;
/// <summary>
/// Symbol lock
/// </summary>
protected object _symbolLock = new object();
/// <summary>
/// Only track provided symbols setting
/// </summary>
protected bool _onlyTrackProvidedSymbols;
/// <summary>
/// Is SharedSymbol model
/// </summary>
protected bool _isSymbolModel;
/// <summary>
/// Symbol tracker
/// </summary>
protected readonly UserDataSymbolTracker _symbolTracker;
/// <inheritdoc />
public T[] Values
@@ -240,22 +233,23 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// <inheritdoc />
public event Func<UserDataUpdate<T[]>, Task>? OnUpdate;
/// <inheritdoc />
public IEnumerable<SharedSymbol> TrackedSymbols => _symbols;
/// <summary>
/// ctor
/// </summary>
public UserDataItemTracker(ILogger logger, UserDataType dataType, string exchange, TrackerItemConfig config, bool onlyTrackProvidedSymbols, IEnumerable<SharedSymbol>? symbols) : base(logger, dataType, exchange)
public UserDataItemTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
UserDataType dataType,
string exchange,
TrackerItemConfig config) : base(logger, dataType, exchange)
{
_onlyTrackProvidedSymbols = onlyTrackProvidedSymbols;
_symbols = symbols?.ToList() ?? [];
_pollIntervalDisconnected = config.PollIntervalDisconnected;
_pollIntervalConnected = config.PollIntervalConnected;
_pollAtStart = config.PollAtStart;
_retentionTime = config is TrackerTimedItemConfig timeConfig ? timeConfig.RetentionTime : TimeSpan.MaxValue;
_isSymbolModel = typeof(T).IsSubclassOf(typeof(SharedSymbolModel));
_symbolTracker = symbolTracker;
}
/// <summary>
@@ -334,26 +328,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
/// Get the age of an item
/// </summary>
protected virtual TimeSpan GetAge(DateTime time, T item) => TimeSpan.Zero;
/// <summary>
/// Update the tracked symbol list with potential new symbols
/// </summary>
/// <param name="symbols"></param>
protected void UpdateSymbolsList(IEnumerable<SharedSymbol> symbols)
{
lock (_symbolLock)
{
foreach (var symbol in symbols.Distinct())
{
if (!_symbols.Any(x => x.TradingMode == symbol.TradingMode && x.BaseAsset == symbol.BaseAsset && x.QuoteAsset == symbol.QuoteAsset))
{
_symbols.Add(symbol);
_logger.LogDebug("Adding {BaseAsset}/{QuoteAsset} to symbol tracking list", symbol.BaseAsset, symbol.QuoteAsset);
}
}
}
}
/// <summary>
/// Handle an update
/// </summary>
@@ -372,9 +347,9 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
toRemove ??= new List<T>();
toRemove.Add(item);
_logger.LogWarning("Ignoring {DataType} update for {Key}, no SharedSymbol set", DataType, GetKey(item));
}
else if (_onlyTrackProvidedSymbols
&& !_symbols.Any(y => y.TradingMode == symbolModel.SharedSymbol!.TradingMode && y.BaseAsset == symbolModel.SharedSymbol.BaseAsset && y.QuoteAsset == symbolModel.SharedSymbol.QuoteAsset))
else if (!_symbolTracker.ShouldProcess(symbolModel.SharedSymbol))
{
toRemove ??= new List<T>();
toRemove.Add(item);
@@ -385,8 +360,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
if (toRemove != null)
@event = @event.Except(toRemove).ToArray();
if (!_onlyTrackProvidedSymbols)
UpdateSymbolsList(@event.OfType<SharedSymbolModel>().Select(x => x.SharedSymbol!));
_symbolTracker.UpdateTrackedSymbols(@event.OfType<SharedSymbolModel>().Select(x => x.SharedSymbol!));
}
// Update local store
@@ -0,0 +1,85 @@
using CryptoExchange.Net.SharedApis;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace CryptoExchange.Net.Trackers.UserData.Objects
{
/// <summary>
/// Tracker for symbols used in UserDataTracker
/// </summary>
public class UserDataSymbolTracker
{
private readonly ILogger _logger;
private readonly List<SharedSymbol> _trackedSymbols;
private readonly bool _onlyTrackProvidedSymbols;
private readonly object _symbolLock = new object();
/// <summary>
/// ctor
/// </summary>
public UserDataSymbolTracker(ILogger logger, UserDataTrackerConfig config)
{
_logger = logger;
_trackedSymbols = config.TrackedSymbols?.ToList() ?? [];
_onlyTrackProvidedSymbols = config.OnlyTrackProvidedSymbols;
}
/// <summary>
/// Get currently tracked symbols
/// </summary>
/// <returns></returns>
public IEnumerable<SharedSymbol> GetTrackedSymbols()
{
lock (_symbolLock)
return _trackedSymbols.ToList();
}
/// <summary>
/// Check whether a symbol is in the tracked symbols list and should be processed
/// </summary>
public bool ShouldProcess(SharedSymbol symbol)
{
if (!_onlyTrackProvidedSymbols)
return true;
return _trackedSymbols.Any(y => y.TradingMode == symbol!.TradingMode && y.BaseAsset == symbol.BaseAsset && y.QuoteAsset == symbol.QuoteAsset);
}
/// <summary>
/// Update the tracked symbol list with potential new symbols
/// </summary>
public void UpdateTrackedSymbols(IEnumerable<SharedSymbol> symbols, bool addByUser = false)
{
if (!addByUser && _onlyTrackProvidedSymbols)
return;
lock (_symbolLock)
{
foreach (var symbol in symbols.Distinct())
{
if (!_trackedSymbols.Any(x => x.TradingMode == symbol.TradingMode && x.BaseAsset == symbol.BaseAsset && x.QuoteAsset == symbol.QuoteAsset))
{
_trackedSymbols.Add(symbol);
_logger.LogDebug("Adding {TradingMode}.{BaseAsset}/{QuoteAsset} to symbol tracking list", symbol.TradingMode, symbol.BaseAsset, symbol.QuoteAsset);
}
}
}
}
/// <summary>
/// Remove a symbol from the list
/// </summary>
public void RemoveTrackedSymbol(SharedSymbol symbol)
{
lock (_symbolLock)
{
var symbolToRemove = _trackedSymbols.SingleOrDefault(x => x.TradingMode == symbol.TradingMode && x.BaseAsset == symbol.BaseAsset && x.QuoteAsset == symbol.QuoteAsset);
if (symbolToRemove != null)
_trackedSymbols.Remove(symbolToRemove);
}
}
}
}
@@ -1,10 +1,12 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.ItemTrackers;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData
@@ -22,11 +24,21 @@ namespace CryptoExchange.Net.Trackers.UserData
/// Listen key to use for subscriptions
/// </summary>
protected string? _listenKey;
/// <summary>
/// Cts
/// </summary>
protected CancellationTokenSource? _cts;
/// <summary>
/// List of data trackers
/// </summary>
protected abstract UserDataItemTracker[] DataTrackers { get; }
/// <summary>
/// Symbol tracker
/// </summary>
protected internal UserDataSymbolTracker SymbolTracker { get; }
/// <inheritdoc />
public string? UserIdentifier { get; }
@@ -45,6 +57,11 @@ namespace CryptoExchange.Net.Trackers.UserData
/// </summary>
public bool Connected => DataTrackers.All(x => x.Connected);
/// <summary>
/// Currently tracked symbols
/// </summary>
public IEnumerable<SharedSymbol> TrackedSymbols => SymbolTracker.GetTrackedSymbols();
/// <summary>
/// ctor
/// </summary>
@@ -54,11 +71,9 @@ namespace CryptoExchange.Net.Trackers.UserData
UserDataTrackerConfig config,
string? userIdentifier)
{
if (config.OnlyTrackProvidedSymbols && !config.TrackedSymbols.Any())
throw new ArgumentException(nameof(config.TrackedSymbols), "Conflicting options; `OnlyTrackProvidedSymbols` but no symbols specific in `TrackedSymbols`");
_logger = logger;
SymbolTracker = new UserDataSymbolTracker(logger, config);
Exchange = exchange;
UserIdentifier = userIdentifier;
}
@@ -68,6 +83,8 @@ namespace CryptoExchange.Net.Trackers.UserData
/// </summary>
public async Task<CallResult> StartAsync()
{
_cts = new CancellationTokenSource();
foreach(var tracker in DataTrackers)
tracker.OnConnectedChange += (x) => OnConnectedChange?.Invoke(tracker.DataType, x);
@@ -100,12 +117,21 @@ namespace CryptoExchange.Net.Trackers.UserData
public async Task StopAsync()
{
_logger.LogDebug("Stopping UserDataTracker");
_cts?.Cancel();
var tasks = new List<Task>();
foreach (var dataTracker in DataTrackers)
tasks.Add(dataTracker.StopAsync());
await DoStopAsync().ConfigureAwait(false);
await Task.WhenAll(tasks).ConfigureAwait(false);
_logger.LogDebug("Stopped UserDataTracker");
}
/// <summary>
/// Stop implementation
/// </summary>
/// <returns></returns>
protected virtual Task DoStopAsync() => Task.CompletedTask;
}
}
@@ -20,6 +20,8 @@ namespace CryptoExchange.Net.Trackers.UserData
private readonly IFuturesSymbolRestClient _symbolClient;
private readonly IListenKeyRestClient? _listenKeyClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly TradingMode _tradingMode;
private Task? _lkKeepAliveTask;
/// <inheritdoc />
protected override UserDataItemTracker[] DataTrackers { get; }
@@ -68,24 +70,28 @@ namespace CryptoExchange.Net.Trackers.UserData
_listenKeyClient = listenKeyRestClient;
_exchangeParameters = exchangeParameters;
_tradingMode = accountType == SharedAccountType.PerpetualInverseFutures ? TradingMode.PerpetualInverse :
accountType == SharedAccountType.DeliveryLinearFutures ? TradingMode.DeliveryLinear :
accountType == SharedAccountType.DeliveryInverseFutures ? TradingMode.DeliveryInverse :
TradingMode.PerpetualLinear;
var trackers = new List<UserDataItemTracker>();
var balanceAccountType = accountType ?? SharedAccountType.PerpetualLinearFutures;
var balanceTracker = new BalanceTracker(logger, balanceRestClient, balanceSocketClient, balanceAccountType, config.BalancesConfig, exchangeParameters);
var balanceTracker = new BalanceTracker(logger, SymbolTracker, balanceRestClient, balanceSocketClient, accountType ?? SharedAccountType.PerpetualLinearFutures, config.BalancesConfig, exchangeParameters);
Balances = balanceTracker;
trackers.Add(balanceTracker);
var orderTracker = new FuturesOrderTracker(logger, futuresOrderRestClient, futuresOrderSocketClient, config.OrdersConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
var orderTracker = new FuturesOrderTracker(logger, SymbolTracker, futuresOrderRestClient, futuresOrderSocketClient, config.OrdersConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Orders = orderTracker;
trackers.Add(orderTracker);
var positionTracker = new PositionTracker(logger, futuresOrderRestClient, positionSocketClient, config.PositionConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, WebsocketPositionUpdatesAreFullSnapshots, exchangeParameters);
var positionTracker = new PositionTracker(logger, SymbolTracker, futuresOrderRestClient, positionSocketClient, config.PositionConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, WebsocketPositionUpdatesAreFullSnapshots, exchangeParameters);
Positions = positionTracker;
trackers.Add(positionTracker);
if (config.TrackTrades)
{
var tradeTracker = new FuturesUserTradeTracker(logger, futuresOrderRestClient, userTradeSocketClient, config.UserTradesConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
var tradeTracker = new FuturesUserTradeTracker(logger, SymbolTracker, futuresOrderRestClient, userTradeSocketClient, config.UserTradesConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Trades = tradeTracker;
trackers.Add(tradeTracker);
@@ -99,7 +105,7 @@ namespace CryptoExchange.Net.Trackers.UserData
/// <inheritdoc />
protected override async Task<CallResult> DoStartAsync()
{
var symbolResult = await _symbolClient.GetFuturesSymbolsAsync(new GetSymbolsRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
var symbolResult = await _symbolClient.GetFuturesSymbolsAsync(new GetSymbolsRequest(_tradingMode, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!symbolResult)
{
_logger.LogWarning("Failed to start UserFuturesDataTracker; symbols request failed: {Error}", symbolResult.Error);
@@ -108,17 +114,70 @@ namespace CryptoExchange.Net.Trackers.UserData
if (_listenKeyClient != null)
{
var lkResult = await _listenKeyClient.StartListenKeyAsync(new StartListenKeyRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
var lkResult = await _listenKeyClient.StartListenKeyAsync(new StartListenKeyRequest(_tradingMode, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!lkResult)
{
_logger.LogWarning("Failed to start UserFuturesDataTracker; listen key request failed: {Error}", lkResult.Error);
return lkResult;
}
_lkKeepAliveTask = KeepAliveListenKeyAsync();
_listenKey = lkResult.Data;
}
return CallResult.SuccessResult;
}
/// <inheritdoc />
protected override async Task DoStopAsync()
{
if (_lkKeepAliveTask != null)
await _lkKeepAliveTask.ConfigureAwait(false);
}
private async Task KeepAliveListenKeyAsync()
{
var interval = TimeSpan.FromMinutes(30);
while (!_cts!.IsCancellationRequested)
{
try { await Task.Delay(interval, _cts.Token).ConfigureAwait(false); } catch (Exception)
{
break;
}
var result = await _listenKeyClient!.KeepAliveListenKeyAsync(new KeepAliveListenKeyRequest(_listenKey!, _tradingMode)).ConfigureAwait(false);
if (!result)
_logger.LogWarning("Listen key keep alive failed: " + result.Error);
// If failed shorten the delay to allow a couple more retries
interval = result ? TimeSpan.FromMinutes(30) : TimeSpan.FromMinutes(5);
}
}
/// <summary>
/// Add symbols to the list of symbols for which data is being tracked
/// </summary>
/// <param name="symbols">Symbols to add</param>
public void AddTrackedSymbolsAsync(IEnumerable<SharedSymbol> symbols)
{
if (symbols.Any(x => x.TradingMode == TradingMode.Spot))
throw new ArgumentException("Spot symbol not allowed in futures tracker", nameof(symbols));
SymbolTracker.UpdateTrackedSymbols(symbols, true);
}
/// <summary>
/// Remove a symbol from the list of symbols for which data is being tracked.
/// Note that the symbol will be added again if new data for that symbol is received, unless the OnlyTrackProvidedSymbols option has been set to true.
/// </summary>
/// <param name="symbol">Symbol to remove</param>
public void RemoveTrackedSymbolAsync(SharedSymbol symbol)
{
SymbolTracker.RemoveTrackedSymbol(symbol);
((FuturesOrderTracker)Orders).ClearDataForSymbol(symbol);
((FuturesUserTradeTracker?)Trades)?.ClearDataForSymbol(symbol);
}
}
}
@@ -7,6 +7,7 @@ using System.Linq;
using CryptoExchange.Net.Trackers.UserData.Interfaces;
using CryptoExchange.Net.Trackers.UserData.Objects;
using CryptoExchange.Net.Trackers.UserData.ItemTrackers;
using System;
namespace CryptoExchange.Net.Trackers.UserData
{
@@ -18,6 +19,7 @@ namespace CryptoExchange.Net.Trackers.UserData
private readonly ISpotSymbolRestClient _symbolClient;
private readonly IListenKeyRestClient? _listenKeyClient;
private readonly ExchangeParameters? _exchangeParameters;
private Task? _lkKeepAliveTask;
/// <inheritdoc />
protected override UserDataItemTracker[] DataTrackers { get; }
@@ -51,17 +53,17 @@ namespace CryptoExchange.Net.Trackers.UserData
var trackers = new List<UserDataItemTracker>();
var balanceTracker = new BalanceTracker(logger, balanceRestClient, balanceSocketClient, SharedAccountType.Spot, config.BalancesConfig, exchangeParameters);
var balanceTracker = new BalanceTracker(logger, SymbolTracker, balanceRestClient, balanceSocketClient, SharedAccountType.Spot, config.BalancesConfig, exchangeParameters);
Balances = balanceTracker;
trackers.Add(balanceTracker);
var orderTracker = new SpotOrderTracker(logger, spotOrderRestClient, spotOrderSocketClient, config.OrdersConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
var orderTracker = new SpotOrderTracker(logger, SymbolTracker, spotOrderRestClient, spotOrderSocketClient, config.OrdersConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Orders = orderTracker;
trackers.Add(orderTracker);
if (config.TrackTrades)
{
var tradeTracker = new SpotUserTradeTracker(logger, spotOrderRestClient, userTradeSocketClient, config.UserTradesConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
var tradeTracker = new SpotUserTradeTracker(logger, SymbolTracker, spotOrderRestClient, userTradeSocketClient, config.UserTradesConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Trades = tradeTracker;
trackers.Add(tradeTracker);
@@ -91,10 +93,64 @@ namespace CryptoExchange.Net.Trackers.UserData
return lkResult;
}
_lkKeepAliveTask = KeepAliveListenKeyAsync();
_listenKey = lkResult.Data;
}
return CallResult.SuccessResult;
}
/// <inheritdoc />
protected override async Task DoStopAsync()
{
if (_lkKeepAliveTask != null)
await _lkKeepAliveTask.ConfigureAwait(false);
}
private async Task KeepAliveListenKeyAsync()
{
var interval = TimeSpan.FromMinutes(30);
while (!_cts!.IsCancellationRequested)
{
try { await Task.Delay(interval, _cts.Token).ConfigureAwait(false); }
catch (Exception)
{
break;
}
var result = await _listenKeyClient!.KeepAliveListenKeyAsync(new KeepAliveListenKeyRequest(_listenKey!, TradingMode.Spot)).ConfigureAwait(false);
if (!result)
_logger.LogWarning("Listen key keep alive failed: " + result.Error);
// If failed shorten the delay to allow a couple more retries
interval = result ? TimeSpan.FromMinutes(30) : TimeSpan.FromMinutes(5);
}
}
/// <summary>
/// Add symbols to the list of symbols for which data is being tracked
/// </summary>
/// <param name="symbols">Symbols to add</param>
public void AddTrackedSymbolsAsync(IEnumerable<SharedSymbol> symbols)
{
if (symbols.Any(x => x.TradingMode != TradingMode.Spot))
throw new ArgumentException("Futures symbol not allowed in spot tracker", nameof(symbols));
SymbolTracker.UpdateTrackedSymbols(symbols, true);
}
/// <summary>
/// Remove a symbol from the list of symbols for which data is being tracked. Also removes stored data for that symbol.
/// Note that the symbol will be added again if new data for that symbol is received, unless the OnlyTrackProvidedSymbols option has been set to true.
/// </summary>
/// <param name="symbol">Symbol to remove</param>
public void RemoveTrackedSymbolAsync(SharedSymbol symbol)
{
SymbolTracker.RemoveTrackedSymbol(symbol);
((SpotOrderTracker)Orders).ClearDataForSymbol(symbol);
((SpotUserTradeTracker?)Trades)?.ClearDataForSymbol(symbol);
}
}
}
+25 -25
View File
@@ -5,32 +5,32 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Binance.Net" Version="12.1.0" />
<PackageReference Include="Bitfinex.Net" Version="10.2.0" />
<PackageReference Include="BitMart.Net" Version="3.1.0" />
<PackageReference Include="BloFin.Net" Version="2.1.1" />
<PackageReference Include="Bybit.Net" Version="6.1.0" />
<PackageReference Include="CoinEx.Net" Version="10.1.0" />
<PackageReference Include="CoinW.Net" Version="2.1.1" />
<PackageReference Include="CryptoCom.Net" Version="3.1.0" />
<PackageReference Include="DeepCoin.Net" Version="3.1.0" />
<PackageReference Include="GateIo.Net" Version="3.1.0" />
<PackageReference Include="HyperLiquid.Net" Version="3.2.0" />
<PackageReference Include="JK.BingX.Net" Version="3.1.0" />
<PackageReference Include="JK.Bitget.Net" Version="3.1.0" />
<PackageReference Include="JK.Mexc.Net" Version="4.1.0" />
<PackageReference Include="JK.OKX.Net" Version="4.1.0" />
<PackageReference Include="Jkorf.Aster.Net" Version="2.1.0" />
<PackageReference Include="JKorf.BitMEX.Net" Version="3.1.0" />
<PackageReference Include="JKorf.Coinbase.Net" Version="3.1.0" />
<PackageReference Include="JKorf.HTX.Net" Version="8.1.0" />
<PackageReference Include="JKorf.Upbit.Net" Version="2.1.0" />
<PackageReference Include="KrakenExchange.Net" Version="7.1.0" />
<PackageReference Include="Kucoin.Net" Version="8.1.0" />
<PackageReference Include="Binance.Net" Version="12.5.0" />
<PackageReference Include="Bitfinex.Net" Version="10.6.0" />
<PackageReference Include="BitMart.Net" Version="3.5.0" />
<PackageReference Include="BloFin.Net" Version="2.5.0" />
<PackageReference Include="Bybit.Net" Version="6.5.0" />
<PackageReference Include="CoinEx.Net" Version="10.5.0" />
<PackageReference Include="CoinW.Net" Version="2.5.0" />
<PackageReference Include="CryptoCom.Net" Version="3.5.0" />
<PackageReference Include="DeepCoin.Net" Version="3.5.0" />
<PackageReference Include="GateIo.Net" Version="3.5.0" />
<PackageReference Include="HyperLiquid.Net" Version="3.7.0" />
<PackageReference Include="JK.BingX.Net" Version="3.5.0" />
<PackageReference Include="JK.Bitget.Net" Version="3.5.0" />
<PackageReference Include="JK.Mexc.Net" Version="4.5.0" />
<PackageReference Include="JK.OKX.Net" Version="4.5.0" />
<PackageReference Include="Jkorf.Aster.Net" Version="2.5.0" />
<PackageReference Include="JKorf.BitMEX.Net" Version="3.5.0" />
<PackageReference Include="JKorf.Coinbase.Net" Version="3.5.1" />
<PackageReference Include="JKorf.HTX.Net" Version="8.5.0" />
<PackageReference Include="JKorf.Upbit.Net" Version="2.5.0" />
<PackageReference Include="KrakenExchange.Net" Version="7.5.0" />
<PackageReference Include="Kucoin.Net" Version="8.5.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Toobit.Net" Version="2.1.0" />
<PackageReference Include="WhiteBit.Net" Version="3.1.0" />
<PackageReference Include="XT.Net" Version="3.1.0" />
<PackageReference Include="Toobit.Net" Version="3.5.0" />
<PackageReference Include="WhiteBit.Net" Version="3.5.0" />
<PackageReference Include="XT.Net" Version="3.5.0" />
</ItemGroup>
</Project>
+14 -14
View File
@@ -6,20 +6,20 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Binance.Net" Version="12.1.0" />
<PackageReference Include="Bitfinex.Net" Version="10.2.0" />
<PackageReference Include="BitMart.Net" Version="3.1.0" />
<PackageReference Include="Bybit.Net" Version="6.1.0" />
<PackageReference Include="CoinEx.Net" Version="10.1.0" />
<PackageReference Include="CryptoCom.Net" Version="3.1.0" />
<PackageReference Include="GateIo.Net" Version="3.1.0" />
<PackageReference Include="JK.Bitget.Net" Version="3.1.0" />
<PackageReference Include="JK.Mexc.Net" Version="4.1.0" />
<PackageReference Include="JK.OKX.Net" Version="4.1.0" />
<PackageReference Include="JKorf.Coinbase.Net" Version="3.1.0" />
<PackageReference Include="JKorf.HTX.Net" Version="8.1.0" />
<PackageReference Include="KrakenExchange.Net" Version="7.1.0" />
<PackageReference Include="Kucoin.Net" Version="8.1.0" />
<PackageReference Include="Binance.Net" Version="12.5.0" />
<PackageReference Include="Bitfinex.Net" Version="10.6.0" />
<PackageReference Include="BitMart.Net" Version="3.5.0" />
<PackageReference Include="Bybit.Net" Version="6.5.0" />
<PackageReference Include="CoinEx.Net" Version="10.5.0" />
<PackageReference Include="CryptoCom.Net" Version="3.5.0" />
<PackageReference Include="GateIo.Net" Version="3.5.0" />
<PackageReference Include="JK.Bitget.Net" Version="3.5.0" />
<PackageReference Include="JK.Mexc.Net" Version="4.5.0" />
<PackageReference Include="JK.OKX.Net" Version="4.5.0" />
<PackageReference Include="JKorf.Coinbase.Net" Version="3.5.1" />
<PackageReference Include="JKorf.HTX.Net" Version="8.5.0" />
<PackageReference Include="KrakenExchange.Net" Version="7.5.0" />
<PackageReference Include="Kucoin.Net" Version="8.5.0" />
</ItemGroup>
</Project>
+3 -3
View File
@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Binance.Net" Version="12.1.0" />
<PackageReference Include="BitMart.Net" Version="3.1.0" />
<PackageReference Include="JK.OKX.Net" Version="4.1.0" />
<PackageReference Include="Binance.Net" Version="12.5.0" />
<PackageReference Include="BitMart.Net" Version="3.5.0" />
<PackageReference Include="JK.OKX.Net" Version="4.5.0" />
</ItemGroup>
</Project>
+66
View File
@@ -67,6 +67,72 @@ Make a one time donation in a crypto currency of your choice. If you prefer to d
Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf).
## Release notes
* Version 10.8.0 - 06 Mar 2026
* Added `RequestBodyContentEncoding` and `OmitContentTypeHeaderWithoutContent` config to RestApiClient
* Added `ForcePathEndWithSlash` setting to RequestDefinition
* Added `encoding` parameter to SetContent on REST Request
* Version 10.7.2 - 02 Mar 2026
* Added small overlap in UserDataTracker polling logic to account for API endpoints not immediately having the data available
* Version 10.7.1 - 25 Feb 2026
* Fixed deadlock scenario in websocket connection when subscribe and handling message concurrently
* Version 10.7.0 - 24 Feb 2026
* Added parsing of REST response data up to 128 characters for error responses
* Added check for invalid json in JsonSocketMessageHandler
* Added virtual GetTypeIdentifierNonJson for handling non-json messages in JsonSocketMessageHandler
* Added additional options to Rest client options for configuring HttpClient
* Updated INextPageToken parameter on Shared interfaces to PageRequest type, functionality unchanged
* Added SupportsAscending and SupportsDescending properties to PaginatedEndpointOptions to expose supported data directions
* Added MaxAge property to PaginatedEndpointOptions to expose the max age of data that can be requested
* Added Direction property to Shared interfaces paginated requests to configure pagination data direction
* Removed PaginationSupport property from PaginatedEndpointOptions, replaced by above new properties
* Updated Shared GetTradeHistoryRequest EndTime property to be optional
* Updated I(Futures/Spot)OrderRestClient.GetClosed(Futures/Spot)OrdersOptions from PaginatedEndpointOptions<GetClosedOrdersRequest> to GetClosedOrdersOptions
* Updated I(Futures/Spot)OrderRestClient.Get(Futures/Spot)UserTradesOptions from PaginatedEndpointOptions<GetUserTradesRequest> to GetUserTradesOptions
* Updated rate limiting PathStartFilter to ignore added or missing slash before the path
* Updated internal lock for subscription to ReaderWriterLockSlim on SocketConnection
* Removed check for OnlyTrackProvidedSymbols in combination with empty TrackedSymbols list
* Fixed KlineTracker throwing exception if there is no data in the initial snapshot
* Version 10.6.2 - 17 Feb 2026
* Fix for websocket queries which don't expects response getting stuck in subscribing state
* Version 10.6.1 - 16 Feb 2026
* Fixed exception when stopping SymbolOrderBook instance when update is received while closing
* Version 10.6.0 - 16 Feb 2026
* Updated symbol tracking logic on UserDataTracker, now is per UserDataTracker instead of per topic
* Added check for startTime filter for polling being to close to current time which can cause issues if time isn't in sync with server
* Added AddTrackedSymbolsAsync and RemoveTrackedSymbolAsync methods to UserDataTracker
* Added check SymbolOrderBook is still alive when trying to add updates to prevent unnoticed growing in the background when subscription isn't closed while book is
* Version 10.5.4 - 12 Feb 2026
* Fixed type check ExchangeParameters GetValue
* Fixed bug in polling time filter for UserDataTracker item
* Version 10.5.3 - 11 Feb 2026
* Fixed orders getting incorrectly set to canceled state for UserDataTracker spot and futures orders
* Added check EnumConverter to detect undefined int value parsing
* Version 10.5.2 - 10 Feb 2026
* Added check for subscribe queries with TimeoutBehavior.Success to complete when subscription has received update
* Added call to ApiClient.HandleUnhandledMessage when no websocket message processor is found based on topic to allow additional processing
* Combined websocket connection subscribe and re-subscribe logic
* Set websocket query completed after setting Result
* Version 10.5.1 - 10 Feb 2026
* Fixed trading mode selection for futures listen key methods in FuturesUserDataTracker
* Version 10.5.0 - 10 Feb 2026
* Added keep alive for listenkeys to UserDataTracker
* Updated logging unmatched websocket message
* Updated websocket message forwarding logic
* Fixed bug in IncomingKbps calculation
* Fixed bug in SendAsync in SocketConnection
* Fixed bug in UserDataTracker orders logic incorrectly setting order to canceled status
* Version 10.4.1 - 06 Feb 2026
* Updated UserDataTracker to only track symbol when position size > 0
* Update UserDataTracker log verbosity