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

Compare commits

...

42 Commits

Author SHA1 Message Date
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
Jkorf 87dd2d9d40 Updated to version 10.4.1 2026-02-06 17:19:08 +01:00
Jkorf f2b0cb2f0d Updated UserDataTracker to only track symbol when position size > 0, update log verbosity 2026-02-06 17:16:31 +01:00
Jkorf de0a954a91 Updated to version 10.4.0 2026-02-06 12:00:50 +01:00
Jkorf 4a79ce22ec Added timeout/ct task cleanup AsyncResetEvent 2026-02-06 11:57:56 +01:00
JKorf 40d480e1fc Added AutoTimestamp option for socket client, fixed socket client timestamp offset bug 2026-02-05 21:54:16 +01:00
Jan Korf 74e5cf6fc9 Feature/userdata tracker (#271)
Added user data tracking logic
Added LastReceiveTime, SocketStatus and SubscriptionStatus properties to UpdateSubscription
Added SharedTransferStatus Enum and property to SharedDeposit
Added PositionMode property to SharedPosition model
Added IsZero property to SharedQuantity
Renamed IWebSocket LastActionTime to LastReceiveTime
Updated CryptoExchangeWebsocketClient LastReceiveTime logic
Updated Subscription status change event handler to run sync instead of separate task
2026-02-05 16:05:13 +01:00
Jkorf 2fd3912795 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2026-02-03 08:47:26 +01:00
JKorf ec44307a0c Updated AsyncResetEvent implementation 2026-02-02 21:01:35 +01:00
Jkorf ba55705385 Added Disposed property on BaseClient and IRestClient/ISocketClient interfaces 2026-02-02 09:33:47 +01:00
Jkorf 2c63a83117 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2026-02-02 08:42:53 +01:00
JKorf 71b1e5e906 Added additional methods for requesting supported symbols to Shared ISpotSymbolRestClient/IFuturesSymbolRestClient interfaces 2026-02-01 15:02:48 +01:00
Jkorf eaeba6f27e Made Interval property of Kline tracker public 2026-01-28 10:31:44 +01:00
65 changed files with 5078 additions and 318 deletions
@@ -4,6 +4,7 @@ using NUnit.Framework.Legacy;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.UnitTests
@@ -139,5 +140,17 @@ namespace CryptoExchange.Net.UnitTests
ClassicAssert.False(result1);
}
[Test]
public async Task CancellingWait_Should_ReturnFalse()
{
var evnt = new AsyncResetEvent(false, true);
var waiter1 = evnt.WaitAsync(ct: new CancellationTokenSource(50).Token);
var result1 = await waiter1;
ClassicAssert.False(result1);
}
}
}
@@ -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);
}
}
}
@@ -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));
}
}
}
@@ -518,11 +518,14 @@ namespace CryptoExchange.Net.Authentication
/// </summary>
protected DateTime GetTimestamp(SocketApiClient apiClient, bool includeOneSecondOffset = true)
{
var result = TimeProvider.GetTime().Add(TimeOffsetManager.GetSocketOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
if (includeOneSecondOffset)
result = result.AddSeconds(-1);
var timestamp = TimeProvider.GetTime();
if(apiClient.ApiOptions.AutoTimestamp ?? apiClient.ClientOptions.AutoTimestamp)
timestamp = timestamp.Add(-TimeOffsetManager.GetSocketOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
return result;
if (includeOneSecondOffset)
timestamp = timestamp.AddSeconds(-1);
return timestamp;
}
/// <summary>
+4 -1
View File
@@ -13,7 +13,10 @@ namespace CryptoExchange.Net.Clients
/// </summary>
public abstract class BaseApiClient : IDisposable, IBaseApiClient
{
private string? _clientName;
/// <summary>
/// Client name
/// </summary>
protected string? _clientName;
/// <summary>
/// Logger
+7
View File
@@ -39,6 +39,11 @@ namespace CryptoExchange.Net.Clients
/// </summary>
public string Exchange { get; }
/// <summary>
/// Whether client is disposed
/// </summary>
public bool Disposed { get; private set; }
/// <summary>
/// Api clients in this client
/// </summary>
@@ -125,6 +130,8 @@ namespace CryptoExchange.Net.Clients
/// </summary>
public virtual void Dispose()
{
Disposed = true;
foreach (var client in ApiClients)
client.Dispose();
}
+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)
+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.3.1</PackageVersion>
<AssemblyVersion>10.3.1</AssemblyVersion>
<FileVersion>10.3.1</FileVersion>
<PackageVersion>10.6.2</PackageVersion>
<AssemblyVersion>10.6.2</AssemblyVersion>
<FileVersion>10.6.2</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>
+60
View File
@@ -32,6 +32,66 @@ namespace CryptoExchange.Net
_symbolInfos[topicId] = new ExchangeInfo(DateTime.UtcNow, updateData.ToDictionary(x => x.Name, x => x.SharedSymbol));
}
/// <summary>
/// Whether the specific topic has been cached
/// </summary>
/// <param name="topicId">Id</param>
public static bool HasCached(string topicId)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return false;
return exchangeInfo.Symbols.Count > 0;
}
/// <summary>
/// Whether a specific exchange(topic) support the provided symbol
/// </summary>
/// <param name="topicId">Id for the provided data</param>
/// <param name="symbolName">The symbol name</param>
public static bool SupportsSymbol(string topicId, string symbolName)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return false;
if (!exchangeInfo.Symbols.TryGetValue(symbolName, out var symbolInfo))
return false;
return true;
}
/// <summary>
/// Whether a specific exchange(topic) support the provided symbol
/// </summary>
/// <param name="topicId">Id for the provided data</param>
/// <param name="symbol">The symbol info</param>
public static bool SupportsSymbol(string topicId, SharedSymbol symbol)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return false;
return exchangeInfo.Symbols.Any(x =>
x.Value.TradingMode == symbol.TradingMode
&& x.Value.BaseAsset == symbol.BaseAsset
&& x.Value.QuoteAsset == symbol.QuoteAsset);
}
/// <summary>
/// Get all symbols for a specific base asset
/// </summary>
/// <param name="topicId">Id for the provided data</param>
/// <param name="baseAsset">Base asset name</param>
public static SharedSymbol[] GetSymbolsForBaseAsset(string topicId, string baseAsset)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return [];
return exchangeInfo.Symbols
.Where(x => x.Value.BaseAsset.Equals(baseAsset, StringComparison.InvariantCultureIgnoreCase))
.Select(x => x.Value)
.ToArray();
}
/// <summary>
/// Parse a symbol name to a SharedSymbol
/// </summary>
@@ -22,5 +22,10 @@ namespace CryptoExchange.Net.Interfaces.Clients
/// The exchange name
/// </summary>
string Exchange { get; }
/// <summary>
/// Whether client is disposed
/// </summary>
bool Disposed { get; }
}
}
@@ -35,6 +35,11 @@ namespace CryptoExchange.Net.Interfaces.Clients
/// </summary>
public int CurrentSubscriptions { get; }
/// <summary>
/// Whether client is disposed
/// </summary>
bool Disposed { get; }
/// <summary>
/// Unsubscribe from a stream using the subscription id received when starting the subscription
/// </summary>
@@ -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)
@@ -1,129 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Objects
{
/// <summary>
/// Async auto reset based on Stephen Toub`s implementation
/// https://devblogs.microsoft.com/pfxteam/building-async-coordination-primitives-part-2-asyncautoresetevent/
/// </summary>
public class AsyncResetEvent : IDisposable
{
private static readonly Task<bool> _completed = Task.FromResult(true);
private Queue<TaskCompletionSource<bool>> _waits = new Queue<TaskCompletionSource<bool>>();
#if NET9_0_OR_GREATER
private readonly Lock _waitsLock = new Lock();
#else
private readonly object _waitsLock = new object();
#endif
private bool _signaled;
private readonly bool _reset;
/// <summary>
/// New AsyncResetEvent
/// </summary>
/// <param name="initialState"></param>
/// <param name="reset"></param>
public AsyncResetEvent(bool initialState = false, bool reset = true)
{
_signaled = initialState;
_reset = reset;
}
/// <summary>
/// Wait for the AutoResetEvent to be set
/// </summary>
/// <returns></returns>
public async Task<bool> WaitAsync(TimeSpan? timeout = null, CancellationToken ct = default)
{
CancellationTokenRegistration registration = default;
try
{
Task<bool> waiter = _completed;
lock (_waitsLock)
{
if (_signaled)
{
if (_reset)
_signaled = false;
}
else if (!ct.IsCancellationRequested)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if (timeout.HasValue)
{
var timeoutSource = new CancellationTokenSource(timeout.Value);
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutSource.Token, ct);
ct = cancellationSource.Token;
}
registration = ct.Register(() =>
{
lock (_waitsLock)
{
tcs.TrySetResult(false);
// Not the cleanest but it works
_waits = new Queue<TaskCompletionSource<bool>>(_waits.Where(i => i != tcs));
}
}, useSynchronizationContext: false);
_waits.Enqueue(tcs);
waiter = tcs.Task;
}
}
return await waiter.ConfigureAwait(false);
}
finally
{
registration.Dispose();
}
}
/// <summary>
/// Signal a waiter
/// </summary>
public void Set()
{
lock (_waitsLock)
{
if (!_reset)
{
// Act as ManualResetEvent. Once set keep it signaled and signal everyone who is waiting
_signaled = true;
while (_waits.Count > 0)
{
var toRelease = _waits.Dequeue();
toRelease.TrySetResult(true);
}
}
else
{
// Act as AutoResetEvent. When set signal 1 waiter
if (_waits.Count > 0)
{
var toRelease = _waits.Dequeue();
toRelease.TrySetResult(true);
}
else if (!_signaled)
{
_signaled = true;
}
}
}
}
/// <summary>
/// Dispose
/// </summary>
public void Dispose()
{
_waits.Clear();
}
}
}
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Objects
{
/// <summary>
/// Async auto/manual reset event implementation
/// </summary>
public class AsyncResetEvent
{
private readonly Queue<TaskCompletionSource<bool>> _waiters = new();
private readonly bool _autoReset;
private bool _signaled;
#if NET9_0_OR_GREATER
private readonly Lock _waitersLock = new Lock();
#else
private readonly object _waitersLock = new object();
#endif
/// <summary>
/// ctor
/// </summary>
public AsyncResetEvent(bool initialState = false, bool autoReset = true)
{
_signaled = initialState;
_autoReset = autoReset;
}
/// <summary>
/// Wait for the set event
/// </summary>
/// <returns></returns>
public async Task<bool> WaitAsync(
TimeSpan? timeout = null,
CancellationToken ct = default)
{
TaskCompletionSource<bool> tcs;
lock (_waitersLock)
{
if (_signaled)
{
// Already was signaled, can return immediately
if (_autoReset)
_signaled = false;
return true;
}
tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_waiters.Enqueue(tcs);
}
CancellationTokenSource? delayCts = null;
try
{
if (timeout.HasValue || ct.CanBeCanceled)
{
delayCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var delayTask = Task.Delay(
timeout ?? Timeout.InfiniteTimeSpan,
delayCts.Token);
var completedTask =
await Task.WhenAny(tcs.Task, delayTask)
.ConfigureAwait(false);
if (completedTask != tcs.Task)
{
// This was a timeout or cancellation, need to remove tcs from waiters
// if the tcs was set instead it will be removed in the Set method
if (tcs.TrySetResult(false))
{
lock (_waitersLock)
{
// Dequeue and put in the back of the queue again except for the one we need to remove
int count = _waiters.Count;
for (int i = 0; i < count; i++)
{
var w = _waiters.Dequeue();
if (w != tcs)
_waiters.Enqueue(w);
}
}
}
return false;
}
}
else
{
await tcs.Task.ConfigureAwait(false);
}
return true;
}
finally
{
// Actively stop the delay if tcs.Task won
delayCts?.Cancel();
delayCts?.Dispose();
}
}
/// <summary>
/// Signal a waiter
/// </summary>
public void Set()
{
if (!_autoReset && _signaled)
// Already signaled and not resetting
return;
lock (_waitersLock)
{
if (_autoReset)
{
while (_waiters.Count > 0)
{
// Try to dequeue and set the result
// If result setting was not successful it means timeout/cancellation happened at the same time
// If this is the case this Set isn't the one setting the result and we need to continue
var w = _waiters.Dequeue();
if (w.TrySetResult(true))
return;
}
// No queued waiters, set signaled for next waiter
_signaled = true;
}
else
{
_signaled = true;
// Signal all current waiters
while (_waiters.Count > 0)
{
var w = _waiters.Dequeue();
w.TrySetResult(true);
}
}
}
}
}
}
@@ -7,6 +7,11 @@ namespace CryptoExchange.Net.Objects.Options
/// </summary>
public class ApiOptions
{
/// <summary>
/// Whether or not to automatically sync the local time with the server time
/// </summary>
public bool? AutoTimestamp { get; set; }
/// <summary>
/// If true, the CallResult and DataEvent objects will also include the originally received string data in the OriginalData property.
/// Note that this comes at a performance cost
@@ -8,6 +8,10 @@ namespace CryptoExchange.Net.Objects.Options
/// </summary>
public class ExchangeOptions
{
/// <summary>
/// Whether or not to automatically sync the local time with the server time
/// </summary>
public bool AutoTimestamp { get; set; }
/// <summary>
/// Proxy settings
/// </summary>
@@ -8,11 +8,6 @@ namespace CryptoExchange.Net.Objects.Options
/// </summary>
public class RestApiOptions : ApiOptions
{
/// <summary>
/// Whether or not to automatically sync the local time with the server time
/// </summary>
public bool? AutoTimestamp { get; set; }
/// <summary>
/// How often the timestamp adjustment between client and server is recalculated. If you need a very small TimeSpan here you're probably better of syncing your server time more often
/// </summary>
@@ -8,11 +8,6 @@ namespace CryptoExchange.Net.Objects.Options
/// </summary>
public class RestExchangeOptions: ExchangeOptions
{
/// <summary>
/// Whether or not to automatically sync the local time with the server time
/// </summary>
public bool AutoTimestamp { get; set; }
/// <summary>
/// How often the timestamp adjustment between client and server is recalculated. If you need a very small TimeSpan here you're probably better of syncing your server time more often
/// </summary>
@@ -27,6 +27,7 @@ namespace CryptoExchange.Net.Objects.Options
item.ApiCredentials = ApiCredentials?.Copy();
item.OutputOriginalData = OutputOriginalData;
item.SocketNoDataTimeout = SocketNoDataTimeout;
item.AutoTimestamp = AutoTimestamp;
item.MaxSocketConnections = MaxSocketConnections;
return item;
}
@@ -75,6 +75,15 @@ namespace CryptoExchange.Net.Objects.Options
/// </remarks>
public int? ReceiveBufferSize { get; set; }
/// <summary>
/// ctor
/// </summary>
public SocketExchangeOptions()
{
// Enable auto timestamping by default for sockets
AutoTimestamp = true;
}
/// <summary>
/// Create a copy of this options
/// </summary>
@@ -83,6 +92,7 @@ namespace CryptoExchange.Net.Objects.Options
public T Set<T>(T item) where T : SocketExchangeOptions, new()
{
item.ApiCredentials = ApiCredentials?.Copy();
item.AutoTimestamp = AutoTimestamp;
item.OutputOriginalData = OutputOriginalData;
item.ReconnectPolicy = ReconnectPolicy;
item.DelayAfterConnect = DelayAfterConnect;
@@ -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();
}
}
@@ -109,6 +109,21 @@ namespace CryptoExchange.Net.Objects.Sockets
/// </summary>
public int Id => _subscription.Id;
/// <summary>
/// The last timestamp anything was received from the server
/// </summary>
public DateTime? LastReceiveTime => _connection.LastReceiveTime;
/// <summary>
/// The current websocket status
/// </summary>
public SocketStatus SocketStatus => _connection.Status;
/// <summary>
/// The current subscription status
/// </summary>
public SubscriptionStatus SubscriptionStatus => _subscription.Status;
/// <summary>
/// ctor
/// </summary>
@@ -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();
}
@@ -0,0 +1,21 @@
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Transfer status
/// </summary>
public enum SharedTransferStatus
{
/// <summary>
/// In progress
/// </summary>
InProgress,
/// <summary>
/// Failed
/// </summary>
Failed,
/// <summary>
/// Completed
/// </summary>
Completed
}
}
@@ -12,6 +12,25 @@ namespace CryptoExchange.Net.SharedApis
/// Futures symbol request options
/// </summary>
EndpointOptions<GetSymbolsRequest> GetFuturesSymbolsOptions { get; }
/// <summary>
/// Get all futures symbols for a specific base asset
/// </summary>
/// <param name="baseAsset">Asset, for example `ETH`</param>
Task<ExchangeResult<SharedSymbol[]>> GetFuturesSymbolsForBaseAssetAsync(string baseAsset);
/// <summary>
/// Gets whether the client supports a futures symbol
/// </summary>
/// <param name="symbol">The symbol</param>
Task<ExchangeResult<bool>> SupportsFuturesSymbolAsync(SharedSymbol symbol);
/// <summary>
/// Gets whether the client supports a futures symbol
/// </summary>
/// <param name="symbolName">The symbol name</param>
Task<ExchangeResult<bool>> SupportsFuturesSymbolAsync(string symbolName);
/// <summary>
/// Get info on all futures symbols supported on the exchange
/// </summary>
@@ -1,4 +1,5 @@
using System.Threading;
using CryptoExchange.Net.Objects;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.SharedApis
@@ -13,6 +14,24 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
EndpointOptions<GetSymbolsRequest> GetSpotSymbolsOptions { get; }
/// <summary>
/// Get all spot symbols for a specific base asset
/// </summary>
/// <param name="baseAsset">Asset, for example `ETH`</param>
Task<ExchangeResult<SharedSymbol[]>> GetSpotSymbolsForBaseAssetAsync(string baseAsset);
/// <summary>
/// Gets whether the client supports a spot symbol
/// </summary>
/// <param name="symbol">The symbol</param>
Task<ExchangeResult<bool>> SupportsSpotSymbolAsync(SharedSymbol symbol);
/// <summary>
/// Gets whether the client supports a spot symbol
/// </summary>
/// <param name="symbolName">The symbol name</param>
Task<ExchangeResult<bool>> SupportsSpotSymbolAsync(string symbolName);
/// <summary>
/// Get info on all available spot symbols on the exchange
/// </summary>
@@ -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);
@@ -38,6 +38,17 @@ namespace CryptoExchange.Net.SharedApis
Exchange = exchange;
}
/// <summary>
/// ctor
/// </summary>
public ExchangeResult(
string exchange,
T result) :
base(result, null, null)
{
Exchange = exchange;
}
/// <inheritdoc />
public override string ToString() => $"{Exchange} - " + base.ToString();
}
@@ -44,15 +44,21 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public bool Completed { get; set; }
/// <summary>
/// Status of the deposit
/// </summary>
public SharedTransferStatus Status { get; set; }
/// <summary>
/// ctor
/// </summary>
public SharedDeposit(string asset, decimal quantity, bool completed, DateTime timestamp)
public SharedDeposit(string asset, decimal quantity, bool completed, DateTime timestamp, SharedTransferStatus status)
{
Asset = asset;
Quantity = quantity;
Timestamp = timestamp;
Completed = completed;
Status = status;
}
}
@@ -20,6 +20,10 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public SharedPositionSide PositionSide { get; set; }
/// <summary>
/// Whether the position is one way mode
/// </summary>
public SharedPositionMode PositionMode { get; set; }
/// <summary>
/// Average open price
/// </summary>
public decimal? AverageOpenPrice { get; set; }
@@ -21,6 +21,11 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public decimal? QuantityInContracts { get; set; }
/// <summary>
/// Whether all values are null or zero
/// </summary>
public bool IsZero => !(QuantityInBaseAsset > 0) && !(QuantityInQuoteAsset > 0) && !(QuantityInContracts > 0);
/// <summary>
/// ctor
/// </summary>
@@ -49,7 +49,6 @@ namespace CryptoExchange.Net.Sockets.Default
private int _reconnectAttempt;
private readonly int _receiveBufferSize;
private const int _defaultReceiveBufferSize = 1048576;
private const int _sendBufferSize = 4096;
private int _bytesReceived = 0;
@@ -71,7 +70,7 @@ namespace CryptoExchange.Net.Sockets.Default
/// <summary>
/// The timestamp this socket has been active for the last time
/// </summary>
public DateTime LastActionTime { get; private set; }
public DateTime? LastReceiveTime { get; private set; }
/// <inheritdoc />
public Uri Uri => Parameters.Uri;
@@ -88,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;
}
}
@@ -487,7 +487,6 @@ namespace CryptoExchange.Net.Sockets.Default
_disposed = true;
_socket.Dispose();
_ctsSource?.Dispose();
_sendEvent.Dispose();
_logger.SocketDisposed(Id);
}
@@ -623,6 +622,7 @@ namespace CryptoExchange.Net.Sockets.Default
break;
}
LastReceiveTime = DateTime.UtcNow;
if (receiveResult.MessageType == WebSocketMessageType.Close)
{
// Connection closed
@@ -773,6 +773,7 @@ namespace CryptoExchange.Net.Sockets.Default
break;
}
LastReceiveTime = DateTime.UtcNow;
if (receiveResult.MessageType == WebSocketMessageType.Close)
{
// Connection closed
@@ -881,7 +882,6 @@ namespace CryptoExchange.Net.Sockets.Default
/// <returns></returns>
protected void ProcessDataNew(WebSocketMessageType type, ReadOnlySpan<byte> data)
{
LastActionTime = DateTime.UtcNow;
_connection.HandleStreamMessage2(type, data);
}
@@ -892,7 +892,7 @@ namespace CryptoExchange.Net.Sockets.Default
protected async Task CheckTimeoutAsync()
{
_logger.SocketStartingTaskForNoDataReceivedCheck(Id, Parameters.Timeout);
LastActionTime = DateTime.UtcNow;
LastReceiveTime = DateTime.UtcNow;
try
{
while (true)
@@ -900,7 +900,7 @@ namespace CryptoExchange.Net.Sockets.Default
if (_ctsSource.IsCancellationRequested)
return;
if (DateTime.UtcNow - LastActionTime > Parameters.Timeout)
if (DateTime.UtcNow - LastReceiveTime > Parameters.Timeout)
{
_logger.SocketNoDataReceiveTimoutReconnect(Id, Parameters.Timeout);
_ = ReconnectAsync().ConfigureAwait(false);
@@ -69,6 +69,10 @@ namespace CryptoExchange.Net.Sockets.Default.Interfaces
/// </summary>
bool IsOpen { get; }
/// <summary>
/// Last timestamp something was received from the server
/// </summary>
DateTime? LastReceiveTime { get; }
/// <summary>
/// Connect the socket
/// </summary>
/// <returns></returns>
@@ -178,6 +178,11 @@ namespace CryptoExchange.Net.Sockets.Default
/// </summary>
public DateTime? DisconnectTime { get; set; }
/// <summary>
/// Last timestamp something was received from the server
/// </summary>
public DateTime? LastReceiveTime => _socket.LastReceiveTime;
/// <summary>
/// Tag for identification
/// </summary>
@@ -628,22 +633,37 @@ namespace CryptoExchange.Net.Sockets.Default
if (route.TypeIdentifier != typeIdentifier)
continue;
if (topicFilter == null
|| route.TopicFilter == null
|| route.TopicFilter.Equals(topicFilter, StringComparison.Ordinal))
// 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)
{
processed = true;
if (isQuery && query!.Completed)
if (route.TopicFilter != null)
// No topic on message, but route is filtering on topic
continue;
processor.Handle(this, receiveTime, originalData, result, route);
if (isQuery && !route.MultipleReaders)
{
complete = true;
break;
}
}
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)
@@ -653,10 +673,16 @@ namespace CryptoExchange.Net.Sockets.Default
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]")))));
lock (_listenersLock)
{
_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]")))));
}
}
}
}
@@ -787,7 +813,6 @@ namespace CryptoExchange.Net.Sockets.Default
{
Status = SocketStatus.Disposed;
periodicEvent?.Set();
periodicEvent?.Dispose();
_socket.Dispose();
}
@@ -926,7 +951,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");
@@ -1060,40 +1085,8 @@ namespace CryptoExchange.Net.Sockets.Default
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);
@@ -1110,6 +1103,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);
@@ -46,7 +46,7 @@ namespace CryptoExchange.Net.Sockets.Default
return;
_status = value;
Task.Run(() => StatusChanged?.Invoke(value));
StatusChanged?.Invoke(value);
}
}
@@ -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);
}
@@ -233,7 +233,6 @@ namespace CryptoExchange.Net.Sockets.HighPerf
{
Status = SocketStatus.Disposed;
periodicEvent?.Set();
periodicEvent?.Dispose();
_socket.Dispose();
}
+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();
}
@@ -39,6 +39,7 @@ namespace CryptoExchange.Net.Testing.Implementations
public Func<Task<Uri?>>? GetReconnectionUrl { get; set; }
public static int lastId = 0;
public DateTime? LastReceiveTime { get; }
#if NET9_0_OR_GREATER
public static readonly Lock lastIdLock = new Lock();
#else
@@ -55,6 +55,11 @@ namespace CryptoExchange.Net.Trackers.Klines
/// </summary>
SharedKline? Last { get; }
/// <summary>
/// The kline interval
/// </summary>
public SharedKlineInterval Interval { get; }
/// <summary>
/// Event for when a new kline is added
/// </summary>
@@ -45,10 +45,6 @@ namespace CryptoExchange.Net.Trackers.Klines
/// </summary>
protected bool _changed = false;
/// <summary>
/// The kline interval
/// </summary>
protected readonly SharedKlineInterval _interval;
/// <summary>
/// Whether the snapshot has been set
/// </summary>
protected bool _snapshotSet;
@@ -66,6 +62,10 @@ namespace CryptoExchange.Net.Trackers.Klines
/// </summary>
protected DateTime? _firstTimestamp;
/// <summary>
/// The kline interval
/// </summary>
public SharedKlineInterval Interval { get; }
/// <inheritdoc/>
public SyncStatus Status
{
@@ -165,7 +165,7 @@ namespace CryptoExchange.Net.Trackers.Klines
Exchange = restClient.Exchange;
Limit = limit;
Period = period;
_interval = interval;
Interval = interval;
_socketClient = socketClient;
_restClient = restClient;
}
@@ -180,7 +180,7 @@ namespace CryptoExchange.Net.Trackers.Klines
Status = SyncStatus.Syncing;
_logger.KlineTrackerStarting(SymbolName);
var subResult = await _socketClient.SubscribeToKlineUpdatesAsync(new SubscribeKlineRequest(Symbol, _interval),
var subResult = await _socketClient.SubscribeToKlineUpdatesAsync(new SubscribeKlineRequest(Symbol, Interval),
update =>
{
AddOrUpdate(update.Data);
@@ -237,7 +237,7 @@ namespace CryptoExchange.Net.Trackers.Klines
var limit = Math.Min(_restClient.GetKlinesOptions.MaxLimit, Limit ?? 100);
var request = new GetKlinesRequest(Symbol, _interval, startTime, DateTime.UtcNow, limit: limit);
var request = new GetKlinesRequest(Symbol, Interval, startTime, DateTime.UtcNow, limit: limit);
var data = new List<SharedKline>();
await foreach (var result in ExchangeHelpers.ExecutePages(_restClient.GetKlinesAsync, request).ConfigureAwait(false))
{
@@ -0,0 +1,34 @@
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
{
/// <summary>
/// Data tracker interface
/// </summary>
public interface IUserDataTracker<T>
{
/// <summary>
/// Whether the tracker is currently fully connected
/// </summary>
bool Connected { get; }
/// <summary>
/// On connection status change. Might trigger multiple times with the same status depending on the underlying subscriptions.
/// </summary>
event Action<bool>? OnConnectedChange;
/// <summary>
/// Currently tracker values
/// </summary>
T[] Values { get; }
/// <summary>
/// On data update
/// </summary>
event Func<UserDataUpdate<T[]>, Task>? OnUpdate;
}
}
@@ -0,0 +1,82 @@
using CryptoExchange.Net.Objects;
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
{
/// <summary>
/// Futures user data tracker
/// </summary>
public interface IUserFuturesDataTracker
{
/// <summary>
/// User identifier
/// </summary>
string? UserIdentifier { get; }
/// <summary>
/// Whether the tracker is currently fully connected
/// </summary>
bool Connected { get; }
/// <summary>
/// Exchange name
/// </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>
IUserDataTracker<SharedBalance> Balances { get; }
/// <summary>
/// Orders tracker
/// </summary>
IUserDataTracker<SharedFuturesOrder> Orders { get; }
/// <summary>
/// Positions tracker
/// </summary>
IUserDataTracker<SharedPosition> Positions { get; }
/// <summary>
/// Trades tracker
/// </summary>
IUserDataTracker<SharedUserTrade>? Trades { get; }
/// <summary>
/// On connection status change
/// </summary>
event Action<UserDataType, bool>? OnConnectedChange;
/// <summary>
/// Start tracking user data
/// </summary>
Task<CallResult> StartAsync();
/// <summary>
/// Stop tracking data
/// </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);
}
}
@@ -0,0 +1,78 @@
using CryptoExchange.Net.Objects;
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
{
/// <summary>
/// User data tracker
/// </summary>
public interface IUserSpotDataTracker
{
/// <summary>
/// User identifier
/// </summary>
string? UserIdentifier { get; }
/// <summary>
/// Whether the tracker is currently fully connected
/// </summary>
bool Connected { get; }
/// <summary>
/// Exchange name
/// </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>
IUserDataTracker<SharedBalance> Balances { get; }
/// <summary>
/// Orders tracker
/// </summary>
IUserDataTracker<SharedSpotOrder> Orders { get; }
/// <summary>
/// Trades tracker
/// </summary>
IUserDataTracker<SharedUserTrade>? Trades { get; }
/// <summary>
/// On connection status change
/// </summary>
event Action<UserDataType, bool>? OnConnectedChange;
/// <summary>
/// Start tracking user data
/// </summary>
Task<CallResult> StartAsync();
/// <summary>
/// Stop tracking data
/// </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);
}
}
@@ -0,0 +1,95 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
/// <summary>
/// Balance tracker implementation
/// </summary>
public class BalanceTracker : UserDataItemTracker<SharedBalance>
{
private readonly IBalanceRestClient _restClient;
private readonly IBalanceSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly SharedAccountType _accountType;
/// <summary>
/// ctor
/// </summary>
public BalanceTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IBalanceRestClient restClient,
IBalanceSocketClient? socketClient,
SharedAccountType accountType,
TrackerItemConfig config,
ExchangeParameters? exchangeParameters = null
) : base(logger, symbolTracker, UserDataType.Balances, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
_accountType = accountType;
}
/// <inheritdoc />
protected override bool Update(SharedBalance existingItem, SharedBalance updateItem)
{
var changed = false;
if (existingItem.Total != updateItem.Total)
{
existingItem.Total = updateItem.Total;
changed = true;
}
if (existingItem.Available != updateItem.Available)
{
existingItem.Available = updateItem.Available;
changed = true;
}
return changed;
}
/// <inheritdoc />
protected override string GetKey(SharedBalance item) => item.Asset + item.IsolatedMarginSymbol;
/// <inheritdoc />
protected override bool? CheckIfUpdateShouldBeApplied(SharedBalance existingItem, SharedBalance updateItem) => true;
/// <inheritdoc />
protected override Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey)
{
if (_socketClient == null)
return Task.FromResult(new CallResult<UpdateSubscription?>(data: null));
var accountType = _accountType == SharedAccountType.Spot ? TradingMode.Spot :
_accountType == SharedAccountType.PerpetualInverseFutures ? TradingMode.PerpetualInverse :
_accountType == SharedAccountType.DeliveryLinearFutures ? TradingMode.DeliveryLinear :
_accountType == SharedAccountType.DeliveryInverseFutures ? TradingMode.DeliveryInverse :
TradingMode.PerpetualLinear;
return ExchangeHelpers.ProcessQueuedAsync<SharedBalance[]>(
async handler => await _socketClient.SubscribeToBalanceUpdatesAsync(new SubscribeBalancesRequest(listenKey, accountType, exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}
/// <inheritdoc />
protected override async Task<bool> DoPollAsync()
{
var balances = await _restClient.GetBalancesAsync(new GetBalancesRequest(accountType: _accountType, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (balances.Success)
await HandleUpdateAsync(UpdateSource.Poll, balances.Data).ConfigureAwait(false);
else
_initialPollingError ??= balances.Error;
return !balances.Success;
}
}
}
@@ -0,0 +1,402 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
/// <summary>
/// Futures order tracker
/// </summary>
public class FuturesOrderTracker : UserDataItemTracker<SharedFuturesOrder>
{
private readonly IFuturesOrderRestClient _restClient;
private readonly IFuturesOrderSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly bool _requiresSymbolParameterOpenOrders;
private readonly Dictionary<string, int> _openOrderNotReturnedTimes = new();
internal event Func<UpdateSource, SharedUserTrade[], Task>? OnTradeUpdate;
/// <summary>
/// ctor
/// </summary>
public FuturesOrderTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IFuturesOrderRestClient restClient,
IFuturesOrderSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, symbolTracker, UserDataType.Orders, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
_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)
{
var changed = false;
if (updateItem.AveragePrice != null && updateItem.AveragePrice != existingItem.AveragePrice)
{
existingItem.AveragePrice = updateItem.AveragePrice;
changed = true;
}
if (updateItem.OrderPrice != null && updateItem.OrderPrice != existingItem.OrderPrice)
{
existingItem.OrderPrice = updateItem.OrderPrice;
changed = true;
}
if (updateItem.Fee != null && updateItem.Fee != existingItem.Fee)
{
existingItem.Fee = updateItem.Fee;
changed = true;
}
if (updateItem.FeeAsset != null && updateItem.FeeAsset != existingItem.FeeAsset)
{
existingItem.FeeAsset = updateItem.FeeAsset;
changed = true;
}
if (updateItem.OrderQuantity != null && updateItem.OrderQuantity != existingItem.OrderQuantity)
{
existingItem.OrderQuantity = updateItem.OrderQuantity;
changed = true;
}
if (updateItem.QuantityFilled != null && updateItem.QuantityFilled != existingItem.QuantityFilled)
{
existingItem.QuantityFilled = updateItem.QuantityFilled;
changed = true;
}
if (updateItem.Status != existingItem.Status)
{
existingItem.Status = updateItem.Status;
changed = true;
}
if (updateItem.StopLossPrice != existingItem.StopLossPrice)
{
existingItem.StopLossPrice = updateItem.StopLossPrice;
changed = true;
}
if (updateItem.TakeProfitPrice != existingItem.TakeProfitPrice)
{
existingItem.TakeProfitPrice = updateItem.TakeProfitPrice;
changed = true;
}
if (updateItem.TriggerPrice != existingItem.TriggerPrice)
{
existingItem.TriggerPrice = updateItem.TriggerPrice;
changed = true;
}
if (updateItem.UpdateTime != null && updateItem.UpdateTime != existingItem.UpdateTime)
{
existingItem.UpdateTime = updateItem.UpdateTime;
changed = true;
}
return changed;
}
/// <inheritdoc />
protected override string GetKey(SharedFuturesOrder item) => item.OrderId;
/// <inheritdoc />
protected override TimeSpan GetAge(DateTime time, SharedFuturesOrder item) => item.Status == SharedOrderStatus.Open ? TimeSpan.Zero : time - (item.UpdateTime ?? item.CreateTime ?? time);
/// <inheritdoc />
protected override bool? CheckIfUpdateShouldBeApplied(SharedFuturesOrder existingItem, SharedFuturesOrder updateItem)
{
if (existingItem.Status == SharedOrderStatus.Open && updateItem.Status != SharedOrderStatus.Open)
// 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;
if (existingItem.UpdateTime != null && updateItem.UpdateTime != null)
{
// If both have an update time base of that
if (existingItem.UpdateTime < updateItem.UpdateTime)
return true;
if (existingItem.UpdateTime > updateItem.UpdateTime)
return false;
}
if (existingItem.QuantityFilled != null && updateItem.QuantityFilled != null)
{
if (existingItem.QuantityFilled.QuantityInBaseAsset != null && updateItem.QuantityFilled.QuantityInBaseAsset != null)
{
// If base quantity is not null we can base it on that
if (existingItem.QuantityFilled.QuantityInBaseAsset < updateItem.QuantityFilled.QuantityInBaseAsset)
return true;
else if (existingItem.QuantityFilled.QuantityInBaseAsset > updateItem.QuantityFilled.QuantityInBaseAsset)
return false;
}
if (existingItem.QuantityFilled.QuantityInQuoteAsset != null && updateItem.QuantityFilled.QuantityInQuoteAsset != null)
{
// If quote quantity is not null we can base it on that
if (existingItem.QuantityFilled.QuantityInQuoteAsset < updateItem.QuantityFilled.QuantityInQuoteAsset)
return true;
else if (existingItem.QuantityFilled.QuantityInQuoteAsset > updateItem.QuantityFilled.QuantityInQuoteAsset)
return false;
}
}
if (existingItem.Fee != null && updateItem.Fee != null)
{
// Higher fee means later processing
if (existingItem.Fee < updateItem.Fee)
return true;
if (existingItem.Fee > updateItem.Fee)
return false;
}
return null;
}
/// <inheritdoc />
protected internal override async Task HandleUpdateAsync(UpdateSource source, SharedFuturesOrder[] @event)
{
await base.HandleUpdateAsync(source, @event).ConfigureAwait(false);
var trades = @event.Where(x => x.LastTrade != null).Select(x => x.LastTrade!).ToArray();
if (trades.Length != 0 && OnTradeUpdate != null)
await OnTradeUpdate.Invoke(source, trades).ConfigureAwait(false);
}
/// <inheritdoc />
protected override Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey)
{
if (_socketClient == null)
return Task.FromResult(new CallResult<UpdateSubscription?>(data: null));
return ExchangeHelpers.ProcessQueuedAsync<SharedFuturesOrder[]>(
async handler => await _socketClient.SubscribeToFuturesOrderUpdatesAsync(new SubscribeFuturesOrderRequest(listenKey, exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}
/// <inheritdoc />
protected override async Task<bool> DoPollAsync()
{
var anyError = false;
List<SharedFuturesOrder> openOrders = new List<SharedFuturesOrder>();
if (!_requiresSymbolParameterOpenOrders)
{
var openOrdersResult = await _restClient.GetOpenFuturesOrdersAsync(new GetOpenOrdersRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!openOrdersResult.Success)
{
anyError = true;
_initialPollingError ??= openOrdersResult.Error;
if (!_firstPollDone)
return anyError;
}
else
{
openOrders.AddRange(openOrdersResult.Data);
await HandleUpdateAsync(UpdateSource.Poll, openOrdersResult.Data).ConfigureAwait(false);
}
}
else
{
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var openOrdersResult = await _restClient.GetOpenFuturesOrdersAsync(new GetOpenOrdersRequest(symbol, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!openOrdersResult.Success)
{
anyError = true;
_initialPollingError ??= openOrdersResult.Error;
if (!_firstPollDone)
break;
}
else
{
openOrders.AddRange(openOrdersResult.Data);
await HandleUpdateAsync(UpdateSource.Poll, openOrdersResult.Data).ConfigureAwait(false);
}
}
}
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))
{
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)
{
anyError = true;
_initialPollingError ??= closedOrdersResult.Error;
if (!_firstPollDone)
break;
}
else
{
// 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
|| (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 =>
// 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>();
foreach (var order in openOrdersNotReturned)
{
additionalUpdates.Add(order with
{
Status = SharedOrderStatus.Canceled
});
}
relevantOrders = relevantOrders.Concat(additionalUpdates).ToArray();
if (relevantOrders.Length > 0)
await HandleUpdateAsync(UpdateSource.Poll, relevantOrders).ConfigureAwait(false);
}
}
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. 1 seconds buffer
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.AddSeconds(-1);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime;
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.AddMilliseconds(-1);
source = "OpenOrder";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
if (DateTime.UtcNow - fromTime < TimeSpan.FromSeconds(1))
{
// 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;
}
}
}
@@ -0,0 +1,157 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
/// <summary>
/// Futures user trade tracker
/// </summary>
public class FuturesUserTradeTracker : UserDataItemTracker<SharedUserTrade>
{
private readonly IFuturesOrderRestClient _restClient;
private readonly IUserTradeSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
internal Func<string[]>? GetTrackedOrderIds { get; set; }
/// <summary>
/// ctor
/// </summary>
public FuturesUserTradeTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IFuturesOrderRestClient restClient,
IUserTradeSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, symbolTracker, UserDataType.Trades, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_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 />
protected override bool? CheckIfUpdateShouldBeApplied(SharedUserTrade existingItem, SharedUserTrade updateItem) => false;
/// <inheritdoc />
protected override bool Update(SharedUserTrade existingItem, SharedUserTrade updateItem) => false; // trades are never updated
/// <inheritdoc />
protected override TimeSpan GetAge(DateTime time, SharedUserTrade item) => time - item.Timestamp;
/// <inheritdoc />
protected override async Task<bool> DoPollAsync()
{
var anyError = false;
var fromTimeTrades = GetTradesRequestStartTime();
var updatedPollTime = DateTime.UtcNow;
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var tradesResult = await _restClient.GetFuturesUserTradesAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!tradesResult.Success)
{
anyError = true;
_initialPollingError ??= tradesResult.Error;
if (!_firstPollDone)
break;
}
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();
if (relevantTrades.Length > 0)
await HandleUpdateAsync(UpdateSource.Poll, tradesResult.Data).ConfigureAwait(false);
}
}
if (!anyError)
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
}
return anyError;
}
private DateTime? GetTradesRequestStartTime()
{
// 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. 1 seconds buffer
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.AddSeconds(-1);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime;
source = "LastPollTime";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
var now = DateTime.UtcNow;
if (now - fromTime < TimeSpan.FromSeconds(1))
{
// 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)
{
if (_socketClient == null)
return Task.FromResult(new CallResult<UpdateSubscription?>(data: null));
return ExchangeHelpers.ProcessQueuedAsync<SharedUserTrade[]>(
async handler => await _socketClient.SubscribeToUserTradeUpdatesAsync(new SubscribeUserTradeRequest(listenKey, exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}
}
}
@@ -0,0 +1,240 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
/// <summary>
/// Position tracker
/// </summary>
public class PositionTracker : UserDataItemTracker<SharedPosition>
{
private readonly IFuturesOrderRestClient _restClient;
private readonly IPositionSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
/// <summary>
/// Whether websocket position updates are full snapshots and missing positions should be considered 0
/// </summary>
protected bool WebsocketPositionUpdatesAreFullSnapshots { get; }
/// <summary>
/// ctor
/// </summary>
public PositionTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
IFuturesOrderRestClient restClient,
IPositionSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
bool websocketPositionUpdatesAreFullSnapshots,
ExchangeParameters? exchangeParameters = null
) : base(logger, symbolTracker, UserDataType.Positions, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
WebsocketPositionUpdatesAreFullSnapshots = websocketPositionUpdatesAreFullSnapshots;
}
/// <inheritdoc />
protected override bool Update(SharedPosition existingItem, SharedPosition updateItem)
{
// Some other way to way to determine sequence? Maybe timestamp?
var changed = false;
if (existingItem.AverageOpenPrice != updateItem.AverageOpenPrice)
{
existingItem.AverageOpenPrice = updateItem.AverageOpenPrice;
changed = true;
}
if (existingItem.Leverage != updateItem.Leverage)
{
existingItem.Leverage = updateItem.Leverage;
changed = true;
}
if (existingItem.LiquidationPrice != updateItem.LiquidationPrice)
{
existingItem.LiquidationPrice = updateItem.LiquidationPrice;
changed = true;
}
if (existingItem.PositionSize != updateItem.PositionSize)
{
existingItem.PositionSize = updateItem.PositionSize;
changed = true;
}
if (existingItem.StopLossPrice != updateItem.StopLossPrice)
{
existingItem.StopLossPrice = updateItem.StopLossPrice;
changed = true;
}
if (existingItem.TakeProfitPrice != updateItem.TakeProfitPrice)
{
existingItem.TakeProfitPrice = updateItem.TakeProfitPrice;
changed = true;
}
if (updateItem.UnrealizedPnl != null && existingItem.UnrealizedPnl != updateItem.UnrealizedPnl)
{
existingItem.UnrealizedPnl = updateItem.UnrealizedPnl;
changed = true;
}
if (updateItem.UpdateTime != null && existingItem.UpdateTime != updateItem.UpdateTime)
{
existingItem.UpdateTime = updateItem.UpdateTime;
// If update time is the only changed prop don't mark it as changed
}
return changed;
}
/// <inheritdoc />
protected internal override async Task HandleUpdateAsync(UpdateSource source, SharedPosition[] @event)
{
LastUpdateTime = DateTime.UtcNow;
List<SharedPosition>? toRemove = null;
foreach (var item in @event)
{
if (item is SharedSymbolModel symbolModel)
{
if (symbolModel.SharedSymbol == null)
{
toRemove ??= new List<SharedPosition>();
toRemove.Add(item);
_logger.LogTrace("Ignoring {DataType} update for {Key}, no SharedSymbol set", DataType, item.Symbol);
}
else if (!_symbolTracker.ShouldProcess(symbolModel.SharedSymbol))
{
toRemove ??= new List<SharedPosition>();
toRemove.Add(item);
}
}
}
if (toRemove != null)
@event = @event.Except(toRemove).ToArray();
_symbolTracker.UpdateTrackedSymbols(@event.Where(x => x.PositionSize > 0).OfType<SharedSymbolModel>().Select(x => x.SharedSymbol!));
// Update local store
var updatedItems = @event.Select(GetKey).ToList();
if (WebsocketPositionUpdatesAreFullSnapshots)
{
// Reset any tracking position to zero/null values when it's no longer in the snapshot as it means there is no open position any more
var notInSnapshot = _store.Where(x => !updatedItems.Contains(x.Key) && x.Value.PositionSize != 0).ToList();
foreach (var position in notInSnapshot)
{
position.Value.UpdateTime = DateTime.UtcNow;
position.Value.AverageOpenPrice = null;
position.Value.LiquidationPrice = null;
position.Value.PositionSize = 0;
position.Value.StopLossPrice = null;
position.Value.TakeProfitPrice = null;
position.Value.UnrealizedPnl = null;
updatedItems.Add(position.Key);
LastChangeTime = DateTime.UtcNow;
}
}
foreach (var item in @event)
{
bool existed = false;
_store.AddOrUpdate(GetKey(item), item, (key, existing) =>
{
existed = true;
if (CheckIfUpdateShouldBeApplied(existing, item) == false)
{
updatedItems.Remove(key);
}
else
{
var updated = Update(existing, item);
if (!updated)
{
updatedItems.Remove(key);
}
else
{
_logger.LogDebug("Updated {DataType} {Item}", DataType, key);
LastChangeTime = DateTime.UtcNow;
}
}
return existing;
});
if (!existed)
{
_logger.LogDebug("Added {DataType} {Item}", DataType, GetKey(item));
LastChangeTime = DateTime.UtcNow;
}
}
if (updatedItems.Count > 0)
{
await InvokeUpdate(
new UserDataUpdate<SharedPosition[]>(source, _exchange, _store.Where(x => updatedItems.Contains(x.Key)).Select(x => x.Value).ToArray())).ConfigureAwait(false);
}
}
/// <inheritdoc />
protected override string GetKey(SharedPosition item) =>
item.SharedSymbol!.TradingMode + item.SharedSymbol.BaseAsset + item.SharedSymbol.QuoteAsset + item.PositionMode + (item.PositionMode != SharedPositionMode.OneWay ? item.PositionSide.ToString() : "");
/// <inheritdoc />
protected override bool? CheckIfUpdateShouldBeApplied(SharedPosition existingItem, SharedPosition updateItem) => true;
/// <inheritdoc />
protected override Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey)
{
if (_socketClient == null)
return Task.FromResult(new CallResult<UpdateSubscription?>(data: null));
return ExchangeHelpers.ProcessQueuedAsync<SharedPosition[]>(
async handler => await _socketClient.SubscribeToPositionUpdatesAsync(new SubscribePositionRequest(listenKey, exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}
/// <inheritdoc />
protected override async Task<bool> DoPollAsync()
{
var anyError = false;
var positionsResult = await _restClient.GetPositionsAsync(new GetPositionsRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!positionsResult.Success)
{
anyError = true;
_initialPollingError ??= positionsResult.Error;
if (!_firstPollDone)
return anyError;
}
else
{
await HandleUpdateAsync(UpdateSource.Poll, positionsResult.Data).ConfigureAwait(false);
}
return anyError;
}
}
}
@@ -0,0 +1,413 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
/// <summary>
/// Spot order tracker
/// </summary>
public class SpotOrderTracker : UserDataItemTracker<SharedSpotOrder>
{
private readonly ISpotOrderRestClient _restClient;
private readonly ISpotOrderSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
private readonly bool _requiresSymbolParameterOpenOrders;
private readonly Dictionary<string, int> _openOrderNotReturnedTimes = new();
internal event Func<UpdateSource, SharedUserTrade[], Task>? OnTradeUpdate;
/// <summary>
/// ctor
/// </summary>
public SpotOrderTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
ISpotOrderRestClient restClient,
ISpotOrderSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, symbolTracker, UserDataType.Orders, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
_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)
{
var changed = false;
if (updateItem.AveragePrice != null && updateItem.AveragePrice != existingItem.AveragePrice)
{
existingItem.AveragePrice = updateItem.AveragePrice;
changed = true;
}
if (updateItem.OrderPrice != null && updateItem.OrderPrice != existingItem.OrderPrice)
{
existingItem.OrderPrice = updateItem.OrderPrice;
changed = true;
}
if (updateItem.Fee != null && updateItem.Fee != existingItem.Fee)
{
existingItem.Fee = updateItem.Fee;
changed = true;
}
if (updateItem.FeeAsset != null && updateItem.FeeAsset != existingItem.FeeAsset)
{
existingItem.FeeAsset = updateItem.FeeAsset;
changed = true;
}
if (updateItem.OrderQuantity != null && updateItem.OrderQuantity != existingItem.OrderQuantity)
{
existingItem.OrderQuantity ??= new SharedOrderQuantity();
if (updateItem.OrderQuantity.QuantityInBaseAsset != null)
{
existingItem.OrderQuantity.QuantityInBaseAsset = updateItem.OrderQuantity.QuantityInBaseAsset;
changed = true;
}
if (updateItem.OrderQuantity.QuantityInQuoteAsset != null)
{
existingItem.OrderQuantity.QuantityInQuoteAsset = updateItem.OrderQuantity.QuantityInQuoteAsset;
changed = true;
}
if (updateItem.OrderQuantity.QuantityInContracts != null)
{
existingItem.OrderQuantity.QuantityInContracts = updateItem.OrderQuantity.QuantityInContracts;
changed = true;
}
}
if (updateItem.QuantityFilled != null && updateItem.QuantityFilled != existingItem.QuantityFilled)
{
existingItem.QuantityFilled ??= new SharedOrderQuantity();
if (updateItem.QuantityFilled.QuantityInBaseAsset != null)
{
existingItem.QuantityFilled.QuantityInBaseAsset = updateItem.QuantityFilled.QuantityInBaseAsset;
changed = true;
}
if (updateItem.QuantityFilled.QuantityInQuoteAsset != null)
{
existingItem.QuantityFilled.QuantityInQuoteAsset = updateItem.QuantityFilled.QuantityInQuoteAsset;
changed = true;
}
if (updateItem.QuantityFilled.QuantityInContracts != null)
{
existingItem.QuantityFilled.QuantityInContracts = updateItem.QuantityFilled.QuantityInContracts;
changed = true;
}
}
if (updateItem.Status != existingItem.Status)
{
existingItem.Status = updateItem.Status;
changed = true;
}
if (updateItem.UpdateTime != null && updateItem.UpdateTime != existingItem.UpdateTime)
{
existingItem.UpdateTime = updateItem.UpdateTime;
changed = true;
}
return changed;
}
/// <inheritdoc />
protected override string GetKey(SharedSpotOrder item) => item.OrderId;
/// <inheritdoc />
protected override TimeSpan GetAge(DateTime time, SharedSpotOrder item) => item.Status == SharedOrderStatus.Open ? TimeSpan.Zero : time - (item.UpdateTime ?? item.CreateTime ?? time);
/// <inheritdoc />
protected override bool? CheckIfUpdateShouldBeApplied(SharedSpotOrder existingItem, SharedSpotOrder updateItem)
{
if (existingItem.Status == SharedOrderStatus.Open && updateItem.Status != SharedOrderStatus.Open)
// 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;
if (existingItem.UpdateTime != null && updateItem.UpdateTime != null)
{
// If both have an update time base of that
if (existingItem.UpdateTime < updateItem.UpdateTime)
return true;
if (existingItem.UpdateTime > updateItem.UpdateTime)
return false;
}
if (existingItem.QuantityFilled != null && updateItem.QuantityFilled != null)
{
if (existingItem.QuantityFilled.QuantityInBaseAsset != null && updateItem.QuantityFilled.QuantityInBaseAsset != null)
{
// If base quantity is not null we can base it on that
if (existingItem.QuantityFilled.QuantityInBaseAsset < updateItem.QuantityFilled.QuantityInBaseAsset)
return true;
else if (existingItem.QuantityFilled.QuantityInBaseAsset > updateItem.QuantityFilled.QuantityInBaseAsset)
return false;
}
if (existingItem.QuantityFilled.QuantityInQuoteAsset != null && updateItem.QuantityFilled.QuantityInQuoteAsset != null)
{
// If quote quantity is not null we can base it on that
if (existingItem.QuantityFilled.QuantityInQuoteAsset < updateItem.QuantityFilled.QuantityInQuoteAsset)
return true;
else if (existingItem.QuantityFilled.QuantityInQuoteAsset > updateItem.QuantityFilled.QuantityInQuoteAsset)
return false;
}
}
if (existingItem.Fee != null && updateItem.Fee != null)
{
// Higher fee means later processing
if (existingItem.Fee < updateItem.Fee)
return true;
if (existingItem.Fee > updateItem.Fee)
return false;
}
return null;
}
/// <inheritdoc />
protected internal override async Task HandleUpdateAsync(UpdateSource source, SharedSpotOrder[] @event)
{
await base.HandleUpdateAsync(source, @event).ConfigureAwait(false);
var trades = @event.Where(x => x.LastTrade != null).Select(x => x.LastTrade!).ToArray();
if (trades.Length != 0 && OnTradeUpdate != null)
await OnTradeUpdate(source, trades).ConfigureAwait(false);
}
/// <inheritdoc />
protected override Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey)
{
if (_socketClient == null)
return Task.FromResult(new CallResult<UpdateSubscription?>(data: null));
return ExchangeHelpers.ProcessQueuedAsync<SharedSpotOrder[]>(
async handler => await _socketClient.SubscribeToSpotOrderUpdatesAsync(new SubscribeSpotOrderRequest(listenKey, exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}
/// <inheritdoc />
protected override async Task<bool> DoPollAsync()
{
var anyError = false;
List<SharedSpotOrder> openOrders = new List<SharedSpotOrder>();
if (!_requiresSymbolParameterOpenOrders)
{
var openOrdersResult = await _restClient.GetOpenSpotOrdersAsync(new GetOpenOrdersRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!openOrdersResult.Success)
{
anyError = true;
_initialPollingError ??= openOrdersResult.Error;
if (!_firstPollDone)
return anyError;
}
else
{
openOrders.AddRange(openOrdersResult.Data);
await HandleUpdateAsync(UpdateSource.Poll, openOrdersResult.Data).ConfigureAwait(false);
}
}
else
{
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var openOrdersResult = await _restClient.GetOpenSpotOrdersAsync(new GetOpenOrdersRequest(symbol, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!openOrdersResult.Success)
{
anyError = true;
_initialPollingError ??= openOrdersResult.Error;
if (!_firstPollDone)
break;
}
else
{
openOrders.AddRange(openOrdersResult.Data);
await HandleUpdateAsync(UpdateSource.Poll, openOrdersResult.Data).ConfigureAwait(false);
}
}
}
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))
{
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)
{
anyError = true;
_initialPollingError ??= closedOrdersResult.Error;
if (!_firstPollDone)
break;
}
else
{
// 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
|| (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 =>
// 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>();
foreach (var order in openOrdersNotReturned)
{
additionalUpdates.Add(order with
{
Status = SharedOrderStatus.Canceled
});
}
relevantOrders = relevantOrders.Concat(additionalUpdates).ToArray();
if (relevantOrders.Length > 0)
await HandleUpdateAsync(UpdateSource.Poll, relevantOrders).ConfigureAwait(false);
}
}
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. 1 seconds buffer
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.AddSeconds(-1);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime;
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.AddMilliseconds(-1);
source = "OpenOrder";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
if (DateTime.UtcNow - fromTime < TimeSpan.FromSeconds(1))
{
// 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;
}
}
}
@@ -0,0 +1,153 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
/// <summary>
/// Spot user trade tracker
/// </summary>
public class SpotUserTradeTracker : UserDataItemTracker<SharedUserTrade>
{
private readonly ISpotOrderRestClient _restClient;
private readonly IUserTradeSocketClient? _socketClient;
private readonly ExchangeParameters? _exchangeParameters;
internal Func<string[]>? GetTrackedOrderIds { get; set; }
/// <summary>
/// ctor
/// </summary>
public SpotUserTradeTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
ISpotOrderRestClient restClient,
IUserTradeSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, symbolTracker, UserDataType.Trades, restClient.Exchange, config)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_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 />
protected override bool? CheckIfUpdateShouldBeApplied(SharedUserTrade existingItem, SharedUserTrade updateItem) => false;
/// <inheritdoc />
protected override bool Update(SharedUserTrade existingItem, SharedUserTrade updateItem) => false; // Trades are never updated
/// <inheritdoc />
protected override TimeSpan GetAge(DateTime time, SharedUserTrade item) => time - item.Timestamp;
/// <inheritdoc />
protected override async Task<bool> DoPollAsync()
{
var anyError = false;
var fromTimeTrades = GetTradesRequestStartTime();
var updatedPollTime = DateTime.UtcNow;
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var tradesResult = await _restClient.GetSpotUserTradesAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!tradesResult.Success)
{
anyError = true;
_initialPollingError ??= tradesResult.Error;
if (!_firstPollDone)
break;
}
else
{
// 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();
if (relevantTrades.Length > 0)
await HandleUpdateAsync(UpdateSource.Poll, tradesResult.Data).ConfigureAwait(false);
}
}
if (!anyError)
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
}
return anyError;
}
private DateTime? GetTradesRequestStartTime()
{
// 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. 1 seconds buffer
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
{
fromTime = _lastDataTimeBeforeDisconnect.Value.AddSeconds(-1);
source = "LastDataTimeBeforeDisconnect";
}
// If we've previously polled use that timestamp to request data from
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
{
fromTime = _lastPollTime;
source = "LastPollTime";
}
if (fromTime == null)
{
fromTime = _startTime;
source = "StartTime";
}
if (DateTime.UtcNow - fromTime < TimeSpan.FromSeconds(1))
{
// 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)
{
if (_socketClient == null)
return Task.FromResult(new CallResult<UpdateSubscription?>(data: null));
return ExchangeHelpers.ProcessQueuedAsync<SharedUserTrade[]>(
async handler => await _socketClient.SubscribeToUserTradeUpdatesAsync(new SubscribeUserTradeRequest(listenKey, exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}
}
}
@@ -0,0 +1,507 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Interfaces;
using CryptoExchange.Net.Trackers.UserData.Objects;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
/// <summary>
/// User data tracker
/// </summary>
public abstract class UserDataItemTracker
{
private bool _connected;
/// <summary>
/// Logger
/// </summary>
protected ILogger _logger;
/// <summary>
/// Polling wait event
/// </summary>
protected AsyncResetEvent _pollWaitEvent = new AsyncResetEvent(false, true);
/// <summary>
/// Initial polling done event
/// </summary>
protected AsyncResetEvent _initialPollDoneEvent = new AsyncResetEvent(false, false);
/// <summary>
/// The error from the initial polling;
/// </summary>
protected Error? _initialPollingError;
/// <summary>
/// Polling task
/// </summary>
protected Task? _pollTask;
/// <summary>
/// Cancellation token
/// </summary>
protected CancellationTokenSource? _cts;
/// <summary>
/// Websocket subscription
/// </summary>
protected UpdateSubscription? _subscription;
/// <summary>
/// Start time
/// </summary>
protected DateTime? _startTime = null;
/// <summary>
/// Last polling attempt
/// </summary>
protected DateTime? _lastPollAttempt;
/// <summary>
/// Last polling timestamp
/// </summary>
protected DateTime? _lastPollTime;
/// <summary>
/// Timestamp of last message received before websocket disconnecting
/// </summary>
protected DateTime? _lastDataTimeBeforeDisconnect;
/// <summary>
/// Whether last polling was successful
/// </summary>
protected bool _lastPollSuccess;
/// <summary>
/// Whether first polling was done
/// </summary>
protected bool _firstPollDone;
/// <summary>
/// Whether websocket was disconnected before a polling
/// </summary>
protected bool _wasDisconnected;
/// <summary>
/// Poll at the start
/// </summary>
protected bool _pollAtStart;
/// <summary>
/// Poll interval when connected
/// </summary>
protected TimeSpan _pollIntervalConnected;
/// <summary>
/// Poll interval when disconnected
/// </summary>
protected TimeSpan _pollIntervalDisconnected;
/// <summary>
/// Exchange name
/// </summary>
protected string _exchange;
/// <summary>
/// Time completed data is retained
/// </summary>
public TimeSpan _retentionTime;
/// <summary>
/// Data type
/// </summary>
public UserDataType DataType { get; }
/// <summary>
/// Timestamp an update was handled. Does not necessarily mean the data was changed
/// </summary>
public DateTime? LastUpdateTime { get; protected set; }
/// <summary>
/// Timestamp any change was applied to the data
/// </summary>
public DateTime? LastChangeTime { get; protected set; }
/// <summary>
/// Connection status changed
/// </summary>
public event Action<bool>? OnConnectedChange;
/// <summary>
/// ctor
/// </summary>
public UserDataItemTracker(ILogger logger, UserDataType dataType, string exchange)
{
_logger = logger;
_exchange = exchange;
DataType = dataType;
}
/// <summary>
/// Start the tracker
/// </summary>
/// <param name="listenKey">Optional listen key</param>
public abstract Task<CallResult> StartAsync(string? listenKey);
/// <summary>
/// Stop the tracker
/// </summary>
/// <returns></returns>
public async Task StopAsync()
{
_cts?.Cancel();
if (_pollTask != null)
await _pollTask.ConfigureAwait(false);
}
/// <summary>
/// Get the delay until next poll
/// </summary>
/// <returns></returns>
protected TimeSpan? GetNextPollDelay()
{
if (!_firstPollDone && _pollAtStart)
// First polling should be done immediately
return TimeSpan.Zero;
if (!Connected)
{
if (_pollIntervalDisconnected == TimeSpan.Zero)
// No polling interval
return null;
return _pollIntervalDisconnected;
}
if (_pollIntervalConnected == TimeSpan.Zero)
// No polling interval
return null;
// Wait for next poll
return _pollIntervalConnected;
}
/// <inheritdoc />
public bool Connected
{
get => _connected;
protected set
{
if (_connected == value)
return;
_connected = value;
if (!_connected)
_wasDisconnected = true;
else
_pollWaitEvent.Set();
OnConnectedChange?.Invoke(_connected);
}
}
}
/// <summary>
/// User data tracker
/// </summary>
public abstract class UserDataItemTracker<T> : UserDataItemTracker, IUserDataTracker<T>
{
/// <summary>
/// Data store
/// </summary>
protected ConcurrentDictionary<string, T> _store = new ConcurrentDictionary<string, T>(StringComparer.InvariantCultureIgnoreCase);
/// <summary>
/// Is SharedSymbol model
/// </summary>
protected bool _isSymbolModel;
/// <summary>
/// Symbol tracker
/// </summary>
protected readonly UserDataSymbolTracker _symbolTracker;
/// <inheritdoc />
public T[] Values
{
get
{
if (_retentionTime != TimeSpan.MaxValue)
{
var timestamp = DateTime.UtcNow;
foreach (var value in _store.Values)
{
if (GetAge(timestamp, value) > _retentionTime)
_store.TryRemove(GetKey(value), out _);
}
}
return _store.Values.ToArray();
}
}
/// <inheritdoc />
public event Func<UserDataUpdate<T[]>, Task>? OnUpdate;
/// <summary>
/// ctor
/// </summary>
public UserDataItemTracker(
ILogger logger,
UserDataSymbolTracker symbolTracker,
UserDataType dataType,
string exchange,
TrackerItemConfig config) : base(logger, dataType, exchange)
{
_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>
/// Invoke OnUpdate event
/// </summary>
protected async Task InvokeUpdate(UserDataUpdate<T[]> data)
{
if (OnUpdate == null)
return;
await OnUpdate(data).ConfigureAwait(false);
}
/// <inheritdoc />
public async override Task<CallResult> StartAsync(string? listenKey)
{
_startTime = DateTime.UtcNow;
_cts = new CancellationTokenSource();
var start = await SubscribeAsync(listenKey).ConfigureAwait(false);
if (!start)
return start;
Connected = true;
_pollTask = PollAsync();
await _initialPollDoneEvent.WaitAsync().ConfigureAwait(false);
if (_initialPollingError != null)
{
await StopAsync().ConfigureAwait(false);
return new CallResult(_initialPollingError);
}
return CallResult.SuccessResult;
}
/// <summary>
/// Subscribe the websocket
/// </summary>
public async Task<CallResult> SubscribeAsync(string? listenKey)
{
var subscriptionResult = await DoSubscribeAsync(listenKey).ConfigureAwait(false);
if (!subscriptionResult)
{
// Failed
// ..
return subscriptionResult;
}
if (subscriptionResult.Data == null)
{
// No subscription available
// ..
return CallResult.SuccessResult;
}
_subscription = subscriptionResult.Data;
_subscription.SubscriptionStatusChanged += SubscriptionStatusChanged;
return CallResult.SuccessResult;
}
/// <summary>
/// Get the unique identifier for the item
/// </summary>
protected abstract string GetKey(T item);
/// <summary>
/// Check whether an update should be applied
/// </summary>
protected abstract bool? CheckIfUpdateShouldBeApplied(T existingItem, T updateItem);
/// <summary>
/// Update an existing item with an update
/// </summary>
protected abstract bool Update(T existingItem, T updateItem);
/// <summary>
/// Get the age of an item
/// </summary>
protected virtual TimeSpan GetAge(DateTime time, T item) => TimeSpan.Zero;
/// <summary>
/// Handle an update
/// </summary>
protected internal virtual async Task HandleUpdateAsync(UpdateSource source, T[] @event)
{
LastUpdateTime = DateTime.UtcNow;
if (_isSymbolModel)
{
List<T>? toRemove = null;
foreach (var item in @event)
{
if (item is SharedSymbolModel symbolModel)
{
if (symbolModel.SharedSymbol == null)
{
toRemove ??= new List<T>();
toRemove.Add(item);
_logger.LogWarning("Ignoring {DataType} update for {Key}, no SharedSymbol set", DataType, GetKey(item));
}
else if (!_symbolTracker.ShouldProcess(symbolModel.SharedSymbol))
{
toRemove ??= new List<T>();
toRemove.Add(item);
}
}
}
if (toRemove != null)
@event = @event.Except(toRemove).ToArray();
_symbolTracker.UpdateTrackedSymbols(@event.OfType<SharedSymbolModel>().Select(x => x.SharedSymbol!));
}
// Update local store
var updatedItems = @event.Select(GetKey).ToList();
foreach (var item in @event)
{
bool existed = false;
_store.AddOrUpdate(GetKey(item), item, (key, existing) =>
{
existed = true;
if (CheckIfUpdateShouldBeApplied(existing, item) == false)
{
updatedItems.Remove(key);
}
else
{
var updated = Update(existing, item);
if (!updated)
{
updatedItems.Remove(key);
}
else
{
_logger.LogTrace("Updated {DataType} {Item}", DataType, key);
LastChangeTime = DateTime.UtcNow;
}
}
return existing;
});
if (!existed)
{
_logger.LogTrace("Added {DataType} {Item}", DataType, GetKey(item));
LastChangeTime = DateTime.UtcNow;
}
}
if (updatedItems.Count > 0 && OnUpdate != null)
{
await OnUpdate.Invoke(
new UserDataUpdate<T[]>(source, _exchange, _store.Where(x => updatedItems.Contains(x.Key)).Select(x => x.Value).ToArray())).ConfigureAwait(false);
}
}
/// <summary>
/// Websocket subscription implementation
/// </summary>
protected abstract Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey);
/// <summary>
/// Polling task
/// </summary>
protected async Task PollAsync()
{
while (!_cts!.IsCancellationRequested)
{
var delayForNextPoll = GetNextPollDelay();
if (delayForNextPoll != TimeSpan.Zero)
{
try
{
if (delayForNextPoll != null)
_logger.LogTrace("{DataType} delay for next polling: {Delay}", DataType, delayForNextPoll);
await _pollWaitEvent.WaitAsync(delayForNextPoll, _cts.Token).ConfigureAwait(false);
}
catch { }
}
var currentlyFirstPoll = !_firstPollDone;
_firstPollDone = true;
if (_cts.IsCancellationRequested)
break;
if (_lastPollAttempt != null
&& (DateTime.UtcNow - _lastPollAttempt.Value) < TimeSpan.FromSeconds(2)
&& !(Connected && _wasDisconnected))
{
if (_lastPollSuccess)
// If last poll was less than 2 seconds ago and it was successful don't bother immediately polling again
continue;
}
if (Connected)
_wasDisconnected = false;
_lastPollSuccess = false;
try
{
var anyError = await DoPollAsync().ConfigureAwait(false);
_initialPollDoneEvent.Set();
_lastPollAttempt = DateTime.UtcNow;
_lastPollSuccess = !anyError;
if (anyError && currentlyFirstPoll && _pollAtStart)
{
if (_initialPollingError == null)
throw new Exception("Error in initial polling but error not set");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "{DataType} UserDataTracker polling exception", DataType);
}
}
}
/// <summary>
/// Polling implementation
/// </summary>
/// <returns></returns>
protected abstract Task<bool> DoPollAsync();
/// <summary>
/// Handle subscription status change
/// </summary>
/// <param name="newState"></param>
private void SubscriptionStatusChanged(SubscriptionStatus newState)
{
_logger.LogDebug("{DataType} stream status changed: {NewState}", DataType, newState);
if (newState == SubscriptionStatus.Pending)
{
// Record last data receive time since we need to request data from that timestamp on when polling
// Only set to new value if it isn't already set since if we disconnect/reconnect a couple of times without
// managing to do a poll we don't want to override the time since we still need to request that earlier data
if (_lastDataTimeBeforeDisconnect == null)
{
_lastDataTimeBeforeDisconnect = _subscription!.LastReceiveTime;
// When changing to pending (disconnected) trigger polling to start checking
_pollWaitEvent.Set();
}
}
Connected = newState == SubscriptionStatus.Subscribed;
}
}
}
@@ -0,0 +1,57 @@
using System;
namespace CryptoExchange.Net.Trackers.UserData.Objects
{
/// <summary>
/// Tracker configuration
/// </summary>
public record TrackerItemConfig
{
/// <summary>
/// Interval to poll data at as backup, even when the websocket stream is still connected.
/// </summary>
public TimeSpan PollIntervalConnected { get; set; } = TimeSpan.Zero;
/// <summary>
/// Interval to poll data at while the websocket is disconnected.
/// </summary>
public TimeSpan PollIntervalDisconnected { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether to poll for data initially when starting the tracker.
/// </summary>
public bool PollAtStart { get; set; } = true;
/// <summary>
/// ctor
/// </summary>
/// <param name="pollAtStart">Whether to poll for data initially when starting the tracker</param>
/// <param name="pollIntervalConnected">Interval to poll data at as backup, even when the websocket stream is still connected</param>
/// <param name="pollIntervalDisconnected">Interval to poll data at while the websocket is disconnected</param>
public TrackerItemConfig(bool pollAtStart, TimeSpan pollIntervalConnected, TimeSpan pollIntervalDisconnected)
{
PollAtStart = pollAtStart;
PollIntervalConnected = pollIntervalConnected;
PollIntervalDisconnected = pollIntervalDisconnected;
}
}
/// <inheritdoc />
public record TrackerTimedItemConfig: TrackerItemConfig
{
/// <summary>
/// The timespan data is retained after being completed
/// </summary>
public TimeSpan RetentionTime { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// ctor
/// </summary>
/// <param name="pollAtStart">Whether to poll for data initially when starting the tracker</param>
/// <param name="pollIntervalConnected">Interval to poll data at as backup, even when the websocket stream is still connected</param>
/// <param name="pollIntervalDisconnected">Interval to poll data at while the websocket is disconnected</param>
/// <param name="retentionTime">The timespan data is retained after being completed</param>
public TrackerTimedItemConfig(bool pollAtStart, TimeSpan pollIntervalConnected, TimeSpan pollIntervalDisconnected, TimeSpan retentionTime) : base(pollAtStart, pollIntervalConnected, pollIntervalDisconnected)
{
RetentionTime = retentionTime;
}
}
}
@@ -0,0 +1,17 @@
namespace CryptoExchange.Net.Trackers.UserData.Objects
{
/// <summary>
/// Update source
/// </summary>
public enum UpdateSource
{
/// <summary>
/// Polling result
/// </summary>
Poll,
/// <summary>
/// Websocket push
/// </summary>
Push
}
}
@@ -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);
}
}
}
}
@@ -0,0 +1,68 @@
using CryptoExchange.Net.SharedApis;
using System;
using System.Collections.Generic;
namespace CryptoExchange.Net.Trackers.UserData.Objects
{
/// <summary>
/// User data tracker configuration
/// </summary>
public abstract record UserDataTrackerConfig
{
/// <summary>
/// Symbols to initially track, used when polling data. Other symbols will get tracked when updates are received for orders or trades on a new symbol and when there are open orders or positions on a new symbol. To only track the symbols specified here set `OnlyTrackProvidedSymbols` to true.
/// </summary>
public IEnumerable<SharedSymbol> TrackedSymbols { get; set; } = [];
/// <summary>
/// If true only orders and trades in the `Symbols` options will get tracked, data on other symbols will be ignored.
/// </summary>
public bool OnlyTrackProvidedSymbols { get; set; } = false;
/// <summary>
/// Whether to track order trades, can lead to increased requests when polling since they're requested per symbol.
/// </summary>
public bool TrackTrades { get; set; } = true;
}
/// <summary>
/// Spot user data tracker config
/// </summary>
public record SpotUserDataTrackerConfig : UserDataTrackerConfig
{
/// <summary>
/// Balance tracking config
/// </summary>
public TrackerItemConfig BalancesConfig { get; set; } = new TrackerItemConfig(true, TimeSpan.Zero, TimeSpan.FromSeconds(10));
/// <summary>
/// Order tracking config
/// </summary>
public TrackerTimedItemConfig OrdersConfig { get; set; } = new TrackerTimedItemConfig(true, TimeSpan.Zero, TimeSpan.FromSeconds(30), TimeSpan.MaxValue);
/// <summary>
/// Trade tracking config
/// </summary>
public TrackerTimedItemConfig UserTradesConfig { get; set; } = new TrackerTimedItemConfig(false, TimeSpan.Zero, TimeSpan.FromSeconds(30), TimeSpan.MaxValue);
}
/// <summary>
/// Futures user data tracker config
/// </summary>
public record FuturesUserDataTrackerConfig : UserDataTrackerConfig
{
/// <summary>
/// Balance tracking config
/// </summary>
public TrackerItemConfig BalancesConfig { get; set; } = new TrackerItemConfig(true, TimeSpan.Zero, TimeSpan.FromSeconds(10));
/// <summary>
/// Order tracking config
/// </summary>
public TrackerTimedItemConfig OrdersConfig { get; set; } = new TrackerTimedItemConfig(true, TimeSpan.Zero, TimeSpan.FromSeconds(30), TimeSpan.MaxValue);
/// <summary>
/// Trade tracking config
/// </summary>
public TrackerTimedItemConfig UserTradesConfig { get; set; } = new TrackerTimedItemConfig(false, TimeSpan.Zero, TimeSpan.FromSeconds(30), TimeSpan.MaxValue);
/// <summary>
/// Position tracking config
/// </summary>
public TrackerItemConfig PositionConfig { get; set; } = new TrackerItemConfig(true, TimeSpan.Zero, TimeSpan.FromSeconds(30));
}
}
@@ -0,0 +1,25 @@
namespace CryptoExchange.Net.Trackers.UserData.Objects
{
/// <summary>
/// Data type
/// </summary>
public enum UserDataType
{
/// <summary>
/// Balances
/// </summary>
Balances,
/// <summary>
/// Orders
/// </summary>
Orders,
/// <summary>
/// Trades
/// </summary>
Trades,
/// <summary>
/// Positions
/// </summary>
Positions
}
}
@@ -0,0 +1,32 @@
namespace CryptoExchange.Net.Trackers.UserData.Objects
{
/// <summary>
/// User data update
/// </summary>
/// <typeparam name="T">Data type</typeparam>
public class UserDataUpdate<T>
{
/// <summary>
/// Source
/// </summary>
public UpdateSource Source { get; set; }
/// <summary>
/// Exchange name
/// </summary>
public string Exchange { get; set; }
/// <summary>
/// Data
/// </summary>
public T Data { get; set; } = default!;
/// <summary>
/// ctor
/// </summary>
public UserDataUpdate(UpdateSource source, string exchange, T data)
{
Source = source;
Exchange = exchange;
Data = data;
}
}
}
@@ -0,0 +1,140 @@
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
{
/// <summary>
/// User data tracker
/// </summary>
public abstract class UserDataTracker
{
/// <summary>
/// Logger
/// </summary>
protected readonly ILogger _logger;
/// <summary>
/// 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; }
/// <summary>
/// Connected status changed
/// </summary>
public event Action<UserDataType, bool>? OnConnectedChange;
/// <summary>
/// Exchange name
/// </summary>
public string Exchange { get; }
/// <summary>
/// Whether all trackers are full connected
/// </summary>
public bool Connected => DataTrackers.All(x => x.Connected);
/// <summary>
/// Currently tracked symbols
/// </summary>
public IEnumerable<SharedSymbol> TrackedSymbols => SymbolTracker.GetTrackedSymbols();
/// <summary>
/// ctor
/// </summary>
public UserDataTracker(
ILogger logger,
string exchange,
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;
}
/// <summary>
/// Start the data tracker
/// </summary>
public async Task<CallResult> StartAsync()
{
_cts = new CancellationTokenSource();
foreach(var tracker in DataTrackers)
tracker.OnConnectedChange += (x) => OnConnectedChange?.Invoke(tracker.DataType, x);
var result = await DoStartAsync().ConfigureAwait(false);
if (!result)
return result;
var tasks = new List<Task<CallResult>>();
foreach (var dataTracker in DataTrackers)
tasks.Add(dataTracker.StartAsync(_listenKey));
await Task.WhenAll(tasks).ConfigureAwait(false);
if (!tasks.All(x => x.Result.Success))
{
await Task.WhenAll(DataTrackers.Select(x => x.StopAsync())).ConfigureAwait(false);
return tasks.First(x => !x.Result.Success).Result;
}
return CallResult.SuccessResult;
}
/// <summary>
/// Implementation specific start logic
/// </summary>
protected abstract Task<CallResult> DoStartAsync();
/// <summary>
/// Stop the data tracker
/// </summary>
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;
}
}
@@ -0,0 +1,183 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.SharedApis;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using System.Linq;
using CryptoExchange.Net.Trackers.UserData.ItemTrackers;
using CryptoExchange.Net.Trackers.UserData.Interfaces;
using CryptoExchange.Net.Trackers.UserData.Objects;
namespace CryptoExchange.Net.Trackers.UserData
{
/// <summary>
/// User futures data tracker
/// </summary>
public abstract class UserFuturesDataTracker : UserDataTracker, IUserFuturesDataTracker
{
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; }
/// <summary>
/// Balances tracker
/// </summary>
public IUserDataTracker<SharedBalance> Balances { get; }
/// <summary>
/// Orders tracker
/// </summary>
public IUserDataTracker<SharedFuturesOrder> Orders { get; }
/// <summary>
/// Positions tracker
/// </summary>
public IUserDataTracker<SharedPosition> Positions { get; }
/// <summary>
/// Trades tracker
/// </summary>
public IUserDataTracker<SharedUserTrade>? Trades { get; }
/// <summary>
/// Whether websocket position updates are full snapshots and missing positions should be considered 0
/// </summary>
protected abstract bool WebsocketPositionUpdatesAreFullSnapshots { get; }
/// <summary>
/// ctor
/// </summary>
public UserFuturesDataTracker(
ILogger logger,
IFuturesSymbolRestClient symbolRestClient,
IListenKeyRestClient? listenKeyRestClient,
IBalanceRestClient balanceRestClient,
IBalanceSocketClient? balanceSocketClient,
IFuturesOrderRestClient futuresOrderRestClient,
IFuturesOrderSocketClient? futuresOrderSocketClient,
IUserTradeSocketClient? userTradeSocketClient,
IPositionSocketClient? positionSocketClient,
string? userIdentifier,
FuturesUserDataTrackerConfig config,
SharedAccountType? accountType = null,
ExchangeParameters? exchangeParameters = null) : base(logger, symbolRestClient.Exchange, config, userIdentifier)
{
// create trackers
_symbolClient = symbolRestClient;
_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 balanceTracker = new BalanceTracker(logger, SymbolTracker, balanceRestClient, balanceSocketClient, accountType ?? SharedAccountType.PerpetualLinearFutures, config.BalancesConfig, exchangeParameters);
Balances = balanceTracker;
trackers.Add(balanceTracker);
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, SymbolTracker, futuresOrderRestClient, positionSocketClient, config.PositionConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, WebsocketPositionUpdatesAreFullSnapshots, exchangeParameters);
Positions = positionTracker;
trackers.Add(positionTracker);
if (config.TrackTrades)
{
var tradeTracker = new FuturesUserTradeTracker(logger, SymbolTracker, futuresOrderRestClient, userTradeSocketClient, config.UserTradesConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Trades = tradeTracker;
trackers.Add(tradeTracker);
orderTracker.OnTradeUpdate += tradeTracker.HandleUpdateAsync;
tradeTracker.GetTrackedOrderIds = () => orderTracker.Values.Select(x => x.OrderId).ToArray();
}
DataTrackers = trackers.ToArray();
}
/// <inheritdoc />
protected override async Task<CallResult> DoStartAsync()
{
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);
return symbolResult;
}
if (_listenKeyClient != null)
{
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);
}
}
}
@@ -0,0 +1,156 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.SharedApis;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Threading.Tasks;
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
{
/// <summary>
/// Spot user data tracker
/// </summary>
public class UserSpotDataTracker : UserDataTracker, IUserSpotDataTracker
{
private readonly ISpotSymbolRestClient _symbolClient;
private readonly IListenKeyRestClient? _listenKeyClient;
private readonly ExchangeParameters? _exchangeParameters;
private Task? _lkKeepAliveTask;
/// <inheritdoc />
protected override UserDataItemTracker[] DataTrackers { get; }
/// <inheritdoc />
public IUserDataTracker<SharedBalance> Balances { get; }
/// <inheritdoc />
public IUserDataTracker<SharedSpotOrder> Orders { get; }
/// <inheritdoc />
public IUserDataTracker<SharedUserTrade>? Trades { get; }
/// <summary>
/// ctor
/// </summary>
public UserSpotDataTracker(
ILogger logger,
ISpotSymbolRestClient symbolRestClient,
IListenKeyRestClient? listenKeyRestClient,
IBalanceRestClient balanceRestClient,
IBalanceSocketClient? balanceSocketClient,
ISpotOrderRestClient spotOrderRestClient,
ISpotOrderSocketClient? spotOrderSocketClient,
IUserTradeSocketClient? userTradeSocketClient,
string? userIdentifier,
SpotUserDataTrackerConfig config,
ExchangeParameters? exchangeParameters = null) : base(logger, symbolRestClient.Exchange, config, userIdentifier)
{
// create trackers
_symbolClient = symbolRestClient;
_listenKeyClient = listenKeyRestClient;
_exchangeParameters = exchangeParameters;
var trackers = new List<UserDataItemTracker>();
var balanceTracker = new BalanceTracker(logger, SymbolTracker, balanceRestClient, balanceSocketClient, SharedAccountType.Spot, config.BalancesConfig, exchangeParameters);
Balances = balanceTracker;
trackers.Add(balanceTracker);
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, SymbolTracker, spotOrderRestClient, userTradeSocketClient, config.UserTradesConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Trades = tradeTracker;
trackers.Add(tradeTracker);
orderTracker.OnTradeUpdate += tradeTracker.HandleUpdateAsync;
tradeTracker.GetTrackedOrderIds = () => orderTracker.Values.Select(x => x.OrderId).ToArray();
}
DataTrackers = trackers.ToArray();
}
/// <inheritdoc />
protected override async Task<CallResult> DoStartAsync()
{
var symbolResult = await _symbolClient.GetSpotSymbolsAsync(new GetSymbolsRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!symbolResult)
{
_logger.LogWarning("Failed to start UserSpotDataTracker; symbols request failed: {Error}", symbolResult.Error);
return symbolResult;
}
if (_listenKeyClient != null)
{
var lkResult = await _listenKeyClient.StartListenKeyAsync(new StartListenKeyRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!lkResult)
{
_logger.LogWarning("Failed to start UserSpotDataTracker; 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.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>
+57
View File
@@ -67,6 +67,63 @@ 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.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
* Version 10.4.0 - 06 Feb 2026
* Added user data tracking logic
* Added LastReceiveTime, SocketStatus and SubscriptionStatus properties to UpdateSubscription
* Added SharedTransferStatus Enum and property to SharedDeposit
* Added PositionMode property to SharedPosition model
* Added IsZero property to SharedQuantity
* Added additional methods for requesting supported symbols to Shared ISpotSymbolRestClient/IFuturesSymbolRestClient interfaces
* Added Disposed property on BaseClient and IRestClient/ISocketClient interfaces
* Added AutoTimestamp option for socket client
* Renamed IWebSocket LastActionTime to LastReceiveTime
* Refactored AsyncResetEvent implementation
* Updated CryptoExchangeWebsocketClient LastReceiveTime logic
* Updated Subscription status change event handler to run sync instead of separate task
* Updated Interval property access on KlineTracker to public
* Fixed socket client timestamp offset bug
* Version 10.3.1 - 27 Jan 2026
* Fixed potential collection modified exception upon logging message not handled in websocket message handling