mirror of
https://github.com/JKorf/CryptoExchange.Net.git
synced 2026-08-12 00:43:03 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 297eee0e1f | |||
| 6a9231b1e3 | |||
| e40f2a15b6 | |||
| b94085a27a | |||
| 5e083811df | |||
| 7dcf5cd6ea | |||
| 1471a4733f | |||
| f39d9f7cfb | |||
| 9fab8faa45 | |||
| 226f175343 | |||
| 813bd9f5a1 | |||
| c8d2b4f09d | |||
| 6560b82a3e | |||
| e151af8f37 | |||
| bdf7a07c6f | |||
| a8ffe90bf2 | |||
| 3372b9eb44 | |||
| df25221960 | |||
| 7c67a014f5 | |||
| 65291b3195 | |||
| ece6a9d27d | |||
| 1ed29b0474 | |||
| be2eb01353 | |||
| 39cd66596d | |||
| 63f51811a9 | |||
| abda065237 | |||
| 759c8b9a58 | |||
| 8e18482781 | |||
| 87dd2d9d40 | |||
| f2b0cb2f0d | |||
| de0a954a91 | |||
| 4a79ce22ec | |||
| 40d480e1fc | |||
| 74e5cf6fc9 | |||
| 2fd3912795 | |||
| ec44307a0c | |||
| ba55705385 | |||
| 2c63a83117 | |||
| 71b1e5e906 | |||
| eaeba6f27e | |||
| 913bdaa855 | |||
| 5aa5790d0a | |||
| a8321e083e | |||
| ce3fa5f186 | |||
| fff70a9c65 | |||
| cff33bb5ac | |||
| 21c8133292 | |||
| 76772e91ba | |||
| 218e0260ce | |||
| 96b3904266 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,6 @@ namespace CryptoExchange.Net.UnitTests
|
||||
Assert.That(result.Success);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void DeserializingInvalidJson_Should_GiveErrorResult()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestBaseClient();
|
||||
|
||||
// act
|
||||
var result = client.SubClient.Deserialize<object>("{\"testProperty\": 123");
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "/path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1/", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Clients;
|
||||
@@ -51,19 +52,11 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
public CallResult<T> Deserialize<T>(string data)
|
||||
{
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(data));
|
||||
var accessor = CreateAccessor();
|
||||
var valid = accessor.Read(stream, true).Result;
|
||||
if (!valid)
|
||||
return new CallResult<T>(new ServerError(ErrorInfo.Unknown with { Message = data }));
|
||||
|
||||
var deserializeResult = accessor.Deserialize<T>();
|
||||
return deserializeResult;
|
||||
return new CallResult<T>(JsonSerializer.Deserialize<T>(data));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
|
||||
protected override IStreamMessageAccessor CreateAccessor() => new SystemTextJsonStreamMessageAccessor(new System.Text.Json.JsonSerializerOptions());
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions());
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials) => throw new NotImplementedException();
|
||||
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
|
||||
|
||||
@@ -142,7 +142,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
/// <inheritdoc />
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
|
||||
|
||||
protected override IStreamMessageAccessor CreateAccessor() => new SystemTextJsonStreamMessageAccessor(new System.Text.Json.JsonSerializerOptions() { TypeInfoResolver = new TestSerializerContext() });
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions());
|
||||
|
||||
public async Task<CallResult<T>> Request<T>(CancellationToken ct = default) where T : class
|
||||
@@ -178,7 +177,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
RequestFactory = new Mock<IRequestFactory>().Object;
|
||||
}
|
||||
|
||||
protected override IStreamMessageAccessor CreateAccessor() => new SystemTextJsonStreamMessageAccessor(new System.Text.Json.JsonSerializerOptions());
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions());
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -114,12 +114,6 @@ namespace CryptoExchange.Net.Clients
|
||||
RequestFactory.Configure(options, httpClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a message accessor instance
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected abstract IStreamMessageAccessor CreateAccessor();
|
||||
|
||||
/// <summary>
|
||||
/// Create a serializer instance
|
||||
/// </summary>
|
||||
@@ -727,7 +721,16 @@ namespace CryptoExchange.Net.Clients
|
||||
return;
|
||||
|
||||
var localTime = DateTime.UtcNow;
|
||||
var result = await GetServerTimestampAsync().ConfigureAwait(false);
|
||||
WebCallResult<DateTime> result;
|
||||
try
|
||||
{
|
||||
result = await GetServerTimestampAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (NotImplementedException)
|
||||
{
|
||||
throw new ArgumentException("AutoTimestamp is not available for this API");
|
||||
}
|
||||
|
||||
if (!result)
|
||||
{
|
||||
_logger.LogWarning("Failed to determine time offset between client and server, timestamping might fail");
|
||||
|
||||
@@ -99,11 +99,6 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
protected bool AllowTopicsOnTheSameConnection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to continue processing and forward unparsable messages to handlers
|
||||
/// </summary>
|
||||
protected internal bool ProcessUnparsableMessages { get; set; } = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public double IncomingKbps
|
||||
{
|
||||
@@ -165,12 +160,6 @@ namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a message accessor instance
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected internal abstract IByteMessageAccessor CreateAccessor(WebSocketMessageType messageType);
|
||||
|
||||
/// <summary>
|
||||
/// Create a serializer instance
|
||||
/// </summary>
|
||||
@@ -315,52 +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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
@@ -751,7 +697,6 @@ namespace CryptoExchange.Net.Clients
|
||||
|
||||
// Create new socket connection
|
||||
var socketConnection = new SocketConnection(_logger, SocketFactory, GetWebSocketParameters(connectionAddress.Data!), this, address);
|
||||
socketConnection.UnhandledMessage += HandleUnhandledMessage;
|
||||
socketConnection.ConnectRateLimitedAsync += HandleConnectRateLimitedAsync;
|
||||
if (dedicatedRequestConnection)
|
||||
{
|
||||
@@ -802,14 +747,6 @@ namespace CryptoExchange.Net.Clients
|
||||
return new CallResult<HighPerfSocketConnection<TUpdateType>>(socketConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process an unhandled message
|
||||
/// </summary>
|
||||
/// <param name="message">The message that wasn't processed</param>
|
||||
protected virtual void HandleUnhandledMessage(IMessageAccessor message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process an unhandled message
|
||||
/// </summary>
|
||||
@@ -870,7 +807,6 @@ namespace CryptoExchange.Net.Clients
|
||||
Proxy = ClientOptions.Proxy,
|
||||
Timeout = ApiOptions.SocketNoDataTimeout ?? ClientOptions.SocketNoDataTimeout,
|
||||
ReceiveBufferSize = ClientOptions.ReceiveBufferSize,
|
||||
UseUpdatedDeserialization = ClientOptions.UseUpdatedDeserialization
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -1063,7 +999,6 @@ namespace CryptoExchange.Net.Clients
|
||||
sb.AppendLine($"\t\t\tId: {subState.Id}");
|
||||
sb.AppendLine($"\t\t\tStatus: {subState.Status}");
|
||||
sb.AppendLine($"\t\t\tInvocations: {subState.Invocations}");
|
||||
sb.AppendLine($"\t\t\tIdentifiers: [{subState.ListenMatcher.ToString()}]");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1094,21 +1029,10 @@ namespace CryptoExchange.Net.Clients
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the listener identifier for the message
|
||||
/// </summary>
|
||||
/// <param name="messageAccessor"></param>
|
||||
/// <returns></returns>
|
||||
public abstract string? GetListenerIdentifier(IMessageAccessor messageAccessor);
|
||||
|
||||
/// <summary>
|
||||
/// Preprocess a stream message
|
||||
/// </summary>
|
||||
public virtual ReadOnlySpan<byte> PreprocessStreamMessage(SocketConnection connection, WebSocketMessageType type, ReadOnlySpan<byte> data) => data;
|
||||
/// <summary>
|
||||
/// Preprocess a stream message
|
||||
/// </summary>
|
||||
public virtual ReadOnlyMemory<byte> PreprocessStreamMessage(SocketConnection connection, WebSocketMessageType type, ReadOnlyMemory<byte> data) => data;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new message converter instance
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Node accessor
|
||||
/// </summary>
|
||||
public readonly struct NodeAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Index
|
||||
/// </summary>
|
||||
public int? Index { get; }
|
||||
/// <summary>
|
||||
/// Property name
|
||||
/// </summary>
|
||||
public string? Property { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Type (0 = int, 1 = string, 2 = prop name)
|
||||
/// </summary>
|
||||
public int Type { get; }
|
||||
|
||||
private NodeAccessor(int? index, string? property, int type)
|
||||
{
|
||||
Index = index;
|
||||
Property = property;
|
||||
Type = type;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an int node accessor
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static NodeAccessor Int(int value) { return new NodeAccessor(value, null, 0); }
|
||||
|
||||
/// <summary>
|
||||
/// Create a string node accessor
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static NodeAccessor String(string value) { return new NodeAccessor(null, value, 1); }
|
||||
|
||||
/// <summary>
|
||||
/// Create a property name node accessor
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static NodeAccessor PropertyName() { return new NodeAccessor(null, null, 2); }
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Message access definition
|
||||
/// </summary>
|
||||
public readonly struct MessagePath : IEnumerable<NodeAccessor>
|
||||
{
|
||||
private readonly List<NodeAccessor> _path;
|
||||
|
||||
internal void Add(NodeAccessor node)
|
||||
{
|
||||
_path.Add(node);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public MessagePath()
|
||||
{
|
||||
_path = new List<NodeAccessor>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new message path
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static MessagePath Get()
|
||||
{
|
||||
return new MessagePath();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IEnumerable implementation
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public IEnumerator<NodeAccessor> GetEnumerator()
|
||||
{
|
||||
for (var i = 0; i < _path.Count; i++)
|
||||
yield return _path[i];
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Message path extension methods
|
||||
/// </summary>
|
||||
public static class MessagePathExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a string node accessor
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="propName"></param>
|
||||
/// <returns></returns>
|
||||
public static MessagePath Property(this MessagePath path, string propName)
|
||||
{
|
||||
path.Add(NodeAccessor.String(propName));
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a property name node accessor
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
public static MessagePath PropertyName(this MessagePath path)
|
||||
{
|
||||
path.Add(NodeAccessor.PropertyName());
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a int node accessor
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="index"></param>
|
||||
/// <returns></returns>
|
||||
public static MessagePath Index(this MessagePath path, int index)
|
||||
{
|
||||
path.Add(NodeAccessor.Int(index));
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Message node type
|
||||
/// </summary>
|
||||
public enum NodeType
|
||||
{
|
||||
/// <summary>
|
||||
/// Array node
|
||||
/// </summary>
|
||||
Array,
|
||||
/// <summary>
|
||||
/// Object node
|
||||
/// </summary>
|
||||
Object,
|
||||
/// <summary>
|
||||
/// Value node
|
||||
/// </summary>
|
||||
Value
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
using CryptoExchange.Net.Converters.MessageParsing;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
{
|
||||
/// <summary>
|
||||
/// System.Text.Json message accessor
|
||||
/// </summary>
|
||||
public abstract class SystemTextJsonMessageAccessor : IMessageAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// The JsonDocument loaded
|
||||
/// </summary>
|
||||
protected JsonDocument? _document;
|
||||
|
||||
private readonly JsonSerializerOptions? _customSerializerOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsValid { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract bool OriginalDataAvailable { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? Underlying => throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public SystemTextJsonMessageAccessor(JsonSerializerOptions options)
|
||||
{
|
||||
_customSerializerOptions = options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
public CallResult<object> Deserialize(Type type, MessagePath? path = null)
|
||||
{
|
||||
if (!IsValid)
|
||||
return new CallResult<object>(GetOriginalString());
|
||||
|
||||
if (_document == null)
|
||||
throw new InvalidOperationException("No json document loaded");
|
||||
|
||||
try
|
||||
{
|
||||
var result = _document.Deserialize(type, _customSerializerOptions);
|
||||
return new CallResult<object>(result!);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var info = $"Json deserialization failed: {ex.Message}, Path: {ex.Path}, LineNumber: {ex.LineNumber}, LinePosition: {ex.BytePositionInLine}";
|
||||
return new CallResult<object>(new DeserializeError(info, ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CallResult<object>(new DeserializeError($"Json deserialization failed: {ex.Message}", ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
public CallResult<T> Deserialize<T>(MessagePath? path = null)
|
||||
{
|
||||
if (_document == null)
|
||||
throw new InvalidOperationException("No json document loaded");
|
||||
|
||||
try
|
||||
{
|
||||
var result = _document.Deserialize<T>(_customSerializerOptions);
|
||||
return new CallResult<T>(result!);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var info = $"Json deserialization failed: {ex.Message}, Path: {ex.Path}, LineNumber: {ex.LineNumber}, LinePosition: {ex.BytePositionInLine}";
|
||||
return new CallResult<T>(new DeserializeError(info, ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CallResult<T>(new DeserializeError($"Json deserialization failed: {ex.Message}", ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public NodeType? GetNodeType()
|
||||
{
|
||||
if (!IsValid)
|
||||
throw new InvalidOperationException("Can't access json data on non-json message");
|
||||
|
||||
if (_document == null)
|
||||
throw new InvalidOperationException("No json document loaded");
|
||||
|
||||
return _document.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => NodeType.Object,
|
||||
JsonValueKind.Array => NodeType.Array,
|
||||
_ => NodeType.Value
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public NodeType? GetNodeType(MessagePath path)
|
||||
{
|
||||
if (!IsValid)
|
||||
throw new InvalidOperationException("Can't access json data on non-json message");
|
||||
|
||||
var node = GetPathNode(path);
|
||||
if (!node.HasValue)
|
||||
return null;
|
||||
|
||||
return node.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => NodeType.Object,
|
||||
JsonValueKind.Array => NodeType.Array,
|
||||
_ => NodeType.Value
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
public T? GetValue<T>(MessagePath path)
|
||||
{
|
||||
if (!IsValid)
|
||||
throw new InvalidOperationException("Can't access json data on non-json message");
|
||||
|
||||
var value = GetPathNode(path);
|
||||
if (value == null)
|
||||
return default;
|
||||
|
||||
if (value.Value.ValueKind == JsonValueKind.Object || value.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
try
|
||||
{
|
||||
return value.Value.Deserialize<T>(_customSerializerOptions);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(string))
|
||||
{
|
||||
if (value.Value.ValueKind == JsonValueKind.Number)
|
||||
return (T)(object)value.Value.GetInt64().ToString();
|
||||
}
|
||||
|
||||
return value.Value.Deserialize<T>(_customSerializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
public T?[]? GetValues<T>(MessagePath path)
|
||||
{
|
||||
if (!IsValid)
|
||||
throw new InvalidOperationException("Can't access json data on non-json message");
|
||||
|
||||
var value = GetPathNode(path);
|
||||
if (value == null)
|
||||
return default;
|
||||
|
||||
if (value.Value.ValueKind != JsonValueKind.Array)
|
||||
return default;
|
||||
|
||||
return value.Value.Deserialize<T[]>(_customSerializerOptions)!;
|
||||
}
|
||||
|
||||
private JsonElement? GetPathNode(MessagePath path)
|
||||
{
|
||||
if (!IsValid)
|
||||
throw new InvalidOperationException("Can't access json data on non-json message");
|
||||
|
||||
if (_document == null)
|
||||
throw new InvalidOperationException("No json document loaded");
|
||||
|
||||
JsonElement? currentToken = _document.RootElement;
|
||||
foreach (var node in path)
|
||||
{
|
||||
if (node.Type == 0)
|
||||
{
|
||||
// Int value
|
||||
var val = node.Index!.Value;
|
||||
if (currentToken!.Value.ValueKind != JsonValueKind.Array || currentToken.Value.GetArrayLength() <= val)
|
||||
return null;
|
||||
|
||||
currentToken = currentToken.Value[val];
|
||||
}
|
||||
else if (node.Type == 1)
|
||||
{
|
||||
// String value
|
||||
if (currentToken!.Value.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
if (!currentToken.Value.TryGetProperty(node.Property!, out var token))
|
||||
return null;
|
||||
currentToken = token;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Property name
|
||||
if (currentToken!.Value.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
if (currentToken == null)
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string GetOriginalString();
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract void Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json stream message accessor
|
||||
/// </summary>
|
||||
public class SystemTextJsonStreamMessageAccessor : SystemTextJsonMessageAccessor, IStreamMessageAccessor
|
||||
{
|
||||
private Stream? _stream;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool OriginalDataAvailable => _stream?.CanSeek == true;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public SystemTextJsonStreamMessageAccessor(JsonSerializerOptions options): base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallResult> Read(Stream stream, bool bufferStream)
|
||||
{
|
||||
if (bufferStream && stream is not MemoryStream)
|
||||
{
|
||||
// We need to be buffer the stream, and it's not currently a seekable stream, so copy it to a new memory stream
|
||||
_stream = new MemoryStream();
|
||||
stream.CopyTo(_stream);
|
||||
_stream.Position = 0;
|
||||
}
|
||||
else if (bufferStream)
|
||||
{
|
||||
// We need to buffer the stream, and the current stream is seekable, store as is
|
||||
_stream = stream;
|
||||
}
|
||||
else
|
||||
{
|
||||
// We don't need to buffer the stream, so don't bother keeping the reference
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_document = await JsonDocument.ParseAsync(_stream ?? stream).ConfigureAwait(false);
|
||||
IsValid = true;
|
||||
return CallResult.SuccessResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Not a json message
|
||||
IsValid = false;
|
||||
return new CallResult(new DeserializeError($"Json deserialization failed: {ex.Message}", ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string GetOriginalString()
|
||||
{
|
||||
if (_stream is null)
|
||||
throw new NullReferenceException("Stream not initialized");
|
||||
|
||||
_stream.Position = 0;
|
||||
using var textReader = new StreamReader(_stream, Encoding.UTF8, false, 1024, true);
|
||||
return textReader.ReadToEnd();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Clear()
|
||||
{
|
||||
_stream?.Dispose();
|
||||
_stream = null;
|
||||
_document?.Dispose();
|
||||
_document = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json byte message accessor
|
||||
/// </summary>
|
||||
public class SystemTextJsonByteMessageAccessor : SystemTextJsonMessageAccessor, IByteMessageAccessor
|
||||
{
|
||||
private ReadOnlyMemory<byte> _bytes;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public SystemTextJsonByteMessageAccessor(JsonSerializerOptions options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CallResult Read(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
_bytes = data;
|
||||
|
||||
try
|
||||
{
|
||||
var firstByte = data.Span[0];
|
||||
if (firstByte != 0x7b && firstByte != 0x5b)
|
||||
{
|
||||
// Value doesn't start with `{` or `[`, prevent deserialization attempt as it's slow
|
||||
IsValid = false;
|
||||
return new CallResult(new DeserializeError("Not a json value"));
|
||||
}
|
||||
|
||||
_document = JsonDocument.Parse(data);
|
||||
IsValid = true;
|
||||
return CallResult.SuccessResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Not a json message
|
||||
IsValid = false;
|
||||
return new CallResult(new DeserializeError($"Json deserialization failed: {ex.Message}", ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string GetOriginalString() =>
|
||||
// NetStandard 2.0 doesn't support GetString from a ReadonlySpan<byte>, so use ToArray there instead
|
||||
#if NETSTANDARD2_0
|
||||
Encoding.UTF8.GetString(_bytes.ToArray());
|
||||
#else
|
||||
Encoding.UTF8.GetString(_bytes.Span);
|
||||
#endif
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool OriginalDataAvailable => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Clear()
|
||||
{
|
||||
_bytes = null;
|
||||
_document?.Dispose();
|
||||
_document = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.2.5</PackageVersion>
|
||||
<AssemblyVersion>10.2.5</AssemblyVersion>
|
||||
<FileVersion>10.2.5</FileVersion>
|
||||
<PackageVersion>10.6.1</PackageVersion>
|
||||
<AssemblyVersion>10.6.1</AssemblyVersion>
|
||||
<FileVersion>10.6.1</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -292,122 +292,6 @@ namespace CryptoExchange.Net
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new uri with the provided parameters as query
|
||||
/// </summary>
|
||||
/// <param name="parameters"></param>
|
||||
/// <param name="baseUri"></param>
|
||||
/// <param name="arraySerialization"></param>
|
||||
/// <returns></returns>
|
||||
public static Uri SetParameters(this Uri baseUri, IDictionary<string, object> parameters, ArrayParametersSerialization arraySerialization)
|
||||
{
|
||||
var uriBuilder = new UriBuilder();
|
||||
uriBuilder.Scheme = baseUri.Scheme;
|
||||
uriBuilder.Host = baseUri.Host;
|
||||
uriBuilder.Port = baseUri.Port;
|
||||
uriBuilder.Path = baseUri.AbsolutePath;
|
||||
var httpValueCollection = HttpUtility.ParseQueryString(string.Empty);
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
if (parameter.Value.GetType().IsArray)
|
||||
{
|
||||
if (arraySerialization == ArrayParametersSerialization.JsonArray)
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key, $"[{string.Join(",", (object[])parameter.Value)}]");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var item in (object[])parameter.Value)
|
||||
{
|
||||
if (arraySerialization == ArrayParametersSerialization.Array)
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key + "[]", item.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key, item.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key, parameter.Value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
uriBuilder.Query = httpValueCollection.ToString();
|
||||
return uriBuilder.Uri;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new uri with the provided parameters as query
|
||||
/// </summary>
|
||||
/// <param name="parameters"></param>
|
||||
/// <param name="baseUri"></param>
|
||||
/// <param name="arraySerialization"></param>
|
||||
/// <returns></returns>
|
||||
public static Uri SetParameters(this Uri baseUri, IOrderedEnumerable<KeyValuePair<string, object>> parameters, ArrayParametersSerialization arraySerialization)
|
||||
{
|
||||
var uriBuilder = new UriBuilder();
|
||||
uriBuilder.Scheme = baseUri.Scheme;
|
||||
uriBuilder.Host = baseUri.Host;
|
||||
uriBuilder.Port = baseUri.Port;
|
||||
uriBuilder.Path = baseUri.AbsolutePath;
|
||||
var httpValueCollection = HttpUtility.ParseQueryString(string.Empty);
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
if (parameter.Value.GetType().IsArray)
|
||||
{
|
||||
if (arraySerialization == ArrayParametersSerialization.JsonArray)
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key, $"[{string.Join(",", (object[])parameter.Value)}]");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var item in (object[])parameter.Value)
|
||||
{
|
||||
if (arraySerialization == ArrayParametersSerialization.Array)
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key + "[]", item.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key, item.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
httpValueCollection.Add(parameter.Key, parameter.Value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
uriBuilder.Query = httpValueCollection.ToString();
|
||||
return uriBuilder.Uri;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add parameter to URI
|
||||
/// </summary>
|
||||
/// <param name="uri"></param>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static Uri AddQueryParameter(this Uri uri, string name, string value)
|
||||
{
|
||||
var httpValueCollection = HttpUtility.ParseQueryString(uri.Query);
|
||||
|
||||
httpValueCollection.Remove(name);
|
||||
httpValueCollection.Add(name, value);
|
||||
|
||||
var ub = new UriBuilder(uri);
|
||||
ub.Query = httpValueCollection.ToString();
|
||||
|
||||
return ub.Uri;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompress using GzipStream
|
||||
/// </summary>
|
||||
@@ -419,20 +303,6 @@ namespace CryptoExchange.Net
|
||||
return new ReadOnlySpan<byte>(decompressedStream.GetBuffer(), 0, (int)decompressedStream.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompress using GzipStream
|
||||
/// </summary>
|
||||
public static ReadOnlyMemory<byte> DecompressGzip(this ReadOnlyMemory<byte> data)
|
||||
{
|
||||
using var decompressedStream = new MemoryStream();
|
||||
using var dataStream = MemoryMarshal.TryGetArray(data, out var arraySegment)
|
||||
? new MemoryStream(arraySegment.Array!, arraySegment.Offset, arraySegment.Count)
|
||||
: new MemoryStream(data.ToArray());
|
||||
using var deflateStream = new GZipStream(dataStream, CompressionMode.Decompress);
|
||||
deflateStream.CopyTo(decompressedStream);
|
||||
return new ReadOnlyMemory<byte>(decompressedStream.GetBuffer(), 0, (int)decompressedStream.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompress using GzipStream
|
||||
/// </summary>
|
||||
@@ -445,22 +315,6 @@ namespace CryptoExchange.Net
|
||||
return new ReadOnlySpan<byte>(output.GetBuffer(), 0, (int)output.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompress using DeflateStream
|
||||
/// </summary>
|
||||
/// <param name="input"></param>
|
||||
/// <returns></returns>
|
||||
public static ReadOnlyMemory<byte> Decompress(this ReadOnlyMemory<byte> input)
|
||||
{
|
||||
var output = new MemoryStream();
|
||||
|
||||
using var compressStream = new MemoryStream(input.ToArray());
|
||||
using var decompressor = new DeflateStream(compressStream, CompressionMode.Decompress);
|
||||
decompressor.CopyTo(output);
|
||||
output.Position = 0;
|
||||
return new ReadOnlyMemory<byte>(output.GetBuffer(), 0, (int)output.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the trading mode is linear
|
||||
/// </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>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
using CryptoExchange.Net.Converters.MessageParsing;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Message accessor
|
||||
/// </summary>
|
||||
public interface IMessageAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Is this a valid message
|
||||
/// </summary>
|
||||
bool IsValid { get; }
|
||||
/// <summary>
|
||||
/// Is the original data available for retrieval
|
||||
/// </summary>
|
||||
bool OriginalDataAvailable { get; }
|
||||
/// <summary>
|
||||
/// The underlying data object
|
||||
/// </summary>
|
||||
object? Underlying { get; }
|
||||
/// <summary>
|
||||
/// Clear internal data structure
|
||||
/// </summary>
|
||||
void Clear();
|
||||
/// <summary>
|
||||
/// Get the type of node
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
NodeType? GetNodeType();
|
||||
/// <summary>
|
||||
/// Get the type of node
|
||||
/// </summary>
|
||||
/// <param name="path">Access path</param>
|
||||
/// <returns></returns>
|
||||
NodeType? GetNodeType(MessagePath path);
|
||||
/// <summary>
|
||||
/// Get the value of a path
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
T? GetValue<T>(MessagePath path);
|
||||
/// <summary>
|
||||
/// Get the values of an array
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
T?[]? GetValues<T>(MessagePath path);
|
||||
/// <summary>
|
||||
/// Deserialize the message into this type
|
||||
/// </summary>
|
||||
/// <param name="type"></param>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2092:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2095:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
CallResult<object> Deserialize(Type type, MessagePath? path = null);
|
||||
/// <summary>
|
||||
/// Deserialize the message into this type
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2092:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2095:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
CallResult<T> Deserialize<T>(MessagePath? path = null);
|
||||
|
||||
/// <summary>
|
||||
/// Get the original string value
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
string GetOriginalString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stream message accessor
|
||||
/// </summary>
|
||||
public interface IStreamMessageAccessor : IMessageAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Load a stream message
|
||||
/// </summary>
|
||||
/// <param name="stream"></param>
|
||||
/// <param name="bufferStream"></param>
|
||||
Task<CallResult> Read(Stream stream, bool bufferStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte message accessor
|
||||
/// </summary>
|
||||
public interface IByteMessageAccessor : IMessageAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Load a data message
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
CallResult Read(ReadOnlyMemory<byte> data);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,40 @@
|
||||
DEX
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of platform
|
||||
/// </summary>
|
||||
public enum PlatformType
|
||||
{
|
||||
/// <summary>
|
||||
/// Platform to trade cryptocurrency
|
||||
/// </summary>
|
||||
CryptoCurrencyExchange,
|
||||
/// <summary>
|
||||
/// Platform for trading on predictions
|
||||
/// </summary>
|
||||
PredictionMarket,
|
||||
/// <summary>
|
||||
/// Other
|
||||
/// </summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centralization type
|
||||
/// </summary>
|
||||
public enum CentralizationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized, a person or company is in full control
|
||||
/// </summary>
|
||||
Centralized,
|
||||
/// <summary>
|
||||
/// Decentralized, governance is split over different entities with no single entity in full control
|
||||
/// </summary>
|
||||
Decentralized
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeout behavior for queries
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -76,9 +76,13 @@ namespace CryptoExchange.Net.Objects.Options
|
||||
public int? ReceiveBufferSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not to use the updated deserialization logic, default is true
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public bool UseUpdatedDeserialization { get; set; } = true;
|
||||
public SocketExchangeOptions()
|
||||
{
|
||||
// Enable auto timestamping by default for sockets
|
||||
AutoTimestamp = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a copy of this options
|
||||
@@ -88,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;
|
||||
@@ -101,7 +106,6 @@ namespace CryptoExchange.Net.Objects.Options
|
||||
item.RateLimitingBehaviour = RateLimitingBehaviour;
|
||||
item.RateLimiterEnabled = RateLimiterEnabled;
|
||||
item.ReceiveBufferSize = ReceiveBufferSize;
|
||||
item.UseUpdatedDeserialization = UseUpdatedDeserialization;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace CryptoExchange.Net.Objects
|
||||
{
|
||||
/// <summary>
|
||||
/// Information on the platform
|
||||
/// </summary>
|
||||
public record PlatformInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Platform id
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
/// <summary>
|
||||
/// Display name
|
||||
/// </summary>
|
||||
public string DisplayName { get; }
|
||||
/// <summary>
|
||||
/// Logo
|
||||
/// </summary>
|
||||
public string Logo { get; }
|
||||
/// <summary>
|
||||
/// Url to main application
|
||||
/// </summary>
|
||||
public string Url { get; }
|
||||
/// <summary>
|
||||
/// Urls to the API documentation
|
||||
/// </summary>
|
||||
public string[] ApiDocsUrl { get; }
|
||||
/// <summary>
|
||||
/// Platform type
|
||||
/// </summary>
|
||||
public PlatformType PlatformType { get; }
|
||||
/// <summary>
|
||||
/// Centralization type
|
||||
/// </summary>
|
||||
public CentralizationType CentralizationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public PlatformInfo(string id, string displayName, string logo, string url, string[] apiDocsUrl, PlatformType platformType, CentralizationType centralizationType)
|
||||
{
|
||||
Id = id;
|
||||
DisplayName = displayName;
|
||||
Logo = logo;
|
||||
Url = url;
|
||||
ApiDocsUrl = apiDocsUrl;
|
||||
PlatformType = platformType;
|
||||
CentralizationType = centralizationType;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -73,11 +73,6 @@ namespace CryptoExchange.Net.Objects.Sockets
|
||||
/// The buffer size to use for receiving data
|
||||
/// </summary>
|
||||
public int? ReceiveBufferSize { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not to use the updated deserialization logic
|
||||
/// </summary>
|
||||
public bool UseUpdatedDeserialization { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
|
||||
@@ -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,16 +87,14 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Func<Task>? OnClose;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task>? OnStreamMessage;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Func<int, Task>? OnRequestSent;
|
||||
|
||||
@@ -139,10 +136,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
_sendEvent = new AsyncResetEvent();
|
||||
_sendBuffer = new ConcurrentQueue<SendItem>();
|
||||
_ctsSource = new CancellationTokenSource();
|
||||
if (websocketParameters.UseUpdatedDeserialization)
|
||||
_receiveBufferSize = websocketParameters.ReceiveBufferSize ?? 65536;
|
||||
else
|
||||
_receiveBufferSize = websocketParameters.ReceiveBufferSize ?? _defaultReceiveBufferSize;
|
||||
_receiveBufferSize = websocketParameters.ReceiveBufferSize ?? 65536;
|
||||
|
||||
_closeSem = new SemaphoreSlim(1, 1);
|
||||
_socket = CreateSocket();
|
||||
@@ -225,7 +219,9 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
catch (Exception e)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.SocketConnectingCanceled(Id);
|
||||
}
|
||||
else if (!_ctsSource.IsCancellationRequested)
|
||||
{
|
||||
// if _ctsSource was canceled this was already logged
|
||||
@@ -271,11 +267,10 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
var sendTask = SendLoopAsync();
|
||||
Task receiveTask;
|
||||
#if !NETSTANDARD2_0
|
||||
if (Parameters.UseUpdatedDeserialization)
|
||||
receiveTask = ReceiveLoopNewAsync();
|
||||
else
|
||||
receiveTask = ReceiveLoopNewAsync();
|
||||
#else
|
||||
receiveTask = ReceiveLoopAsync();
|
||||
#endif
|
||||
receiveTask = ReceiveLoopAsync();
|
||||
var timeoutTask = Parameters.Timeout != null && Parameters.Timeout > TimeSpan.FromSeconds(0) ? CheckTimeoutAsync() : Task.CompletedTask;
|
||||
await Task.WhenAll(sendTask, receiveTask, timeoutTask).ConfigureAwait(false);
|
||||
_logger.SocketFinishedProcessing(Id);
|
||||
@@ -492,7 +487,6 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
_disposed = true;
|
||||
_socket.Dispose();
|
||||
_ctsSource?.Dispose();
|
||||
_sendEvent.Dispose();
|
||||
_logger.SocketDisposed(Id);
|
||||
}
|
||||
|
||||
@@ -578,6 +572,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
}
|
||||
}
|
||||
|
||||
#if NETSTANDARD2_0
|
||||
/// <summary>
|
||||
/// Loop for receiving and reassembling data
|
||||
/// </summary>
|
||||
@@ -627,6 +622,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
break;
|
||||
}
|
||||
|
||||
LastReceiveTime = DateTime.UtcNow;
|
||||
if (receiveResult.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
// Connection closed
|
||||
@@ -666,10 +662,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
_logger.SocketReceivedSingleMessage(Id, receiveResult.Count);
|
||||
|
||||
if (!Parameters.UseUpdatedDeserialization)
|
||||
await ProcessData(receiveResult.MessageType, new ReadOnlyMemory<byte>(buffer.Array!, buffer.Offset, receiveResult.Count)).ConfigureAwait(false);
|
||||
else
|
||||
ProcessDataNew(receiveResult.MessageType, new ReadOnlySpan<byte>(buffer.Array!, buffer.Offset, receiveResult.Count));
|
||||
ProcessDataNew(receiveResult.MessageType, new ReadOnlySpan<byte>(buffer.Array!, buffer.Offset, receiveResult.Count));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -703,11 +696,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
_logger.SocketReassembledMessage(Id, multipartStream!.Length);
|
||||
|
||||
// Get the underlying buffer of the memory stream holding the written data and delimit it (GetBuffer return the full array, not only the written part)
|
||||
|
||||
if (!Parameters.UseUpdatedDeserialization)
|
||||
await ProcessData(receiveResult.MessageType, new ReadOnlyMemory<byte>(multipartStream!.GetBuffer(), 0, (int)multipartStream.Length)).ConfigureAwait(false);
|
||||
else
|
||||
ProcessDataNew(receiveResult.MessageType, new ReadOnlySpan<byte>(multipartStream!.GetBuffer(), 0, (int)multipartStream.Length));
|
||||
ProcessDataNew(receiveResult.MessageType, new ReadOnlySpan<byte>(multipartStream!.GetBuffer(), 0, (int)multipartStream.Length));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -732,6 +721,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
_logger.SocketReceiveLoopFinished(Id);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !NETSTANDARD2_0
|
||||
/// <summary>
|
||||
@@ -783,6 +773,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
break;
|
||||
}
|
||||
|
||||
LastReceiveTime = DateTime.UtcNow;
|
||||
if (receiveResult.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
// Connection closed
|
||||
@@ -891,22 +882,9 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// <returns></returns>
|
||||
protected void ProcessDataNew(WebSocketMessageType type, ReadOnlySpan<byte> data)
|
||||
{
|
||||
LastActionTime = DateTime.UtcNow;
|
||||
_connection.HandleStreamMessage2(type, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a stream message
|
||||
/// </summary>
|
||||
/// <param name="type"></param>
|
||||
/// <param name="data"></param>
|
||||
/// <returns></returns>
|
||||
protected async Task ProcessData(WebSocketMessageType type, ReadOnlyMemory<byte> data)
|
||||
{
|
||||
LastActionTime = DateTime.UtcNow;
|
||||
await (OnStreamMessage?.Invoke(type, data) ?? Task.CompletedTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there is no data received for a period longer than the specified timeout
|
||||
/// </summary>
|
||||
@@ -914,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)
|
||||
@@ -922,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);
|
||||
|
||||
@@ -16,10 +16,6 @@ namespace CryptoExchange.Net.Sockets.Default.Interfaces
|
||||
/// </summary>
|
||||
event Func<Task> OnClose;
|
||||
/// <summary>
|
||||
/// Websocket message received event
|
||||
/// </summary>
|
||||
event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task> OnStreamMessage;
|
||||
/// <summary>
|
||||
/// Websocket sent event, RequestId as parameter
|
||||
/// </summary>
|
||||
event Func<int, Task> OnRequestSent;
|
||||
@@ -73,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>
|
||||
|
||||
@@ -111,11 +111,6 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// </summary>
|
||||
public event Action? ActivityUnpaused;
|
||||
|
||||
/// <summary>
|
||||
/// Unhandled message event
|
||||
/// </summary>
|
||||
public event Action<IMessageAccessor>? UnhandledMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Connection was rate limited and couldn't be established
|
||||
/// </summary>
|
||||
@@ -183,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>
|
||||
@@ -269,8 +269,6 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
private SocketStatus _status;
|
||||
|
||||
private readonly IMessageSerializer _serializer;
|
||||
private IByteMessageAccessor? _stringMessageAccessor;
|
||||
private IByteMessageAccessor? _byteMessageAccessor;
|
||||
|
||||
private ISocketMessageHandler? _byteMessageConverter;
|
||||
private ISocketMessageHandler? _textMessageConverter;
|
||||
@@ -291,11 +289,6 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// The underlying websocket
|
||||
/// </summary>
|
||||
private readonly IWebsocket _socket;
|
||||
|
||||
/// <summary>
|
||||
/// Cache for deserialization, only caches for a single message
|
||||
/// </summary>
|
||||
private readonly Dictionary<Type, object> _deserializationCache = new Dictionary<Type, object>();
|
||||
|
||||
/// <summary>
|
||||
/// New socket connection
|
||||
@@ -310,7 +303,6 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
_socket = socketFactory.CreateWebsocket(logger, this, parameters);
|
||||
_logger.SocketCreatedForAddress(_socket.Id, parameters.Uri.ToString());
|
||||
|
||||
_socket.OnStreamMessage += HandleStreamMessage;
|
||||
_socket.OnRequestSent += HandleRequestSentAsync;
|
||||
_socket.OnRequestRateLimited += HandleRequestRateLimitedAsync;
|
||||
_socket.OnConnectRateLimited += HandleConnectRateLimitedAsync;
|
||||
@@ -641,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)
|
||||
{
|
||||
if (isQuery && query!.Completed)
|
||||
if (route.TopicFilter != null)
|
||||
// No topic on message, but route is filtering on topic
|
||||
continue;
|
||||
|
||||
processed = true;
|
||||
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)
|
||||
@@ -666,146 +673,17 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
|
||||
if (!processed)
|
||||
{
|
||||
_logger.ReceivedMessageNotMatchedToAnyListener(SocketId, topicFilter!,
|
||||
string.Join(",", _listeners.Select(x => string.Join(",", x.MessageRouter.Routes.Select(x => x.TopicFilter != null ? string.Join(",", x.TopicFilter) : "[null]")))));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a message
|
||||
/// </summary>
|
||||
protected virtual async Task HandleStreamMessage(WebSocketMessageType type, ReadOnlyMemory<byte> data)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var receiveTime = DateTime.UtcNow;
|
||||
string? originalData = null;
|
||||
|
||||
// 1. Decrypt/Preprocess if necessary
|
||||
data = ApiClient.PreprocessStreamMessage(this, type, data);
|
||||
|
||||
// 2. Read data into accessor
|
||||
IByteMessageAccessor accessor;
|
||||
if (type == WebSocketMessageType.Binary)
|
||||
accessor = _stringMessageAccessor ??= ApiClient.CreateAccessor(type);
|
||||
else
|
||||
accessor = _byteMessageAccessor ??= ApiClient.CreateAccessor(type);
|
||||
|
||||
var result = accessor.Read(data);
|
||||
try
|
||||
{
|
||||
bool outputOriginalData = ApiClient.ApiOptions.OutputOriginalData ?? ApiClient.ClientOptions.OutputOriginalData;
|
||||
if (outputOriginalData)
|
||||
if (!ApiClient.HandleUnhandledMessage(this, typeIdentifier, data))
|
||||
{
|
||||
originalData = accessor.GetOriginalString();
|
||||
_logger.ReceivedData(SocketId, originalData);
|
||||
}
|
||||
|
||||
if (!accessor.IsValid && !ApiClient.ProcessUnparsableMessages)
|
||||
{
|
||||
_logger.FailedToParse(SocketId, result.Error!.Message ?? result.Error!.ErrorDescription!);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Determine the identifying properties of this message
|
||||
var listenId = ApiClient.GetListenerIdentifier(accessor);
|
||||
if (listenId == null)
|
||||
{
|
||||
originalData ??= "[OutputOriginalData is false]";
|
||||
if (!ApiClient.UnhandledMessageExpected)
|
||||
_logger.FailedToEvaluateMessage(SocketId, originalData);
|
||||
|
||||
UnhandledMessage?.Invoke(accessor);
|
||||
return;
|
||||
}
|
||||
|
||||
bool processed = false;
|
||||
var totalUserTime = 0;
|
||||
|
||||
List<IMessageProcessor> localListeners;
|
||||
lock (_listenersLock)
|
||||
localListeners = _listeners.ToList();
|
||||
|
||||
foreach (var processor in localListeners)
|
||||
{
|
||||
foreach (var listener in processor.MessageMatcher.GetHandlerLinks(listenId))
|
||||
lock (_listenersLock)
|
||||
{
|
||||
processed = true;
|
||||
_logger.ProcessorMatched(SocketId, listener.ToString(), listenId);
|
||||
|
||||
// 4. Determine the type to deserialize to for this processor
|
||||
var messageType = listener.DeserializationType;
|
||||
if (messageType == null)
|
||||
{
|
||||
_logger.ReceivedMessageNotRecognized(SocketId, processor.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processor is Subscription subscriptionProcessor && subscriptionProcessor.Status == SubscriptionStatus.Subscribing)
|
||||
{
|
||||
// If this message is for this listener then it is automatically confirmed, even if the subscription is not (yet) confirmed
|
||||
subscriptionProcessor.Status = SubscriptionStatus.Subscribed;
|
||||
if (subscriptionProcessor.SubscriptionQuery?.TimeoutBehavior == TimeoutBehavior.Succeed)
|
||||
// If this subscription has a query waiting for a timeout (success if there is no error response)
|
||||
// then time it out now as the data is being received, so we assume it's successful
|
||||
subscriptionProcessor.SubscriptionQuery.Timeout();
|
||||
}
|
||||
|
||||
// 5. Deserialize the message
|
||||
_deserializationCache.TryGetValue(messageType, out var deserialized);
|
||||
|
||||
if (deserialized == null)
|
||||
{
|
||||
var desResult = processor.Deserialize(accessor, messageType);
|
||||
if (!desResult)
|
||||
{
|
||||
_logger.FailedToDeserializeMessage(SocketId, desResult.Error?.ToString(), desResult.Error?.Exception);
|
||||
continue;
|
||||
}
|
||||
|
||||
deserialized = desResult.Data;
|
||||
_deserializationCache.Add(messageType, deserialized);
|
||||
}
|
||||
|
||||
// 6. Pass the message to the handler
|
||||
try
|
||||
{
|
||||
var innerSw = Stopwatch.StartNew();
|
||||
processor.Handle(this, receiveTime, originalData, deserialized, listener);
|
||||
if (processor is Query query && query.RequiredResponses != 1)
|
||||
_logger.LogDebug($"[Sckt {SocketId}] [Req {query.Id}] responses: {query.CurrentResponses}/{query.RequiredResponses}");
|
||||
totalUserTime += (int)innerSw.ElapsedMilliseconds;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.UserMessageProcessingFailed(SocketId, ex.Message, ex);
|
||||
if (processor is Subscription subscription)
|
||||
subscription.InvokeExceptionHandler(ex);
|
||||
}
|
||||
|
||||
_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]")))));
|
||||
}
|
||||
}
|
||||
|
||||
if (!processed)
|
||||
{
|
||||
if (!ApiClient.UnhandledMessageExpected)
|
||||
{
|
||||
List<string> listenerIds;
|
||||
lock (_listenersLock)
|
||||
listenerIds = _listeners.Select(l => l.MessageMatcher.ToString()).ToList();
|
||||
|
||||
_logger.ReceivedMessageNotMatchedToAnyListener(SocketId, listenId, string.Join(",", listenerIds));
|
||||
UnhandledMessage?.Invoke(accessor);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.MessageProcessed(SocketId, sw.ElapsedMilliseconds, sw.ElapsedMilliseconds - totalUserTime);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_deserializationCache.Clear();
|
||||
accessor.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,16 +764,8 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
subscription.CancellationTokenRegistration.Value.Dispose();
|
||||
|
||||
bool anyDuplicateSubscription;
|
||||
if (ApiClient.ClientOptions.UseUpdatedDeserialization)
|
||||
{
|
||||
lock (_listenersLock)
|
||||
anyDuplicateSubscription = _listeners.OfType<Subscription>().Any(x => x != subscription && x.MessageRouter.Routes.All(l => subscription.MessageRouter.ContainsCheck(l)));
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_listenersLock)
|
||||
anyDuplicateSubscription = _listeners.OfType<Subscription>().Any(x => x != subscription && x.MessageMatcher.HandlerLinks.All(l => subscription.MessageMatcher.ContainsCheck(l)));
|
||||
}
|
||||
lock (_listenersLock)
|
||||
anyDuplicateSubscription = _listeners.OfType<Subscription>().Any(x => x != subscription && x.MessageRouter.Routes.All(l => subscription.MessageRouter.ContainsCheck(l)));
|
||||
|
||||
bool shouldCloseConnection;
|
||||
lock (_listenersLock)
|
||||
@@ -943,16 +813,9 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
{
|
||||
Status = SocketStatus.Disposed;
|
||||
periodicEvent?.Set();
|
||||
periodicEvent?.Dispose();
|
||||
_socket.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not a new subscription can be added to this connection
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool CanAddSubscription() => Status == SocketStatus.None || Status == SocketStatus.Connected;
|
||||
|
||||
/// <summary>
|
||||
/// Add a subscription to this connection
|
||||
/// </summary>
|
||||
@@ -1088,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");
|
||||
@@ -1222,33 +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));
|
||||
var subscribeTask = TrySubscribeAsync(subscription, false, default);
|
||||
taskList.Add(subscribeTask);
|
||||
}
|
||||
|
||||
await Task.WhenAll(taskList).ConfigureAwait(false);
|
||||
@@ -1265,6 +1103,67 @@ 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;
|
||||
}
|
||||
|
||||
subQuery.OnComplete = () =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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!);
|
||||
}
|
||||
|
||||
return subQueryResult;
|
||||
}
|
||||
|
||||
internal async Task UnsubscribeAsync(Subscription subscription)
|
||||
{
|
||||
var unsubscribeRequest = subscription.CreateUnsubscriptionQuery(this);
|
||||
@@ -1272,6 +1171,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
return;
|
||||
|
||||
await SendAndWaitQueryAsync(unsubscribeRequest).ConfigureAwait(false);
|
||||
subscription.HandleUnsubQueryResponse(this, unsubscribeRequest.Response);
|
||||
_logger.SubscriptionUnsubscribed(SocketId, subscription.Id);
|
||||
}
|
||||
|
||||
@@ -1285,7 +1185,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
return CallResult.SuccessResult;
|
||||
|
||||
var result = await SendAndWaitQueryAsync(subQuery).ConfigureAwait(false);
|
||||
subscription.HandleSubQueryResponse(this, subQuery.Response!);
|
||||
subscription.HandleSubQueryResponse(this, subQuery.Response);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
return;
|
||||
|
||||
_status = value;
|
||||
Task.Run(() => StatusChanged?.Invoke(value));
|
||||
StatusChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,6 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// </summary>
|
||||
public bool Authenticated { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Matcher for this subscription
|
||||
/// </summary>
|
||||
public MessageMatcher MessageMatcher { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Router for this subscription
|
||||
/// </summary>
|
||||
@@ -154,7 +149,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// <summary>
|
||||
/// Handle an unsubscription query response
|
||||
/// </summary>
|
||||
public virtual void HandleUnsubQueryResponse(SocketConnection connection, object message) { }
|
||||
public virtual void HandleUnsubQueryResponse(SocketConnection connection, object? message) { }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new unsubscription query
|
||||
@@ -172,19 +167,6 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// <returns></returns>
|
||||
protected abstract Query? GetUnsubQuery(SocketConnection connection);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual CallResult<object> Deserialize(IMessageAccessor message, Type type) => message.Deserialize(type);
|
||||
|
||||
/// <summary>
|
||||
/// Handle an update message
|
||||
/// </summary>
|
||||
public CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data, MessageHandlerLink matcher)
|
||||
{
|
||||
ConnectionInvocations++;
|
||||
TotalInvocations++;
|
||||
return matcher.Handle(connection, receiveTime, originalData, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle an update message
|
||||
/// </summary>
|
||||
@@ -192,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);
|
||||
}
|
||||
|
||||
@@ -224,12 +214,12 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// <param name="Id">The id of the subscription</param>
|
||||
/// <param name="Status">Subscription status</param>
|
||||
/// <param name="Invocations">Number of times this subscription got a message</param>
|
||||
/// <param name="ListenMatcher">Matcher for this subscription</param>
|
||||
/// <param name="MessageRouter">Router for this subscription</param>
|
||||
public record SubscriptionState(
|
||||
int Id,
|
||||
SubscriptionStatus Status,
|
||||
int Invocations,
|
||||
MessageMatcher ListenMatcher
|
||||
MessageRouter MessageRouter
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@@ -238,7 +228,7 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
/// <returns></returns>
|
||||
public SubscriptionState GetState()
|
||||
{
|
||||
return new SubscriptionState(Id, Status, TotalInvocations, MessageMatcher);
|
||||
return new SubscriptionState(Id, Status, TotalInvocations, MessageRouter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,6 @@ namespace CryptoExchange.Net.Sockets.HighPerf
|
||||
{
|
||||
Status = SocketStatus.Disposed;
|
||||
periodicEvent?.Set();
|
||||
periodicEvent?.Dispose();
|
||||
_socket.Dispose();
|
||||
}
|
||||
|
||||
@@ -245,7 +244,9 @@ namespace CryptoExchange.Net.Sockets.HighPerf
|
||||
public virtual ValueTask<CallResult> SendAsync<T>(T obj)
|
||||
{
|
||||
if (_serializer is IByteMessageSerializer byteSerializer)
|
||||
{
|
||||
return SendBytesAsync(byteSerializer.Serialize(obj));
|
||||
}
|
||||
else if (_serializer is IStringMessageSerializer stringSerializer)
|
||||
{
|
||||
if (obj is string str)
|
||||
|
||||
@@ -15,27 +15,12 @@ namespace CryptoExchange.Net.Sockets.Interfaces
|
||||
/// </summary>
|
||||
public int Id { get; }
|
||||
/// <summary>
|
||||
/// The matcher for this listener
|
||||
/// </summary>
|
||||
public MessageMatcher MessageMatcher { get; }
|
||||
/// <summary>
|
||||
/// The message router for this processor
|
||||
/// </summary>
|
||||
public MessageRouter MessageRouter { get; }
|
||||
/// <summary>
|
||||
/// Handle a message
|
||||
/// </summary>
|
||||
CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object result, MessageHandlerLink matchedHandler);
|
||||
/// <summary>
|
||||
/// Handle a message
|
||||
/// </summary>
|
||||
CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object result, MessageRoute route);
|
||||
/// <summary>
|
||||
/// Deserialize a message into object of type
|
||||
/// </summary>
|
||||
/// <param name="accessor"></param>
|
||||
/// <param name="type"></param>
|
||||
/// <returns></returns>
|
||||
CallResult<object> Deserialize(IMessageAccessor accessor, Type type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.Sockets
|
||||
{
|
||||
/// <summary>
|
||||
/// Message link type
|
||||
/// </summary>
|
||||
public enum MessageLinkType
|
||||
{
|
||||
/// <summary>
|
||||
/// Match when the listen id matches fully to the value
|
||||
/// </summary>
|
||||
Full,
|
||||
/// <summary>
|
||||
/// Match when the listen id starts with the value
|
||||
/// </summary>
|
||||
StartsWith
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a message listen id to a specific listener
|
||||
/// </summary>
|
||||
public class MessageMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Linkers in this matcher
|
||||
/// </summary>
|
||||
public MessageHandlerLink[] HandlerLinks { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
private MessageMatcher(params MessageHandlerLink[] links)
|
||||
{
|
||||
HandlerLinks = links;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create message matcher
|
||||
/// </summary>
|
||||
public static MessageMatcher Create(string value)
|
||||
{
|
||||
return new MessageMatcher(new MessageHandlerLink<string>(MessageLinkType.Full, value, (con, receiveTime, originalData, msg) => new CallResult<string>(default, null, null)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create message matcher
|
||||
/// </summary>
|
||||
public static MessageMatcher Create<T>(string value)
|
||||
{
|
||||
return new MessageMatcher(new MessageHandlerLink<T>(MessageLinkType.Full, value, (con, receiveTime, originalData, msg) => new CallResult<T>(default, null, null)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create message matcher
|
||||
/// </summary>
|
||||
public static MessageMatcher Create<T>(string value, Func<SocketConnection, DateTime, string?, T, CallResult> handler)
|
||||
{
|
||||
return new MessageMatcher(new MessageHandlerLink<T>(MessageLinkType.Full, value, handler));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create message matcher
|
||||
/// </summary>
|
||||
public static MessageMatcher Create<T>(IEnumerable<string> values, Func<SocketConnection, DateTime, string?, T, CallResult> handler)
|
||||
{
|
||||
return new MessageMatcher(values.Select(x => new MessageHandlerLink<T>(MessageLinkType.Full, x, handler)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create message matcher
|
||||
/// </summary>
|
||||
public static MessageMatcher Create<T>(MessageLinkType type, string value, Func<SocketConnection, DateTime, string?, T, CallResult> handler)
|
||||
{
|
||||
return new MessageMatcher(new MessageHandlerLink<T>(type, value, handler));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create message matcher
|
||||
/// </summary>
|
||||
public static MessageMatcher Create(params MessageHandlerLink[] linkers)
|
||||
{
|
||||
return new MessageMatcher(linkers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this matcher contains a specific link
|
||||
/// </summary>
|
||||
public bool ContainsCheck(MessageHandlerLink link) => HandlerLinks.Any(x => x.Type == link.Type && x.Value == link.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Get any handler links matching with the listen id
|
||||
/// </summary>
|
||||
public IEnumerable<MessageHandlerLink> GetHandlerLinks(string listenId) => HandlerLinks.Where(x => x.Check(listenId));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => string.Join(",", HandlerLinks.Select(x => x.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message handler link
|
||||
/// </summary>
|
||||
public abstract class MessageHandlerLink
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of check
|
||||
/// </summary>
|
||||
public MessageLinkType Type { get; }
|
||||
/// <summary>
|
||||
/// String value of the check
|
||||
/// </summary>
|
||||
public string Value { get; }
|
||||
/// <summary>
|
||||
/// Deserialization type
|
||||
/// </summary>
|
||||
public abstract Type DeserializationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public MessageHandlerLink(MessageLinkType type, string value)
|
||||
{
|
||||
Type = type;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this listen id matches this link
|
||||
/// </summary>
|
||||
public bool Check(string listenId)
|
||||
{
|
||||
if (Type == MessageLinkType.Full)
|
||||
return Value.Equals(listenId, StringComparison.Ordinal);
|
||||
|
||||
return listenId.StartsWith(Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message handler
|
||||
/// </summary>
|
||||
public abstract CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"{Type} match for \"{Value}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message handler link
|
||||
/// </summary>
|
||||
public class MessageHandlerLink<TServer>: MessageHandlerLink
|
||||
{
|
||||
private Func<SocketConnection, DateTime, string?, TServer, CallResult> _handler;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Type DeserializationType => typeof(TServer);
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public MessageHandlerLink(string value, Func<SocketConnection, DateTime, string?, TServer, CallResult> handler)
|
||||
: this(MessageLinkType.Full, value, handler)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public MessageHandlerLink(MessageLinkType type, string value, Func<SocketConnection, DateTime, string?, TServer, CallResult> handler)
|
||||
: base(type, value)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public override CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data)
|
||||
{
|
||||
return _handler(connection, receiveTime, originalData, (TServer)data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,11 +59,6 @@ namespace CryptoExchange.Net.Sockets
|
||||
/// </summary>
|
||||
public object? Response { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Matcher for this query
|
||||
/// </summary>
|
||||
public MessageMatcher MessageMatcher { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Router for this query
|
||||
/// </summary>
|
||||
@@ -132,8 +127,8 @@ namespace CryptoExchange.Net.Sockets
|
||||
}
|
||||
else
|
||||
{
|
||||
Completed = true;
|
||||
Result = CallResult.SuccessResult;
|
||||
Completed = true;
|
||||
_event.Set();
|
||||
}
|
||||
}
|
||||
@@ -146,9 +141,6 @@ namespace CryptoExchange.Net.Sockets
|
||||
/// <returns></returns>
|
||||
public async Task WaitAsync(TimeSpan timeout, CancellationToken ct) => await _event.WaitAsync(timeout, ct).ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual CallResult<object> Deserialize(IMessageAccessor message, Type type) => message.Deserialize(type);
|
||||
|
||||
/// <summary>
|
||||
/// Mark request as timeout
|
||||
/// </summary>
|
||||
@@ -160,11 +152,6 @@ namespace CryptoExchange.Net.Sockets
|
||||
/// <param name="error"></param>
|
||||
public abstract void Fail(Error error);
|
||||
|
||||
/// <summary>
|
||||
/// Handle a response message
|
||||
/// </summary>
|
||||
public abstract CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object message, MessageHandlerLink check);
|
||||
|
||||
/// <summary>
|
||||
/// Handle a response message
|
||||
/// </summary>
|
||||
@@ -223,47 +210,18 @@ namespace CryptoExchange.Net.Sockets
|
||||
return Result ?? CallResult.SuccessResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object message, MessageHandlerLink check)
|
||||
{
|
||||
if (!PreCheckMessage(connection, message))
|
||||
return CallResult.SuccessResult;
|
||||
|
||||
CurrentResponses++;
|
||||
if (CurrentResponses == RequiredResponses)
|
||||
Response = message;
|
||||
|
||||
if (Result?.Success != false)
|
||||
// If an error result is already set don't override that
|
||||
Result = check.Handle(connection, receiveTime, originalData, message);
|
||||
|
||||
if (CurrentResponses == RequiredResponses)
|
||||
{
|
||||
Completed = true;
|
||||
_event.Set();
|
||||
OnComplete?.Invoke();
|
||||
}
|
||||
|
||||
return Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate if a message is actually processable by this query
|
||||
/// </summary>
|
||||
public virtual bool PreCheckMessage(SocketConnection connection, object message) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Timeout()
|
||||
{
|
||||
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();
|
||||
}
|
||||
@@ -276,6 +234,7 @@ namespace CryptoExchange.Net.Sockets
|
||||
|
||||
Result = new CallResult<THandlerResponse>(error);
|
||||
Completed = true;
|
||||
|
||||
_event.Set();
|
||||
OnComplete?.Invoke();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ using CryptoExchange.Net.Sockets.Default.Interfaces;
|
||||
|
||||
namespace CryptoExchange.Net.Testing.Implementations
|
||||
{
|
||||
internal class TestSocket : IWebsocket
|
||||
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
|
||||
public class TestSocket : IWebsocket
|
||||
{
|
||||
public event Action<string>? OnMessageSend;
|
||||
|
||||
@@ -28,7 +29,6 @@ namespace CryptoExchange.Net.Testing.Implementations
|
||||
public event Func<Exception, Task>? OnError;
|
||||
#pragma warning restore 0067
|
||||
public event Func<int, Task>? OnRequestSent;
|
||||
public event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task>? OnStreamMessage;
|
||||
public event Func<Task>? OnOpen;
|
||||
|
||||
public int Id { get; }
|
||||
@@ -39,20 +39,17 @@ 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
|
||||
public static readonly object lastIdLock = new object();
|
||||
#endif
|
||||
|
||||
private bool _newDeserialization;
|
||||
|
||||
public SocketConnection? Connection { get; set; }
|
||||
|
||||
public TestSocket(bool newDeserialization, string address)
|
||||
public TestSocket(string address)
|
||||
{
|
||||
_newDeserialization = newDeserialization;
|
||||
|
||||
Uri = new Uri(address);
|
||||
lock (lastIdLock)
|
||||
{
|
||||
@@ -107,20 +104,23 @@ namespace CryptoExchange.Net.Testing.Implementations
|
||||
|
||||
public void InvokeMessage(string data)
|
||||
{
|
||||
if (!_newDeserialization)
|
||||
{
|
||||
OnStreamMessage?.Invoke(WebSocketMessageType.Text, new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes(data))).Wait();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Connection == null)
|
||||
throw new ArgumentNullException(nameof(Connection));
|
||||
if (Connection == null)
|
||||
throw new ArgumentNullException(nameof(Connection));
|
||||
|
||||
Connection.HandleStreamMessage2(WebSocketMessageType.Text, Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
Connection.HandleStreamMessage2(WebSocketMessageType.Text, Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
public async Task ReconnectAsync()
|
||||
{
|
||||
if (OnReconnecting != null)
|
||||
await OnReconnecting().ConfigureAwait(false);
|
||||
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
|
||||
if (OnReconnected != null)
|
||||
await OnReconnected().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task ReconnectAsync() => Task.CompletedTask;
|
||||
public void Dispose() { }
|
||||
|
||||
public void UpdateProxy(ApiProxy? proxy) => throw new NotImplementedException();
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace CryptoExchange.Net.Testing
|
||||
/// <summary>
|
||||
/// Get a client instance
|
||||
/// </summary>
|
||||
public abstract TClient GetClient(ILoggerFactory loggerFactory, bool newDeserialization);
|
||||
public abstract TClient GetClient(ILoggerFactory loggerFactory);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the test should be run. By default integration tests aren't executed, can be set to true to force execution.
|
||||
@@ -34,11 +34,11 @@ namespace CryptoExchange.Net.Testing
|
||||
/// Create a client
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected TClient CreateClient(bool useNewDeserialization)
|
||||
protected TClient CreateClient()
|
||||
{
|
||||
var fact = new LoggerFactory();
|
||||
fact.AddProvider(new TraceLoggerProvider());
|
||||
return GetClient(fact, useNewDeserialization);
|
||||
return GetClient(fact);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -58,16 +58,15 @@ namespace CryptoExchange.Net.Testing
|
||||
/// Execute a REST endpoint call and check for any errors or warnings.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the update</typeparam>
|
||||
/// <param name="useNewDeserialization">Whether to use the new deserialization method</param>
|
||||
/// <param name="expression">The call expression</param>
|
||||
/// <param name="expectUpdate">Whether an update is expected</param>
|
||||
/// <param name="authRequest">Whether this is an authenticated request</param>
|
||||
public async Task RunAndCheckUpdate<T>(bool useNewDeserialization, Expression<Func<TClient, Action<DataEvent<T>>, Task<CallResult<UpdateSubscription>>>> expression, bool expectUpdate, bool authRequest)
|
||||
public async Task RunAndCheckUpdate<T>(Expression<Func<TClient, Action<DataEvent<T>>, Task<CallResult<UpdateSubscription>>>> expression, bool expectUpdate, bool authRequest)
|
||||
{
|
||||
if (!ShouldRun())
|
||||
return;
|
||||
|
||||
var client = CreateClient(useNewDeserialization);
|
||||
var client = CreateClient();
|
||||
|
||||
var expressionBody = (MethodCallExpression)expression.Body;
|
||||
if (authRequest && !Authenticated)
|
||||
|
||||
@@ -27,8 +27,11 @@ namespace CryptoExchange.Net.Testing
|
||||
/// </summary>
|
||||
public class TestHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Deep compare the values of two objects
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static bool AreEqual<T>(T? self, T? to, params string[] ignore) where T : class
|
||||
public static bool AreEqual<T>(T? self, T? to, params string[] ignore) where T : class
|
||||
{
|
||||
if (self != null && to != null)
|
||||
{
|
||||
@@ -61,9 +64,12 @@ namespace CryptoExchange.Net.Testing
|
||||
return self == to;
|
||||
}
|
||||
|
||||
internal static TestSocket ConfigureSocketClient<T>(T client, string address) where T : BaseSocketClient
|
||||
/// <summary>
|
||||
/// Configure a socket client
|
||||
/// </summary>
|
||||
public static TestSocket ConfigureSocketClient<T>(T client, string address) where T : BaseSocketClient
|
||||
{
|
||||
var socket = new TestSocket(client.ClientOptions.UseUpdatedDeserialization, address);
|
||||
var socket = new TestSocket(address);
|
||||
foreach (var apiClient in client.ApiClients.OfType<SocketApiClient>())
|
||||
{
|
||||
apiClient.SocketFactory = new TestWebsocketFactory(socket);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@ For more information on what CryptoExchange.Net and it's client libraries offers
|
||||
### CryptoExchange.Net Ecosystem
|
||||
Full list of all libraries part of the CryptoExchange.Net ecosystem. Consider using a referral link to support development, as well as potentially get some trading fee discount!
|
||||
|
||||
||Exchange|Type|Repository|Nuget|Referral Link|Referral Fee Discount|
|
||||
||API|Type|Repository|Nuget|Referral Link|Referral Fee Discount|
|
||||
|--|--|--|--|--|--|--|
|
||||
||Aster|DEX|[JKorf/Aster.Net](https://github.com/JKorf/Aster.Net)|[](https://www.nuget.org/packages/JKorf.Aster.Net)|[Link](https://www.asterdex.com/en/referral/FD2E11)|4%|
|
||||
||Binance|CEX|[JKorf/Binance.Net](https://github.com/JKorf/Binance.Net)|[](https://www.nuget.org/packages/Binance.Net)|[Link](https://accounts.binance.com/register?ref=X5K3F2ZG)|20%|
|
||||
@@ -34,6 +34,7 @@ Full list of all libraries part of the CryptoExchange.Net ecosystem. Consider us
|
||||
||Kucoin|CEX|[JKorf/Kucoin.Net](https://github.com/JKorf/Kucoin.Net)|[](https://www.nuget.org/packages/Kucoin.Net)|[Link](https://www.kucoin.com/r/rf/QBS4FPED)|-|
|
||||
||Mexc|CEX|[JKorf/Mexc.Net](https://github.com/JKorf/Mexc.Net)|[](https://www.nuget.org/packages/JK.Mexc.Net)|-|-|
|
||||
||OKX|CEX|[JKorf/OKX.Net](https://github.com/JKorf/OKX.Net)|[](https://www.nuget.org/packages/JK.OKX.Net)|[Link](https://www.okx.com/join/14592495)|20%|
|
||||
||Polymarket|DEX|[JKorf/Polymarket.Net](https://github.com/JKorf/Polymarket.Net)|[](https://www.nuget.org/packages/Polymarket.Net)|-|-|
|
||||
||Toobit|CEX|[JKorf/Toobit.Net](https://github.com/JKorf/Toobit.Net)|[](https://www.nuget.org/packages/Toobit.Net)|[Link](https://www.toobit.com/en-US/register?invite_code=zsV19h)|-|
|
||||
||Upbit|CEX|[JKorf/Upbit.Net](https://github.com/JKorf/Upbit.Net)|[](https://www.nuget.org/packages/JKorf.Upbit.Net)|-|-|
|
||||
||WhiteBit|CEX|[JKorf/WhiteBit.Net](https://github.com/JKorf/WhiteBit.Net)|[](https://www.nuget.org/packages/WhiteBit.Net)|[Link](https://whitebit.com/referral/a8e59b59-186c-4662-824c-3095248e0edf)|-|
|
||||
@@ -66,6 +67,70 @@ 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.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
|
||||
|
||||
* Version 10.3.0 - 22 Jan 2026
|
||||
* Added PlatformInfo class for specifying platform metadata
|
||||
* Added better handling for enabling AutoTimestamp in client options when not implemented in the API
|
||||
* Fixed state handling for subscriptions where queries do not get a response
|
||||
* Fixed HandleSubQueryResponse not getting called
|
||||
* Removed legacy websocket message handling and the corresponding UseUpdatedDeserialization client option
|
||||
|
||||
* Version 10.2.5 - 19 Jan 2026
|
||||
* Updated SymbolOrderBook.WaitUntilFirstUpdateBufferedAsync
|
||||
* Added GetRestOffsets and GetWebsocketOffsets to TimeOffsetManager
|
||||
|
||||
Reference in New Issue
Block a user