1
0
mirror of https://github.com/JKorf/CryptoExchange.Net.git synced 2026-08-11 16:32:57 +00:00

Compare commits

..

51 Commits

Author SHA1 Message Date
Jkorf 87dd2d9d40 Updated to version 10.4.1 2026-02-06 17:19:08 +01:00
Jkorf f2b0cb2f0d Updated UserDataTracker to only track symbol when position size > 0, update log verbosity 2026-02-06 17:16:31 +01:00
Jkorf de0a954a91 Updated to version 10.4.0 2026-02-06 12:00:50 +01:00
Jkorf 4a79ce22ec Added timeout/ct task cleanup AsyncResetEvent 2026-02-06 11:57:56 +01:00
JKorf 40d480e1fc Added AutoTimestamp option for socket client, fixed socket client timestamp offset bug 2026-02-05 21:54:16 +01:00
Jan Korf 74e5cf6fc9 Feature/userdata tracker (#271)
Added user data tracking logic
Added LastReceiveTime, SocketStatus and SubscriptionStatus properties to UpdateSubscription
Added SharedTransferStatus Enum and property to SharedDeposit
Added PositionMode property to SharedPosition model
Added IsZero property to SharedQuantity
Renamed IWebSocket LastActionTime to LastReceiveTime
Updated CryptoExchangeWebsocketClient LastReceiveTime logic
Updated Subscription status change event handler to run sync instead of separate task
2026-02-05 16:05:13 +01:00
Jkorf 2fd3912795 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2026-02-03 08:47:26 +01:00
JKorf ec44307a0c Updated AsyncResetEvent implementation 2026-02-02 21:01:35 +01:00
Jkorf ba55705385 Added Disposed property on BaseClient and IRestClient/ISocketClient interfaces 2026-02-02 09:33:47 +01:00
Jkorf 2c63a83117 Merge branch 'master' of https://github.com/JKorf/CryptoExchange.Net 2026-02-02 08:42:53 +01:00
JKorf 71b1e5e906 Added additional methods for requesting supported symbols to Shared ISpotSymbolRestClient/IFuturesSymbolRestClient interfaces 2026-02-01 15:02:48 +01:00
Jkorf eaeba6f27e Made Interval property of Kline tracker public 2026-01-28 10:31:44 +01:00
Jkorf 913bdaa855 Updated to version 10.3.1 2026-01-27 14:01:26 +01:00
Jkorf 5aa5790d0a Docs 2026-01-23 15:19:33 +01:00
Jkorf a8321e083e Fixed potential collection modified exception upon logging message not handled in websocket message handling 2026-01-22 09:57:02 +01:00
Jkorf ce3fa5f186 Updated to version 10.3.0 2026-01-22 09:33:47 +01:00
Jkorf fff70a9c65 Updated some testing 2026-01-21 15:38:22 +01:00
Jkorf cff33bb5ac Added handling for setting AutoTimestamp in client options when not implemented 2026-01-21 15:37:59 +01:00
Jkorf 21c8133292 Fixed HandleSubQueryResponse not getting called 2026-01-21 11:16:57 +01:00
Jkorf 76772e91ba Remove legacy websocket message handling 2026-01-21 11:14:06 +01:00
Jkorf 218e0260ce Added PlatformInfo class for specifying platform information 2026-01-21 10:14:00 +01:00
Jkorf 96b3904266 Fixed state handling for subscriptions where queries do not get a response 2026-01-20 15:19:42 +01:00
Jkorf bc8faf9822 Updated to version 10.2.5 2026-01-19 10:26:04 +01:00
JKorf 90c1b89ceb Added GetRestOffsets and GetWebsocketOffsets to TimeOffsetManager 2026-01-18 17:46:42 +01:00
JKorf 21206ffb25 Updated SymbolOrderBook.WaitUntilFirstUpdateBufferedAsync 2026-01-18 17:36:18 +01:00
JKorf 5942423bfb Updated to version 10.2.4 2026-01-17 16:29:56 +01:00
JKorf dc4abc42a7 Added WaitUntilFirstUpdateBufferedAsync method on SymbolOrderBook, fixed sequencen validation bug SymbolOrderBook 2026-01-17 16:26:25 +01:00
Jkorf c71a81e686 Added some util methods, Added CommaSplitStringConverter 2026-01-16 16:39:21 +01:00
Jkorf 550c0eabf1 Updated to version 10.2.3 2026-01-14 08:46:07 +01:00
JKorf 28a2a0c7fd Fixed semaphore exception when creating a new REST client while time sync is in progress on another client 2026-01-13 22:11:18 +01:00
JKorf 7dd1cd5bbd Added HandleUnhandledMessage virtual method to SocketApiClient to allow some processing for messages which couldn't be mapped via the normal way 2026-01-13 21:21:30 +01:00
Jkorf a7ff4416bd Updated to version 10.2.2 2026-01-13 11:18:16 +01:00
Jkorf 669d1f7c9e Allow the same websocket connection sequence number to be recorded multiple times 2026-01-13 11:16:37 +01:00
Jkorf 34ee2d3690 Updated to version 10.2.1 2026-01-13 09:29:04 +01:00
Jkorf 005fb7875d Fixed parameter URL creation for array values with ArrayParametersSerialization.MultipleValues 2026-01-13 09:25:35 +01:00
Jkorf fa9300ce97 Removed duplicate logging for rest responses in Trace verbosity 2026-01-13 09:25:07 +01:00
Jkorf fc2d3fc2d2 Updated to version 10.2.0 2026-01-12 14:30:38 +01:00
Jkorf 187ca6a4ef Fixed warning 2026-01-12 14:30:09 +01:00
Jan Korf 3b2a85d210 Feature/websocket sequencing (#267)
Added EnforceSequenceNumbers property on SocketApiClient to configure whether websocket message contain sequence numbers and if these should be checked to be sequential
Added fallback to existing websocket connection if no dedicated request connection was found
Added IntBoolConverter base class for arbitrary int value to bool mapping
Added SequenceNumber property to DataEvent object
Added _skipSequenceCheckFirstUpdateAfterSnapshotSet property for SymbolOrderBook implementations
Updated SymbolOrderBook sequenceNumber validation
Updated SymbolOrderBook log verbosities
Renamed SetInitialOrderBook to SetSnapshot in SymbolOrderBook
Renamed updateId references to sequenceNumber in SymbolOrderBook
2026-01-12 14:26:50 +01:00
Jkorf c512bee825 Updated examples 2026-01-07 14:56:18 +01:00
Jkorf 0943b052b9 Updated to version 10.1.0 2026-01-07 10:03:45 +01:00
Jan Korf a896fffdb3 Time offset management (#266)
Updated time sync / time offset management for REST API's
Added time offset tracking for WebSocket API's
Added GetAuthenticationQuery virtual method on AuthenticationProvider
Updated AuthenticationProvider GetTimestamp methods to include a one second offset by default
Added AuthenticationProvider GetTimestamp methods for SocketApiClient instances
Added ClientName property on BaseApiClient, resolving to the type name
Added ObjectOrArrayConverter JsonConverterFactory implementation for resolving json data which might be returned as object or array
Added UpdateServerTime, UpdateLocalTime and DataAge properties to (I)SymbolOrderBook
Added OutputToConsoleAsync method to (I)SymbolOrderBook
Updated SymbolOrderBook string representation
Added DataTimeLocal and DataAge properties to DataEvent object
Added SocketConnection parameter to subscription HandleSubQueryResponse and HandleUnsubQueryResponse methods
2026-01-07 10:00:14 +01:00
JKorf 177daf903b Added some utils methods 2025-12-30 09:53:30 +01:00
Jkorf aa1ebdc4ed Updated CryptoExchange.Net to version 10.0.2 2025-12-19 11:34:26 +01:00
Jkorf 38058c4a70 Updated to version 10.0.2 2025-12-19 10:14:59 +01:00
Jkorf a7eb483479 Added exception handlers for REST response processing 2025-12-19 10:09:14 +01:00
Jkorf c76931a3b4 Fixed duplicate subscription check with updated deserialization 2025-12-19 09:58:22 +01:00
Jkorf b90b7e9e0c Updated CryptoExchange.Net to 10.0.1 2025-12-18 11:13:56 +01:00
Jkorf beda53d36d Updated to version 10.0.1 2025-12-18 10:56:10 +01:00
Jkorf 0668f669c1 Fixed query parameter array serialization 2025-12-18 10:36:22 +01:00
Jkorf 64250e13db Updated examples 2025-12-17 10:55:48 +01:00
91 changed files with 4058 additions and 1852 deletions
@@ -41,7 +41,7 @@
<DocumentationFile>CryptoExchange.Net.Protobuf.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CryptoExchange.Net" Version="10.0.0" />
<PackageReference Include="CryptoExchange.Net" Version="10.0.2" />
<PackageReference Include="protobuf-net" Version="3.2.56" />
</ItemGroup>
</Project>
@@ -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")]
@@ -9,7 +9,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"></PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.4.0"></PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="6.0.0"></PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1"></PackageReference>
</ItemGroup>
<ItemGroup>
@@ -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,21 +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()}";
public override TimeSpan? GetTimeOffset() => null;
public override TimeSyncInfo GetTimeSyncInfo() => null;
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
@@ -160,11 +159,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
ParameterPositions[method] = position;
}
public override TimeSpan? GetTimeOffset()
{
throw new NotImplementedException();
}
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
=> new TestAuthProvider(credentials);
@@ -172,11 +166,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
{
throw new NotImplementedException();
}
public override TimeSyncInfo GetTimeSyncInfo()
{
throw new NotImplementedException();
}
}
public class TestRestApi2Client : RestApiClient
@@ -188,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 />
@@ -198,12 +186,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
{
return await SendAsync<T>("http://www.test.com", new RequestDefinition("/", HttpMethod.Get) { Weight = 0 }, null, ct);
}
public override TimeSpan? GetTimeOffset()
{
throw new NotImplementedException();
}
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
=> new TestAuthProvider(credentials);
@@ -212,10 +195,6 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
throw new NotImplementedException();
}
public override TimeSyncInfo GetTimeSyncInfo()
{
throw new NotImplementedException();
}
}
public class TestError
@@ -11,6 +11,8 @@ using System.Linq;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using CryptoExchange.Net.Sockets;
using CryptoExchange.Net.Sockets.Default;
namespace CryptoExchange.Net.Authentication
{
@@ -76,12 +78,20 @@ namespace CryptoExchange.Net.Authentication
}
/// <summary>
/// Authenticate a request
/// Authenticate a REST request
/// </summary>
/// <param name="apiClient">The Api client sending the request</param>
/// <param name="apiClient">The API client sending the request</param>
/// <param name="requestConfig">The request configuration</param>
public abstract void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig);
/// <summary>
/// Get an authentication query for a websocket
/// </summary>
/// <param name="apiClient">The API client sending the request</param>
/// <param name="connection">The connection to authenticate</param>
/// <param name="context">Optional context required for creating the authentication query</param>
public virtual Query? GetAuthenticationQuery(SocketApiClient apiClient, SocketConnection connection, Dictionary<string, object?>? context = null) => null;
/// <summary>
/// SHA256 sign the data and return the bytes
/// </summary>
@@ -442,6 +452,14 @@ namespace CryptoExchange.Net.Authentication
/// <param name="buff"></param>
/// <returns></returns>
protected static string BytesToHexString(byte[] buff)
=> BytesToHexString(new ArraySegment<byte>(buff));
/// <summary>
/// Convert byte array to hex string
/// </summary>
/// <param name="buff"></param>
/// <returns></returns>
protected static string BytesToHexString(ArraySegment<byte> buff)
{
#if NET9_0_OR_GREATER
return Convert.ToHexString(buff);
@@ -453,6 +471,26 @@ namespace CryptoExchange.Net.Authentication
#endif
}
/// <summary>
/// Convert a hex encoded string to byte array
/// </summary>
/// <param name="hexString"></param>
/// <returns></returns>
protected static byte[] HexToBytesString(string hexString)
{
if (hexString.StartsWith("0x"))
hexString = hexString.Substring(2);
byte[] bytes = new byte[hexString.Length / 2];
for (int i = 0; i < hexString.Length; i += 2)
{
string hexSubstring = hexString.Substring(i, 2);
bytes[i / 2] = Convert.ToByte(hexSubstring, 16);
}
return bytes;
}
/// <summary>
/// Convert byte array to base64 string
/// </summary>
@@ -466,32 +504,53 @@ namespace CryptoExchange.Net.Authentication
/// <summary>
/// Get current timestamp including the time sync offset from the api client
/// </summary>
/// <param name="apiClient"></param>
/// <returns></returns>
protected DateTime GetTimestamp(RestApiClient apiClient)
protected DateTime GetTimestamp(RestApiClient apiClient, bool includeOneSecondOffset = true)
{
return TimeProvider.GetTime().Add(apiClient.GetTimeOffset() ?? TimeSpan.Zero)!;
var result = TimeProvider.GetTime().Add(TimeOffsetManager.GetRestOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
if (includeOneSecondOffset)
result = result.AddSeconds(-1);
return result;
}
/// <summary>
/// Get current timestamp including the time sync offset from the api client
/// </summary>
protected DateTime GetTimestamp(SocketApiClient apiClient, bool includeOneSecondOffset = true)
{
var timestamp = TimeProvider.GetTime();
if(apiClient.ApiOptions.AutoTimestamp ?? apiClient.ClientOptions.AutoTimestamp)
timestamp = timestamp.Add(-TimeOffsetManager.GetSocketOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
if (includeOneSecondOffset)
timestamp = timestamp.AddSeconds(-1);
return timestamp;
}
/// <summary>
/// Get millisecond timestamp as a string including the time sync offset from the api client
/// </summary>
/// <param name="apiClient"></param>
/// <returns></returns>
protected string GetMillisecondTimestamp(RestApiClient apiClient)
{
return DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient)).Value.ToString(CultureInfo.InvariantCulture);
}
protected string GetMillisecondTimestamp(RestApiClient apiClient, bool includeOneSecondOffset = true)
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value.ToString(CultureInfo.InvariantCulture);
/// <summary>
/// Get millisecond timestamp as a string including the time sync offset from the api client
/// </summary>
protected string GetMillisecondTimestamp(SocketApiClient apiClient, bool includeOneSecondOffset = true)
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value.ToString(CultureInfo.InvariantCulture);
/// <summary>
/// Get millisecond timestamp as a long including the time sync offset from the api client
/// </summary>
/// <param name="apiClient"></param>
/// <returns></returns>
protected long GetMillisecondTimestampLong(RestApiClient apiClient)
{
return DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient)).Value;
}
protected long GetMillisecondTimestampLong(RestApiClient apiClient, bool includeOneSecondOffset = true)
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value;
/// <summary>
/// Get millisecond timestamp as a long including the time sync offset from the api client
/// </summary>
protected long GetMillisecondTimestampLong(SocketApiClient apiClient, bool includeOneSecondOffset = true)
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value;
/// <summary>
/// Return the serialized request body
@@ -13,6 +13,11 @@ namespace CryptoExchange.Net.Clients
/// </summary>
public abstract class BaseApiClient : IDisposable, IBaseApiClient
{
/// <summary>
/// Client name
/// </summary>
protected string? _clientName;
/// <summary>
/// Logger
/// </summary>
@@ -23,6 +28,21 @@ namespace CryptoExchange.Net.Clients
/// </summary>
protected bool _disposing;
/// <summary>
/// Name of the client
/// </summary>
protected internal string ClientName
{
get
{
if (_clientName != null)
return _clientName;
_clientName = GetType().Name;
return _clientName;
}
}
/// <summary>
/// The authentication provider for this API client. (null if no credentials are set)
/// </summary>
+7
View File
@@ -39,6 +39,11 @@ namespace CryptoExchange.Net.Clients
/// </summary>
public string Exchange { get; }
/// <summary>
/// Whether client is disposed
/// </summary>
public bool Disposed { get; private set; }
/// <summary>
/// Api clients in this client
/// </summary>
@@ -125,6 +130,8 @@ namespace CryptoExchange.Net.Clients
/// </summary>
public virtual void Dispose()
{
Disposed = true;
foreach (var client in ApiClients)
client.Dispose();
}
+86 -66
View File
@@ -32,12 +32,6 @@ namespace CryptoExchange.Net.Clients
/// <inheritdoc />
public IRequestFactory RequestFactory { get; set; } = new RequestFactory();
/// <inheritdoc />
public abstract TimeSyncInfo? GetTimeSyncInfo();
/// <inheritdoc />
public abstract TimeSpan? GetTimeOffset();
/// <inheritdoc />
public int TotalRequestsMade { get; set; }
@@ -115,15 +109,11 @@ namespace CryptoExchange.Net.Clients
options,
apiOptions)
{
TimeOffsetManager.RegisterRestApi(ClientName);
RequestFactory.Configure(options, httpClient);
}
/// <summary>
/// Create a message accessor instance
/// </summary>
/// <returns></returns>
protected abstract IStreamMessageAccessor CreateAccessor();
/// <summary>
/// Create a serializer instance
/// </summary>
@@ -241,11 +231,9 @@ namespace CryptoExchange.Net.Clients
{
currentTry++;
var error = await CheckTimeSync(requestId, definition).ConfigureAwait(false);
if (error != null)
return new WebCallResult<T>(error);
await CheckTimeSync(requestId, definition).ConfigureAwait(false);
error = await RateLimitAsync(
var error = await RateLimitAsync(
baseAddress,
requestId,
definition,
@@ -300,28 +288,6 @@ namespace CryptoExchange.Net.Clients
}
}
private async ValueTask<Error?> CheckTimeSync(int requestId, RequestDefinition definition)
{
if (!definition.Authenticated)
return null;
var syncTask = SyncTimeAsync();
var timeSyncInfo = GetTimeSyncInfo();
if (timeSyncInfo != null && timeSyncInfo.TimeSyncState.LastSyncTime == default)
{
// Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue
var syncTimeError = await syncTask.ConfigureAwait(false);
if (syncTimeError != null)
{
_logger.RestApiFailedToSyncTime(requestId, syncTimeError!.ToString());
return syncTimeError;
}
}
return null;
}
/// <summary>
/// Check rate limits for the request
/// </summary>
@@ -482,9 +448,6 @@ namespace CryptoExchange.Net.Clients
{
memoryStream.Position = 0;
originalData = await reader.ReadToEndAsync().ConfigureAwait(false);
if (_logger.IsEnabled(LogLevel.Trace))
_logger.RestApiReceivedResponse(request.RequestId, originalData);
}
// Continue processing from the memory stream since the response stream is already read and we can't seek it
@@ -516,10 +479,20 @@ namespace CryptoExchange.Net.Clients
else
{
// Handle a 'normal' error response. Can still be either a json error message or some random HTML or other string
error = await MessageHandler.ParseErrorResponse(
try
{
error = await MessageHandler.ParseErrorResponse(
(int)response.StatusCode,
response.ResponseHeaders,
responseStream).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception when parsing error response: {Message}", ex.Message);
var errorResult = new ServerError(ErrorInfo.Unknown with { Message = ex.Message });
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, errorResult);
}
}
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
@@ -558,10 +531,19 @@ namespace CryptoExchange.Net.Clients
if (deserializeError != null)
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, deserializeError); ;
// Check the deserialized response to see if it's an error or not
var responseError = MessageHandler.CheckDeserializedResponse(response.ResponseHeaders, deserializeResult);
if (responseError != null)
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, responseError);
try
{
// Check the deserialized response to see if it's an error or not
var responseError = MessageHandler.CheckDeserializedResponse(response.ResponseHeaders, deserializeResult);
if (responseError != null)
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, responseError);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception when checking deserialized response: {Message}", ex.Message);
var error = new ServerError(ErrorInfo.Unknown with { Message = ex.Message });
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, error);
}
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, null);
}
@@ -706,26 +688,53 @@ namespace CryptoExchange.Net.Clients
RequestFactory.UpdateSettings(options.Proxy, options.RequestTimeout ?? ClientOptions.RequestTimeout, ClientOptions.HttpKeepAliveInterval);
}
internal async ValueTask<Error?> SyncTimeAsync()
private async ValueTask CheckTimeSync(int requestId, RequestDefinition definition)
{
var timeSyncParams = GetTimeSyncInfo();
if (timeSyncParams == null)
return null;
if (!definition.Authenticated)
return;
if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false))
var lastUpdateTime = TimeOffsetManager.GetRestLastUpdateTime(ClientName);
var syncTask = CheckTimeOffsetAsync();
if (lastUpdateTime == null)
{
if (!timeSyncParams.SyncTime || DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval)
{
timeSyncParams.TimeSyncState.Semaphore.Release();
return null;
}
// Initially with first request we'll need to wait for the time syncing before making the actual request.
// If it's not the first request we can just continue and let it complete in the background
await syncTask.ConfigureAwait(false);
}
return;
}
internal async ValueTask CheckTimeOffsetAsync()
{
if (!(ApiOptions.AutoTimestamp ?? ClientOptions.AutoTimestamp))
// Time syncing not enabled
return;
await TimeOffsetManager.EnterAsync(ClientName).ConfigureAwait(false);
try
{
var lastUpdateTime = TimeOffsetManager.GetRestLastUpdateTime(ClientName);
if (DateTime.UtcNow - lastUpdateTime < (ApiOptions.TimestampRecalculationInterval ?? ClientOptions.TimestampRecalculationInterval))
// Time syncing was recently done
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)
{
timeSyncParams.TimeSyncState.Semaphore.Release();
return result.Error;
_logger.LogWarning("Failed to determine time offset between client and server, timestamping might fail");
return;
}
if (TotalRequestsMade == 1)
@@ -735,18 +744,29 @@ namespace CryptoExchange.Net.Clients
result = await GetServerTimestampAsync().ConfigureAwait(false);
if (!result)
{
timeSyncParams.TimeSyncState.Semaphore.Release();
return result.Error;
_logger.LogWarning("Failed to determine time offset between client and server, timestamping might fail");
return;
}
}
// Calculate time offset between local and server
// Estimate the offset as the round trip time / 2
var offset = result.Data - localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2);
timeSyncParams.UpdateTimeOffset(offset);
timeSyncParams.TimeSyncState.Semaphore.Release();
}
if (offset.TotalMilliseconds > 0 && offset.TotalMilliseconds < 500)
{
_logger.LogInformation("{ClientName} Time offset within limits ({Offset}ms), set offset to 0ms", ClientName, Math.Round(offset.TotalMilliseconds));
offset = TimeSpan.Zero;
}
else
{
_logger.LogInformation("{ClientName} Time offset set to {Offset}ms", ClientName, Math.Round(offset.TotalMilliseconds));
}
return null;
TimeOffsetManager.UpdateRestOffset(ClientName, offset.TotalMilliseconds);
}
finally
{
TimeOffsetManager.Release(ClientName);
}
}
private bool ShouldCache(RequestDefinition definition)
+38 -32
View File
@@ -32,8 +32,10 @@ namespace CryptoExchange.Net.Clients
public abstract class SocketApiClient : BaseApiClient, ISocketApiClient
{
#region Fields
/// <inheritdoc/>
public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory();
/// <inheritdoc/>
public IHighPerfConnectionFactory? HighPerfConnectionFactory { get; set; }
@@ -97,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
{
@@ -140,6 +137,10 @@ namespace CryptoExchange.Net.Clients
/// </summary>
public int? MaxIndividualSubscriptionsPerConnection { get; set; }
/// <summary>
/// Whether or not to enforce that sequence number updates are always (lastSequenceNumber + 1)
/// </summary>
public bool EnforceSequenceNumbers { get; set; }
#endregion
/// <summary>
@@ -159,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>
@@ -181,6 +176,24 @@ namespace CryptoExchange.Net.Clients
DedicatedConnectionConfigs.Add(new DedicatedConnectionConfig() { SocketAddress = url, Authenticated = auth });
}
/// <summary>
/// Update the timestamp offset between client and server based on the timestamp
/// </summary>
/// <param name="timestamp">Timestamp received from the server</param>
public virtual void UpdateTimeOffset(DateTime timestamp)
{
if (timestamp == default)
return;
TimeOffsetManager.UpdateSocketOffset(ClientName, (DateTime.UtcNow - timestamp).TotalMilliseconds);
}
/// <summary>
/// Get the time offset between client and server
/// </summary>
/// <returns></returns>
public virtual TimeSpan? GetTimeOffset() => TimeOffsetManager.GetSocketOffset(ClientName);
/// <summary>
/// Add a query to periodically send on each connection
/// </summary>
@@ -296,7 +309,7 @@ namespace CryptoExchange.Net.Clients
if (!success)
return;
subscription.HandleSubQueryResponse(response);
subscription.HandleSubQueryResponse(socketConnection, response);
subscription.Status = SubscriptionStatus.Subscribed;
if (ct != default)
{
@@ -332,6 +345,9 @@ namespace CryptoExchange.Net.Clients
return new CallResult<UpdateSubscription>(subResult.Error!);
}
}
if (!subQuery.ExpectsResponse)
HandleSubscriptionComplete(true, null);
}
else
{
@@ -575,7 +591,8 @@ namespace CryptoExchange.Net.Clients
/// Should return the request which can be used to authenticate a socket connection
/// </summary>
/// <returns></returns>
protected internal virtual Task<Query?> GetAuthenticationRequestAsync(SocketConnection connection) => throw new NotImplementedException();
protected internal virtual Task<Query?> GetAuthenticationRequestAsync(SocketConnection connection) =>
Task.FromResult(AuthenticationProvider!.GetAuthenticationQuery(this, connection));
/// <summary>
/// Adds a system subscription. Used for example to reply to ping requests
@@ -685,6 +702,10 @@ namespace CryptoExchange.Net.Clients
if (connection != null && !connection.DedicatedRequestConnection.Authenticated)
// Mark dedicated request connection as authenticated if the request is authenticated
connection.DedicatedRequestConnection.Authenticated = authenticated;
if (connection == null)
// Fall back to an existing connection if there is no dedicated request connection available
connection = socketQuery.OrderBy(s => s.UserSubscriptionCount).FirstOrDefault();
}
bool maxConnectionsReached = _socketConnections.Count >= (ApiOptions.MaxSocketConnections ?? ClientOptions.MaxSocketConnections);
@@ -722,7 +743,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)
{
@@ -773,14 +793,13 @@ 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)
{
}
/// <param name="connection">The socket connection</param>
/// <param name="typeIdentifier">The type as identified</param>
/// <param name="data">The data</param>
protected internal virtual bool HandleUnhandledMessage(SocketConnection connection, string typeIdentifier, ReadOnlySpan<byte> data) => false;
/// <summary>
/// Process connect rate limited
@@ -834,7 +853,6 @@ namespace CryptoExchange.Net.Clients
Proxy = ClientOptions.Proxy,
Timeout = ApiOptions.SocketNoDataTimeout ?? ClientOptions.SocketNoDataTimeout,
ReceiveBufferSize = ClientOptions.ReceiveBufferSize,
UseUpdatedDeserialization = ClientOptions.UseUpdatedDeserialization
};
/// <summary>
@@ -1027,7 +1045,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()}]");
});
}
});
@@ -1058,21 +1075,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
}
}
@@ -0,0 +1,30 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CryptoExchange.Net.Converters.SystemTextJson
{
/// <summary>
/// Converter for comma separated string values
/// </summary>
public class CommaSplitStringConverter : JsonConverter<string[]>
{
/// <inheritdoc />
public override string[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var str = reader.GetString();
if (string.IsNullOrEmpty(str))
return [];
return str!.Split(',').ToArray() ?? [];
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, string[] value, JsonSerializerOptions options)
{
writer.WriteStringValue(string.Join(",", value));
}
}
}
@@ -0,0 +1,38 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CryptoExchange.Net.Converters.SystemTextJson
{
/// <summary>
/// Bool converter
/// </summary>
public class IntBoolConverter : JsonConverter<bool>
{
private readonly int _trueValue;
/// <summary>
/// ctor
/// </summary>
/// <param name="trueValue">The int value representing the true value</param>
public IntBoolConverter(int trueValue)
{
_trueValue = trueValue;
}
/// <inheritdoc />
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.Number)
return false;
return reader.GetDecimal() == _trueValue;
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteNumberValue(_trueValue);
}
}
}
@@ -0,0 +1,58 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
namespace CryptoExchange.Net.Converters.SystemTextJson
{
/// <summary>
/// Converter for parsing object or array responses
/// </summary>
public class ObjectOrArrayConverter : JsonConverterFactory
{
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert) => true;
/// <inheritdoc />
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var type = typeof(InternalObjectOrArrayConverter<>).MakeGenericType(typeToConvert);
return (JsonConverter)Activator.CreateInstance(type)!;
}
private class InternalObjectOrArrayConverter<T> : JsonConverter<T>
{
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.StartObject && !typeToConvert.IsArray)
{
// Object to object
return JsonDocument.ParseValue(ref reader).Deserialize<T>(options);
}
else if (reader.TokenType == JsonTokenType.StartArray && typeToConvert.IsArray)
{
// Array to array
return JsonDocument.ParseValue(ref reader).Deserialize<T>(options);
}
else if (reader.TokenType == JsonTokenType.StartArray)
{
// Array to object
JsonDocument.ParseValue(ref reader).Deserialize<T[]>(options);
return default;
}
else
{
// Object to array
JsonDocument.ParseValue(ref reader);
return default;
}
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, options);
}
}
}
}
@@ -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;
}
}
}
+3 -3
View File
@@ -6,9 +6,9 @@
<PackageId>CryptoExchange.Net</PackageId>
<Authors>JKorf</Authors>
<Description>CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations.</Description>
<PackageVersion>10.0.0</PackageVersion>
<AssemblyVersion>10.0.0</AssemblyVersion>
<FileVersion>10.0.0</FileVersion>
<PackageVersion>10.4.1</PackageVersion>
<AssemblyVersion>10.4.1</AssemblyVersion>
<FileVersion>10.4.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>
+20 -2
View File
@@ -242,8 +242,7 @@ namespace CryptoExchange.Net
/// <summary>
/// Generate a long value
/// </summary>
/// <param name="maxLength">Max character length</param>
/// <returns></returns>
/// <param name="maxLength">Max number of digits</param>
public static long RandomLong(int maxLength)
{
#if NETSTANDARD2_1_OR_GREATER || NET9_0_OR_GREATER
@@ -259,6 +258,25 @@ namespace CryptoExchange.Net
return value;
}
/// <summary>
/// Generate a long value between two values
/// </summary>
/// <param name="minValue">Min value</param>
/// <param name="maxValue">Max value</param>
/// <returns></returns>
public static long RandomLong(long minValue, long maxValue)
{
#if NET8_0_OR_GREATER
var buf = RandomNumberGenerator.GetBytes(8);
#else
byte[] buf = new byte[8];
var random = new Random();
random.NextBytes(buf);
#endif
long longRand = BitConverter.ToInt64(buf, 0);
return (Math.Abs(longRand % (maxValue - minValue)) + minValue);
}
/// <summary>
/// Generate a random string of specified length
/// </summary>
+60
View File
@@ -32,6 +32,66 @@ namespace CryptoExchange.Net
_symbolInfos[topicId] = new ExchangeInfo(DateTime.UtcNow, updateData.ToDictionary(x => x.Name, x => x.SharedSymbol));
}
/// <summary>
/// Whether the specific topic has been cached
/// </summary>
/// <param name="topicId">Id</param>
public static bool HasCached(string topicId)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return false;
return exchangeInfo.Symbols.Count > 0;
}
/// <summary>
/// Whether a specific exchange(topic) support the provided symbol
/// </summary>
/// <param name="topicId">Id for the provided data</param>
/// <param name="symbolName">The symbol name</param>
public static bool SupportsSymbol(string topicId, string symbolName)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return false;
if (!exchangeInfo.Symbols.TryGetValue(symbolName, out var symbolInfo))
return false;
return true;
}
/// <summary>
/// Whether a specific exchange(topic) support the provided symbol
/// </summary>
/// <param name="topicId">Id for the provided data</param>
/// <param name="symbol">The symbol info</param>
public static bool SupportsSymbol(string topicId, SharedSymbol symbol)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return false;
return exchangeInfo.Symbols.Any(x =>
x.Value.TradingMode == symbol.TradingMode
&& x.Value.BaseAsset == symbol.BaseAsset
&& x.Value.QuoteAsset == symbol.QuoteAsset);
}
/// <summary>
/// Get all symbols for a specific base asset
/// </summary>
/// <param name="topicId">Id for the provided data</param>
/// <param name="baseAsset">Base asset name</param>
public static SharedSymbol[] GetSymbolsForBaseAsset(string topicId, string baseAsset)
{
if (!_symbolInfos.TryGetValue(topicId, out var exchangeInfo))
return [];
return exchangeInfo.Symbols
.Where(x => x.Value.BaseAsset.Equals(baseAsset, StringComparison.InvariantCultureIgnoreCase))
.Select(x => x.Value)
.ToArray();
}
/// <summary>
/// Parse a symbol name to a SharedSymbol
/// </summary>
+31 -148
View File
@@ -70,12 +70,17 @@ namespace CryptoExchange.Net
first = false;
if (parameter.GetType().IsArray)
if (parameter.Value.GetType().IsArray)
{
if (serializationType == ArrayParametersSerialization.Array)
{
foreach(var entry in (object[])parameter.Value)
bool firstArrayValue = true;
foreach (var entry in (object[])parameter.Value)
{
if (!firstArrayValue)
uriString.Append('&');
firstArrayValue = false;
uriString.Append(parameter.Key);
uriString.Append("[]=");
if (urlEncodeValues)
@@ -86,8 +91,12 @@ namespace CryptoExchange.Net
}
else if (serializationType == ArrayParametersSerialization.MultipleValues)
{
bool firstArrayValue = true;
foreach (var entry in (object[])parameter.Value)
{
if (!firstArrayValue)
uriString.Append('&');
firstArrayValue = false;
uriString.Append(parameter.Key);
uriString.Append("=");
if (urlEncodeValues)
@@ -283,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>
@@ -410,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>
@@ -436,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>
@@ -607,6 +470,26 @@ namespace CryptoExchange.Net
return services;
}
/// <summary>
/// Convert a hex encoded string to byte array
/// </summary>
/// <param name="hexString"></param>
/// <returns></returns>
public static byte[] HexStringToBytes(this string hexString)
{
if (hexString.StartsWith("0x"))
hexString = hexString.Substring(2);
byte[] bytes = new byte[hexString.Length / 2];
for (int i = 0; i < hexString.Length; i += 2)
{
string hexSubstring = hexString.Substring(i, 2);
bytes[i / 2] = Convert.ToByte(hexSubstring, 16);
}
return bytes;
}
}
}
@@ -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);
}
}
@@ -47,9 +47,21 @@ namespace CryptoExchange.Net.Interfaces
/// </summary>
event Action<(ISymbolOrderBookEntry BestBid, ISymbolOrderBookEntry BestAsk)> OnBestOffersChanged;
/// <summary>
/// Timestamp of the last update
/// Timestamp of when the last update was applied to the book, local time
/// </summary>
DateTime UpdateTime { get; }
/// <summary>
/// Timestamp of the last event that was applied, server time
/// </summary>
DateTime? UpdateServerTime { get; }
/// <summary>
/// Timestamp of the last event that was applied, in local time, estimated based on timestamp difference between client and server
/// </summary>
DateTime? UpdateLocalTime { get; }
/// <summary>
/// Age of the data, in local time, estimated based on timestamp difference between client and server + the period since last update
/// </summary>
TimeSpan? DataAge { get; }
/// <summary>
/// The number of asks in the book
@@ -126,5 +138,13 @@ namespace CryptoExchange.Net.Interfaces
/// </summary>
/// <returns></returns>
string ToString(int rows);
/// <summary>
/// Output the orderbook to the console
/// </summary>
/// <param name="numberOfEntries">Number of rows to display</param>
/// <param name="refreshInterval">Refresh interval</param>
/// <param name="ct">Cancellation token</param>
Task OutputToConsoleAsync(int numberOfEntries, TimeSpan refreshInterval, CancellationToken ct = default);
}
}
@@ -22,7 +22,6 @@ namespace CryptoExchange.Net.Logging.Extensions
private static readonly Action<ILogger, string, Exception?> _restApiCacheHit;
private static readonly Action<ILogger, string, Exception?> _restApiCacheNotHit;
private static readonly Action<ILogger, int?, Exception?> _restApiCancellationRequested;
private static readonly Action<ILogger, int?, string?, Exception?> _restApiReceivedResponse;
static RestApiClientLoggingExtensions()
{
@@ -90,11 +89,6 @@ namespace CryptoExchange.Net.Logging.Extensions
LogLevel.Debug,
new EventId(4012, "RestApiCancellationRequested"),
"[Req {RequestId}] Request cancelled by user");
_restApiReceivedResponse = LoggerMessage.Define<int?, string?>(
LogLevel.Trace,
new EventId(4013, "RestApiReceivedResponse"),
"[Req {RequestId}] Received response: {Data}");
}
@@ -161,10 +155,5 @@ namespace CryptoExchange.Net.Logging.Extensions
{
_restApiCancellationRequested(logger, requestId, null);
}
public static void RestApiReceivedResponse(this ILogger logger, int requestId, string? originalData)
{
_restApiReceivedResponse(logger, requestId, originalData, null);
}
}
}
@@ -22,13 +22,15 @@ namespace CryptoExchange.Net.Logging.Extensions
private static readonly Action<ILogger, string, string, Exception?> _orderBookResyncing;
private static readonly Action<ILogger, string, string, Exception?> _orderBookResynced;
private static readonly Action<ILogger, string, string, Exception?> _orderBookMessageSkippedBecauseOfResubscribing;
private static readonly Action<ILogger, string, string, long, long, long, Exception?> _orderBookDataSet;
private static readonly Action<ILogger, string, string, long, long, long?, Exception?> _orderBookDataSet;
private static readonly Action<ILogger, string, string, long, long, long, long, Exception?> _orderBookUpdateBuffered;
private static readonly Action<ILogger, string, string, decimal, decimal, Exception?> _orderBookOutOfSyncDetected;
private static readonly Action<ILogger, string, string, Exception?> _orderBookReconnectingSocket;
private static readonly Action<ILogger, string, string, long, long, Exception?> _orderBookSkippedMessage;
private static readonly Action<ILogger, string, string, long, long, Exception?> _orderBookProcessedMessage;
private static readonly Action<ILogger, string, string, long, Exception?> _orderBookProcessedMessageSingle;
private static readonly Action<ILogger, string, string, long, long, Exception?> _orderBookOutOfSync;
private static readonly Action<ILogger, string, string, long, long, long, Exception?> _orderBookUpdateSkippedStartEnd;
static SymbolOrderBookLoggingExtensions()
{
@@ -73,7 +75,7 @@ namespace CryptoExchange.Net.Logging.Extensions
"{Api} order book {Symbol} Processing {NumberBufferedUpdated} buffered updates");
_orderBookUpdateSkipped = LoggerMessage.Define<string, string, long, long>(
LogLevel.Debug,
LogLevel.Trace,
new EventId(5008, "OrderBookUpdateSkipped"),
"{Api} order book {Symbol} update skipped #{SequenceNumber}, currently at #{LastSequenceNumber}");
@@ -92,10 +94,10 @@ namespace CryptoExchange.Net.Logging.Extensions
new EventId(5011, "OrderBookMessageSkippedResubscribing"),
"{Api} order book {Symbol} Skipping message because of resubscribing");
_orderBookDataSet = LoggerMessage.Define<string, string, long, long, long>(
LogLevel.Debug,
_orderBookDataSet = LoggerMessage.Define<string, string, long, long, long?>(
LogLevel.Trace,
new EventId(5012, "OrderBookDataSet"),
"{Api} order book {Symbol} data set: {BidCount} bids, {AskCount} asks. #{EndUpdateId}");
"{Api} order book {Symbol} snapshot set: {BidCount} bids, {AskCount} asks. #{EndUpdateId}");
_orderBookUpdateBuffered = LoggerMessage.Define<string, string, long, long, long, long>(
LogLevel.Trace,
@@ -136,6 +138,17 @@ namespace CryptoExchange.Net.Logging.Extensions
LogLevel.Warning,
new EventId(5020, "OrderBookOutOfSyncChecksum"),
"{Api} order book {Symbol} out of sync. Checksum mismatch, resyncing");
_orderBookProcessedMessageSingle = LoggerMessage.Define<string, string, long>(
LogLevel.Trace,
new EventId(5021, "OrderBookProcessedMessage"),
"{Api} order book {Symbol} update processed #{UpdateId}");
_orderBookUpdateSkippedStartEnd = LoggerMessage.Define<string, string, long, long, long>(
LogLevel.Trace,
new EventId(5022, "OrderBookUpdateSkippedStartEnd"),
"{Api} order book {Symbol} update skipped #{SequenceStart}-#{SequenceEnd}, currently at #{LastSequenceNumber}");
}
public static void OrderBookStatusChanged(this ILogger logger, string api, string symbol, OrderBookStatus previousStatus, OrderBookStatus newStatus)
@@ -194,7 +207,7 @@ namespace CryptoExchange.Net.Logging.Extensions
{
_orderBookMessageSkippedBecauseOfResubscribing(logger, api, symbol, null);
}
public static void OrderBookDataSet(this ILogger logger, string api, string symbol, long bidCount, long askCount, long endUpdateId)
public static void OrderBookDataSet(this ILogger logger, string api, string symbol, long bidCount, long askCount, long? endUpdateId)
{
_orderBookDataSet(logger, api, symbol, bidCount, askCount, endUpdateId, null);
}
@@ -229,9 +242,18 @@ namespace CryptoExchange.Net.Logging.Extensions
_orderBookProcessedMessage(logger, api, symbol, firstUpdateId, lastUpdateId, null);
}
public static void OrderBookProcessedMessage(this ILogger logger, string api, string symbol, long updateId)
{
_orderBookProcessedMessageSingle(logger, api, symbol, updateId, null);
}
public static void OrderBookOutOfSyncChecksum(this ILogger logger, string api, string symbol)
{
_orderBookOutOfSyncChecksum(logger, api, symbol, null);
}
public static void OrderBookUpdateSkipped(this ILogger logger, string api, string symbol, long sequenceStart, long sequenceEnd, long lastSequenceNumber)
{
_orderBookUpdateSkippedStartEnd(logger, api, symbol, sequenceStart, sequenceEnd, lastSequenceNumber, null);
}
}
}
@@ -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);
}
}
}
}
}
}
+34
View File
@@ -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;
}
}
@@ -96,6 +96,23 @@ namespace CryptoExchange.Net.Objects
base.Add(key, value.Value.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Add a DateTime value as string
/// </summary>
public void AddString(string key, DateTime value)
{
base.Add(key, value.ToString("yyyy-MM-ddTHH:mm:ssZ"));
}
/// <summary>
/// Add a DateTime value as string. Not added if value is null
/// </summary>
public void AddOptionalString(string key, DateTime? value)
{
if (value != null)
base.Add(key, value.Value.ToString("yyyy-MM-ddTHH:mm:ssZ"));
}
/// <summary>
/// Add a datetime value as milliseconds timestamp
/// </summary>
@@ -241,6 +258,45 @@ namespace CryptoExchange.Net.Objects
base.Add(key, int.Parse(stringVal));
}
}
/// <summary>
/// Add key as comma separated values
/// </summary>
public void AddCommaSeparated(string key, IEnumerable<string> values)
{
base.Add(key, string.Join(",", values));
}
/// <summary>
/// Add key as comma separated values if there are values provided
/// </summary>
public void AddOptionalCommaSeparated(string key, IEnumerable<string>? values)
{
if (values == null || !values.Any())
return;
base.Add(key, string.Join(",", values));
}
/// <summary>
/// Add key as boolean lower case value
/// </summary>
public void AddBoolString(string key, bool value)
{
base.Add(key, value.ToString().ToLower());
}
/// <summary>
/// Add key as boolean lower case value if it's not null
/// </summary>
public void AddOptionalBoolString(string key, bool? value)
{
if (value == null)
return;
base.Add(key, value.ToString()!.ToLower());
}
/// <summary>
/// Set the request body. Can be used to specify a simple value or array as the body instead of an object
@@ -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;
}
}
}
@@ -18,6 +18,16 @@ namespace CryptoExchange.Net.Objects.Sockets
/// </summary>
public DateTime? DataTime { get; set; }
/// <summary>
/// The timestamp of the data in local time. Note that this is an estimation based on average delay from the server.
/// </summary>
public DateTime? DataTimeLocal { get; set; }
/// <summary>
/// The age of the data. Note that this is an estimation based on average delay from the server.
/// </summary>
public TimeSpan? DataAge => DateTime.UtcNow - DataTimeLocal;
/// <summary>
/// The stream producing the update
/// </summary>
@@ -43,6 +53,11 @@ namespace CryptoExchange.Net.Objects.Sockets
/// </summary>
public SocketUpdateType? UpdateType { get; set; }
/// <summary>
/// Sequence number of the update
/// </summary>
public long? SequenceNumber { get; set; }
/// <summary>
/// ctor
/// </summary>
@@ -116,12 +131,28 @@ namespace CryptoExchange.Net.Objects.Sockets
return this;
}
/// <summary>
/// Specify the sequence number of the update
/// </summary>
public DataEvent<T> WithSequenceNumber(long? sequenceNumber)
{
SequenceNumber = sequenceNumber;
return this;
}
/// <summary>
/// Specify the data timestamp
/// </summary>
public DataEvent<T> WithDataTimestamp(DateTime? timestamp)
public DataEvent<T> WithDataTimestamp(DateTime? timestamp, TimeSpan? offset)
{
if (timestamp == null || timestamp == default(DateTime))
return this;
DataTime = timestamp;
if (offset == null)
return this;
DataTimeLocal = DataTime + offset;
return this;
}
@@ -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
@@ -1,95 +0,0 @@
using System;
using System.Threading;
using Microsoft.Extensions.Logging;
namespace CryptoExchange.Net.Objects
{
/// <summary>
/// The time synchronization state of an API client
/// </summary>
public class TimeSyncState
{
/// <summary>
/// Name of the API
/// </summary>
public string ApiName { get; set; }
/// <summary>
/// Semaphore to use for checking the time syncing. Should be shared instance among the API client
/// </summary>
public SemaphoreSlim Semaphore { get; }
/// <summary>
/// Last sync time for the API client
/// </summary>
public DateTime LastSyncTime { get; set; }
/// <summary>
/// Time offset for the API client
/// </summary>
public TimeSpan TimeOffset { get; set; }
/// <summary>
/// ctor
/// </summary>
public TimeSyncState(string apiName)
{
ApiName = apiName;
Semaphore = new SemaphoreSlim(1, 1);
}
}
/// <summary>
/// Time synchronization info
/// </summary>
public class TimeSyncInfo
{
/// <summary>
/// Logger
/// </summary>
public ILogger Logger { get; }
/// <summary>
/// Should synchronize time
/// </summary>
public bool SyncTime { get; }
/// <summary>
/// Timestamp recalulcation interval
/// </summary>
public TimeSpan RecalculationInterval { get; }
/// <summary>
/// Time sync state for the API client
/// </summary>
public TimeSyncState TimeSyncState { get; }
/// <summary>
/// ctor
/// </summary>
/// <param name="logger"></param>
/// <param name="recalculationInterval"></param>
/// <param name="syncTime"></param>
/// <param name="syncState"></param>
public TimeSyncInfo(ILogger logger, bool syncTime, TimeSpan recalculationInterval, TimeSyncState syncState)
{
Logger = logger;
SyncTime = syncTime;
RecalculationInterval = recalculationInterval;
TimeSyncState = syncState;
}
/// <summary>
/// Set the time offset
/// </summary>
/// <param name="offset"></param>
public void UpdateTimeOffset(TimeSpan offset)
{
TimeSyncState.LastSyncTime = DateTime.UtcNow;
if (offset.TotalMilliseconds > 0 && offset.TotalMilliseconds < 500)
{
Logger.Log(LogLevel.Information, "{TimeSyncState.ApiName} Time offset within limits, set offset to 0ms", TimeSyncState.ApiName);
TimeSyncState.TimeOffset = TimeSpan.Zero;
}
else
{
Logger.Log(LogLevel.Information, "{TimeSyncState.ApiName} Time offset set to {Offset}ms", TimeSyncState.ApiName, Math.Round(offset.TotalMilliseconds));
TimeSyncState.TimeOffset = offset;
}
}
}
}
@@ -3,24 +3,28 @@ using System;
namespace CryptoExchange.Net.OrderBook
{
internal class ProcessQueueItem
internal class OrderBookUpdate
{
public long StartUpdateId { get; set; }
public long EndUpdateId { get; set; }
public DateTime? LocalDataTime { get; set; }
public DateTime? ServerDataTime { get; set; }
public long StartSequenceNumber { get; set; }
public long EndSequenceNumber { get; set; }
public ISymbolOrderBookEntry[] Bids { get; set; } = Array.Empty<ISymbolOrderBookEntry>();
public ISymbolOrderBookEntry[] Asks { get; set; } = Array.Empty<ISymbolOrderBookEntry>();
}
internal class InitialOrderBookItem
internal class OrderBookSnapshot
{
public long StartUpdateId { get; set; }
public long EndUpdateId { get; set; }
public DateTime? LocalDataTime { get; set; }
public DateTime? ServerDataTime { get; set; }
public long? SequenceNumber { get; set; }
public ISymbolOrderBookEntry[] Bids { get; set; } = Array.Empty<ISymbolOrderBookEntry>();
public ISymbolOrderBookEntry[] Asks { get; set; } = Array.Empty<ISymbolOrderBookEntry>();
}
internal class ChecksumItem
internal class OrderBookChecksum
{
public long? SequenceNumber { get; set; }
public int Checksum { get; set; }
}
}
+270 -64
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
@@ -38,6 +39,7 @@ namespace CryptoExchange.Net.OrderBook
private readonly AsyncResetEvent _queueEvent;
private readonly ConcurrentQueue<object> _processQueue;
private bool _validateChecksum;
private bool _firstUpdateAfterSnapshotDone;
private class EmptySymbolOrderBookEntry : ISymbolOrderBookEntry
{
@@ -49,6 +51,13 @@ namespace CryptoExchange.Net.OrderBook
private static readonly ISymbolOrderBookEntry _emptySymbolOrderBookEntry = new EmptySymbolOrderBookEntry();
private enum SequenceNumberResult
{
Skip,
Ok,
OutOfSync
}
/// <summary>
/// A buffer to store messages received before the initial book snapshot is processed. These messages
/// will be processed after the book snapshot is set. Any messages in this buffer with sequence numbers lower
@@ -76,7 +85,12 @@ namespace CryptoExchange.Net.OrderBook
/// the book will resynchronize as it is deemed out of sync
/// </summary>
protected bool _sequencesAreConsecutive;
/// <summary>
/// Whether the first update message after a snapshot may have overlapping sequence numbers instead of the snapshot sequence number + 1
/// </summary>
protected bool _skipSequenceCheckFirstUpdateAfterSnapshotSet;
/// <summary>
/// Whether levels should be strictly enforced. For example, when an order book has 25 levels and a new update comes in which pushes
/// the current level 25 ask out of the top 25, should the level 26 entry be removed from the book or does the server handle this
@@ -133,6 +147,15 @@ namespace CryptoExchange.Net.OrderBook
/// <inheritdoc/>
public DateTime UpdateTime { get; private set; }
/// <inheritdoc/>
public DateTime? UpdateServerTime { get; private set; }
/// <inheritdoc/>
public DateTime? UpdateLocalTime { get; set; }
/// <inheritdoc/>
public TimeSpan? DataAge => DateTime.UtcNow - UpdateLocalTime;
/// <inheritdoc/>
public int AskCount { get; private set; }
@@ -257,6 +280,7 @@ namespace CryptoExchange.Net.OrderBook
_processBuffer.Clear();
_bookSet = false;
_firstUpdateAfterSnapshotDone = false;
Status = OrderBookStatus.Connecting;
_processTask = Task.Factory.StartNew(ProcessQueue, TaskCreationOptions.LongRunning);
@@ -406,45 +430,91 @@ namespace CryptoExchange.Net.OrderBook
/// Implementation for validating a checksum value with the current order book. If checksum validation fails (returns false)
/// the order book will be resynchronized
/// </summary>
/// <param name="checksum"></param>
/// <returns></returns>
protected virtual bool DoChecksum(int checksum) => true;
/// <summary>
/// Set the initial data for the order book. Typically the snapshot which was requested from the Rest API, or the first snapshot
/// received from a socket subscription
/// Set snapshot data for the order book. Typically the snapshot which was requested from the Rest API, or the first snapshot
/// received from a socket subscription. Will clear any previous data.
/// </summary>
/// <param name="orderBookSequenceNumber">The last update sequence number until which the snapshot is in sync</param>
/// <param name="askList">List of asks</param>
/// <param name="bidList">List of bids</param>
protected void SetInitialOrderBook(long orderBookSequenceNumber, ISymbolOrderBookEntry[] bidList, ISymbolOrderBookEntry[] askList)
/// <param name="serverDataTime">Server data timestamp</param>
/// <param name="localDataTime">local data timestamp</param>
protected void SetSnapshot(
long? orderBookSequenceNumber,
ISymbolOrderBookEntry[] bidList,
ISymbolOrderBookEntry[] askList,
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
_processQueue.Enqueue(new InitialOrderBookItem { StartUpdateId = orderBookSequenceNumber, EndUpdateId = orderBookSequenceNumber, Asks = askList, Bids = bidList });
_processQueue.Enqueue(
new OrderBookSnapshot
{
LocalDataTime = localDataTime,
ServerDataTime = serverDataTime,
SequenceNumber = orderBookSequenceNumber,
Asks = askList,
Bids = bidList
});
_queueEvent.Set();
}
/// <summary>
/// Add an update to the process queue. Updates the book by providing changed bids and asks, along with an update number which should be higher than the previous update numbers
/// </summary>
/// <param name="updateId">The sequence number</param>
/// <param name="sequenceNumber">The sequence number</param>
/// <param name="bids">List of updated/new bids</param>
/// <param name="asks">List of updated/new asks</param>
protected void UpdateOrderBook(long updateId, ISymbolOrderBookEntry[] bids, ISymbolOrderBookEntry[] asks)
/// <param name="serverDataTime">Server data timestamp</param>
/// <param name="localDataTime">local data timestamp</param>
protected void UpdateOrderBook(
long sequenceNumber,
ISymbolOrderBookEntry[] bids,
ISymbolOrderBookEntry[] asks,
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
_processQueue.Enqueue(new ProcessQueueItem { StartUpdateId = updateId, EndUpdateId = updateId, Asks = asks, Bids = bids });
_processQueue.Enqueue(
new OrderBookUpdate
{
LocalDataTime = localDataTime,
ServerDataTime = serverDataTime,
StartSequenceNumber = sequenceNumber,
EndSequenceNumber = sequenceNumber,
Asks = asks,
Bids = bids
});
_queueEvent.Set();
}
/// <summary>
/// Add an update to the process queue. Updates the book by providing changed bids and asks, along with the first and last sequence number in the update
/// </summary>
/// <param name="firstUpdateId">The sequence number of the first update</param>
/// <param name="lastUpdateId">The sequence number of the last update</param>
/// <param name="firstSequenceNumber">The sequence number of the first update</param>
/// <param name="lastSequenceNumber">The sequence number of the last update</param>
/// <param name="bids">List of updated/new bids</param>
/// <param name="asks">List of updated/new asks</param>
protected void UpdateOrderBook(long firstUpdateId, long lastUpdateId, ISymbolOrderBookEntry[] bids, ISymbolOrderBookEntry[] asks)
/// <param name="serverDataTime">Server data timestamp</param>
/// <param name="localDataTime">local data timestamp</param>
protected void UpdateOrderBook(
long firstSequenceNumber,
long lastSequenceNumber,
ISymbolOrderBookEntry[] bids,
ISymbolOrderBookEntry[] asks,
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
_processQueue.Enqueue(new ProcessQueueItem { StartUpdateId = firstUpdateId, EndUpdateId = lastUpdateId, Asks = asks, Bids = bids });
_processQueue.Enqueue(
new OrderBookUpdate
{
LocalDataTime = localDataTime,
ServerDataTime = serverDataTime,
StartSequenceNumber = firstSequenceNumber,
EndSequenceNumber = lastSequenceNumber,
Asks = asks,
Bids = bids
});
_queueEvent.Set();
}
@@ -453,12 +523,27 @@ namespace CryptoExchange.Net.OrderBook
/// </summary>
/// <param name="bids">List of updated/new bids</param>
/// <param name="asks">List of updated/new asks</param>
protected void UpdateOrderBook(ISymbolOrderSequencedBookEntry[] bids, ISymbolOrderSequencedBookEntry[] asks)
/// <param name="serverDataTime">Server data timestamp</param>
/// <param name="localDataTime">local data timestamp</param>
protected void UpdateOrderBook(
ISymbolOrderSequencedBookEntry[] bids,
ISymbolOrderSequencedBookEntry[] asks,
DateTime? serverDataTime = null,
DateTime? localDataTime = null)
{
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);
_processQueue.Enqueue(new ProcessQueueItem { StartUpdateId = lowest, EndUpdateId = highest, Asks = asks, Bids = bids });
_processQueue.Enqueue(
new OrderBookUpdate
{
LocalDataTime = localDataTime,
ServerDataTime = serverDataTime,
StartSequenceNumber = lowest,
EndSequenceNumber = highest,
Asks = asks,
Bids = bids
});
_queueEvent.Set();
}
@@ -466,9 +551,10 @@ namespace CryptoExchange.Net.OrderBook
/// Add a checksum value to the process queue
/// </summary>
/// <param name="checksum">The checksum value</param>
protected void AddChecksum(int checksum)
/// <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)
{
_processQueue.Enqueue(new ChecksumItem() { Checksum = checksum });
_processQueue.Enqueue(new OrderBookChecksum() { Checksum = checksum, SequenceNumber = sequenceNumber });
_queueEvent.Set();
}
@@ -481,7 +567,12 @@ namespace CryptoExchange.Net.OrderBook
_logger.OrderBookProcessingBufferedUpdates(Api, Symbol, _processBuffer.Count);
foreach (var bufferEntry in _processBuffer)
ProcessRangeUpdates(bufferEntry.FirstUpdateId, bufferEntry.LastUpdateId, bufferEntry.Bids, bufferEntry.Asks);
{
if (_stopProcessing)
break;
ProcessUpdate(bufferEntry.FirstUpdateId, bufferEntry.LastUpdateId, bufferEntry.Bids, bufferEntry.Asks, true);
}
_processBuffer.Clear();
}
@@ -489,26 +580,10 @@ namespace CryptoExchange.Net.OrderBook
/// <summary>
/// Update order book with an entry
/// </summary>
/// <param name="sequence">Sequence number of the update</param>
/// <param name="type">Type of entry</param>
/// <param name="entry">The entry</param>
protected virtual bool ProcessUpdate(long sequence, OrderBookEntryType type, ISymbolOrderBookEntry entry)
protected virtual bool UpdateValue(OrderBookEntryType type, ISymbolOrderBookEntry entry)
{
if (sequence <= LastSequenceNumber)
{
_logger.OrderBookSkippedMessage(Api, Symbol, sequence, LastSequenceNumber);
return false;
}
if (_sequencesAreConsecutive && sequence > LastSequenceNumber + 1)
{
// Out of sync
_logger.OrderBookOutOfSync(Api, Symbol, LastSequenceNumber + 1, sequence);
_stopProcessing = true;
Resubscribe();
return false;
}
UpdateTime = DateTime.UtcNow;
var listToChange = type == OrderBookEntryType.Ask ? _asks : _bids;
if (entry.Quantity == 0)
@@ -565,6 +640,42 @@ namespace CryptoExchange.Net.OrderBook
return new CallResult<bool>(true);
}
/// <summary>
/// Wait until an update has been buffered
/// </summary>
/// <param name="minWait">Min wait time</param>
/// <param name="maxWait">Max wait time</param>
/// <param name="ct">Cancellation token</param>
/// <returns></returns>
protected async Task<CallResult> WaitUntilFirstUpdateBufferedAsync(TimeSpan? minWait, TimeSpan maxWait, CancellationToken ct)
{
var startWait = DateTime.UtcNow;
while (_processBuffer.Count == 0)
{
if (ct.IsCancellationRequested)
return new CallResult(new CancellationRequestedError());
if (DateTime.UtcNow - startWait > maxWait)
return new CallResult(new ServerError(new ErrorInfo(ErrorType.OrderBookTimeout, "Timeout while waiting for data")));
try
{
await Task.Delay(20, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{ }
}
if (minWait != null)
{
var dif = DateTime.UtcNow - startWait;
if (dif < minWait)
await Task.Delay(minWait.Value - dif).ConfigureAwait(false);
}
return CallResult.SuccessResult;
}
/// <summary>
/// IDisposable implementation for the order book
/// </summary>
@@ -614,6 +725,12 @@ namespace CryptoExchange.Net.OrderBook
{
var stringBuilder = new StringBuilder();
var book = Book;
stringBuilder.AppendLine($"{Exchange} - {Symbol}");
stringBuilder.AppendLine($"Update time local: {UpdateTime:HH:mm:ss.fff} ({Math.Round((DateTime.UtcNow - UpdateTime).TotalMilliseconds)}ms ago)");
stringBuilder.AppendLine($"Data timestamp server: {UpdateServerTime:HH:mm:ss.fff}");
stringBuilder.AppendLine($"Data timestamp local: {UpdateLocalTime:HH:mm:ss.fff}");
stringBuilder.AppendLine($"Data age: {DataAge?.TotalMilliseconds}ms");
stringBuilder.AppendLine();
stringBuilder.AppendLine($" Ask quantity Ask price | Bid price Bid quantity");
for(var i = 0; i < numberOfEntries; i++)
{
@@ -625,6 +742,22 @@ namespace CryptoExchange.Net.OrderBook
return stringBuilder.ToString();
}
/// <inheritdoc />
public Task OutputToConsoleAsync(int numberOfEntries, TimeSpan refreshInterval, CancellationToken ct = default)
{
return Task.Run(async () =>
{
var referenceTime = DateTime.UtcNow;
while (!ct.IsCancellationRequested)
{
Console.Clear();
Console.WriteLine(ToString(numberOfEntries));
var delay = Math.Max(1, (DateTime.UtcNow - referenceTime).TotalMilliseconds % refreshInterval.TotalMilliseconds);
try { await Task.Delay(refreshInterval.Add(TimeSpan.FromMilliseconds(-delay)), ct).ConfigureAwait(false); } catch { }
}
});
}
private void CheckBestOffersChanged(ISymbolOrderBookEntry prevBestBid, ISymbolOrderBookEntry prevBestAsk)
{
var (bestBid, bestAsk) = BestOffers;
@@ -641,8 +774,11 @@ namespace CryptoExchange.Net.OrderBook
// Clear queue
while (_processQueue.TryDequeue(out _)) { }
LastSequenceNumber = 0;
_processBuffer.Clear();
_bookSet = false;
_firstUpdateAfterSnapshotDone = false;
DoReset();
}
@@ -680,17 +816,17 @@ namespace CryptoExchange.Net.OrderBook
continue;
}
if (item is InitialOrderBookItem iobi)
ProcessInitialOrderBookItem(iobi);
if (item is ProcessQueueItem pqi)
ProcessQueueItem(pqi);
else if (item is ChecksumItem ci)
ProcessChecksum(ci);
if (item is OrderBookSnapshot snapshot)
ProcessOrderBookSnapshot(snapshot);
if (item is OrderBookUpdate update)
ProcessQueueItem(update);
else if (item is OrderBookChecksum checksum)
ProcessChecksum(checksum);
}
}
}
private void ProcessInitialOrderBookItem(InitialOrderBookItem item)
private void ProcessOrderBookSnapshot(OrderBookSnapshot item)
{
lock (_bookLock)
{
@@ -702,20 +838,25 @@ namespace CryptoExchange.Net.OrderBook
foreach (var bid in item.Bids)
_bids.Add(bid.Price, bid);
LastSequenceNumber = item.EndUpdateId;
if (item.SequenceNumber != null)
LastSequenceNumber = item.SequenceNumber.Value;
AskCount = _asks.Count;
BidCount = _bids.Count;
UpdateTime = DateTime.UtcNow;
_logger.OrderBookDataSet(Api, Symbol, BidCount, AskCount, item.EndUpdateId);
UpdateServerTime = item.ServerDataTime;
UpdateLocalTime = item.LocalDataTime;
_logger.OrderBookDataSet(Api, Symbol, BidCount, AskCount, item.SequenceNumber);
CheckProcessBuffer();
OnOrderBookUpdate?.Invoke((item.Bids.ToArray(), item.Asks.ToArray()));
OnBestOffersChanged?.Invoke((BestBid, BestAsk));
}
}
private void ProcessQueueItem(ProcessQueueItem item)
private void ProcessQueueItem(OrderBookUpdate item)
{
lock (_bookLock)
{
@@ -725,19 +866,19 @@ namespace CryptoExchange.Net.OrderBook
{
Asks = item.Asks,
Bids = item.Bids,
FirstUpdateId = item.StartUpdateId,
LastUpdateId = item.EndUpdateId,
FirstUpdateId = item.StartSequenceNumber,
LastUpdateId = item.EndSequenceNumber,
});
if (_logger.IsEnabled(LogLevel.Trace))
_logger.OrderBookUpdateBuffered(Api, Symbol, item.StartUpdateId, item.EndUpdateId, item.Asks.Length, item.Bids.Length);
_logger.OrderBookUpdateBuffered(Api, Symbol, item.StartSequenceNumber, item.EndSequenceNumber, item.Asks.Length, item.Bids.Length);
}
else
{
CheckProcessBuffer();
var (prevBestBid, prevBestAsk) = BestOffers;
ProcessRangeUpdates(item.StartUpdateId, item.EndUpdateId, item.Bids, item.Asks);
ProcessUpdate(item.StartSequenceNumber, item.EndSequenceNumber, item.Bids, item.Asks, false);
if (_asks.Count == 0 || _bids.Count == 0)
return;
@@ -750,13 +891,16 @@ namespace CryptoExchange.Net.OrderBook
return;
}
UpdateServerTime = item.ServerDataTime;
UpdateLocalTime = item.LocalDataTime;
OnOrderBookUpdate?.Invoke((item.Bids.ToArray(), item.Asks.ToArray()));
CheckBestOffersChanged(prevBestBid, prevBestAsk);
}
}
}
private void ProcessChecksum(ChecksumItem ci)
private void ProcessChecksum(OrderBookChecksum ci)
{
lock (_bookLock)
{
@@ -776,6 +920,9 @@ namespace CryptoExchange.Net.OrderBook
throw;
}
if (ci.SequenceNumber != null)
LastSequenceNumber = ci.SequenceNumber.Value;
if (!checksumResult)
{
_logger.OrderBookOutOfSyncChecksum(Api, Symbol);
@@ -813,40 +960,99 @@ namespace CryptoExchange.Net.OrderBook
});
}
private void ProcessRangeUpdates(long firstUpdateId, long lastUpdateId, IEnumerable<ISymbolOrderBookEntry> bids, IEnumerable<ISymbolOrderBookEntry> asks)
private void ProcessUpdate(
long updateSequenceNumberStart,
long updateSequenceNumberEnd,
IEnumerable<ISymbolOrderBookEntry> bids,
IEnumerable<ISymbolOrderBookEntry> asks,
bool fromBuffer)
{
if (lastUpdateId <= LastSequenceNumber)
var sequenceResult = fromBuffer ? ValidateBufferSequenceNumber(updateSequenceNumberStart, updateSequenceNumberEnd) : ValidateLiveSequenceNumber(updateSequenceNumberStart);
if (sequenceResult == SequenceNumberResult.Skip)
{
_logger.OrderBookUpdateSkipped(Api, Symbol, lastUpdateId, LastSequenceNumber);
if (updateSequenceNumberStart != updateSequenceNumberEnd)
_logger.OrderBookUpdateSkipped(Api, Symbol, updateSequenceNumberStart, updateSequenceNumberEnd, LastSequenceNumber);
else
_logger.OrderBookUpdateSkipped(Api, Symbol, updateSequenceNumberStart, LastSequenceNumber);
return;
}
if (sequenceResult == SequenceNumberResult.OutOfSync)
{
_logger.OrderBookOutOfSync(Api, Symbol, LastSequenceNumber + 1, updateSequenceNumberStart);
_stopProcessing = true;
Resubscribe();
return;
}
foreach (var entry in bids)
ProcessUpdate(LastSequenceNumber + 1, OrderBookEntryType.Bid, entry);
UpdateValue(OrderBookEntryType.Bid, entry);
foreach (var entry in asks)
ProcessUpdate(LastSequenceNumber + 1, OrderBookEntryType.Ask, entry);
UpdateValue(OrderBookEntryType.Ask, entry);
if (Levels.HasValue && _strictLevels)
{
while (this._bids.Count > Levels.Value)
while (_bids.Count > Levels.Value)
{
BidCount--;
this._bids.Remove(this._bids.Last().Key);
_bids.Remove(_bids.Last().Key);
}
while (this._asks.Count > Levels.Value)
while (_asks.Count > Levels.Value)
{
AskCount--;
this._asks.Remove(this._asks.Last().Key);
_asks.Remove(this._asks.Last().Key);
}
}
LastSequenceNumber = lastUpdateId;
_firstUpdateAfterSnapshotDone = true;
LastSequenceNumber = updateSequenceNumberEnd;
if (_logger.IsEnabled(LogLevel.Trace))
_logger.OrderBookProcessedMessage(Api, Symbol, firstUpdateId, lastUpdateId);
}
{
if (updateSequenceNumberStart != updateSequenceNumberEnd)
_logger.OrderBookProcessedMessage(Api, Symbol, updateSequenceNumberStart, updateSequenceNumberEnd);
else
_logger.OrderBookProcessedMessage(Api, Symbol, updateSequenceNumberStart);
}
}
private SequenceNumberResult ValidateBufferSequenceNumber(long startSequenceNumber, long endSequenceNumber)
{
if (endSequenceNumber <= LastSequenceNumber)
// Buffered update is from before the snapshot, ignore
return SequenceNumberResult.Skip;
if (_sequencesAreConsecutive && startSequenceNumber != LastSequenceNumber + 1)
{
if (_firstUpdateAfterSnapshotDone || !_skipSequenceCheckFirstUpdateAfterSnapshotSet)
// Buffered update is not the next sequence number when it was expected to be
return SequenceNumberResult.OutOfSync;
}
// Buffered sequence number is larger than the last sequence number
return SequenceNumberResult.Ok;
}
private SequenceNumberResult ValidateLiveSequenceNumber(long sequenceNumber)
{
if (sequenceNumber < LastSequenceNumber
&& (_firstUpdateAfterSnapshotDone || !_skipSequenceCheckFirstUpdateAfterSnapshotSet))
// Update is somehow from before the current state
return SequenceNumberResult.OutOfSync;
if (_sequencesAreConsecutive
&& LastSequenceNumber != 0
&& sequenceNumber != LastSequenceNumber + 1)
{
if (_firstUpdateAfterSnapshotDone || !_skipSequenceCheckFirstUpdateAfterSnapshotSet)
return SequenceNumberResult.OutOfSync;
}
return SequenceNumberResult.Ok;
}
}
internal class DescComparer<T> : IComparer<T>
@@ -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>
@@ -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;
@@ -95,9 +94,6 @@ namespace CryptoExchange.Net.Sockets.Default
/// <inheritdoc />
public event Func<Task>? OnClose;
/// <inheritdoc />
public event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task>? OnStreamMessage;
/// <inheritdoc />
public event Func<int, Task>? OnRequestSent;
@@ -139,10 +135,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 +218,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 +266,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 +486,6 @@ namespace CryptoExchange.Net.Sockets.Default
_disposed = true;
_socket.Dispose();
_ctsSource?.Dispose();
_sendEvent.Dispose();
_logger.SocketDisposed(Id);
}
@@ -578,6 +571,7 @@ namespace CryptoExchange.Net.Sockets.Default
}
}
#if NETSTANDARD2_0
/// <summary>
/// Loop for receiving and reassembling data
/// </summary>
@@ -627,6 +621,7 @@ namespace CryptoExchange.Net.Sockets.Default
break;
}
LastReceiveTime = DateTime.UtcNow;
if (receiveResult.MessageType == WebSocketMessageType.Close)
{
// Connection closed
@@ -666,10 +661,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 +695,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 +720,7 @@ namespace CryptoExchange.Net.Sockets.Default
_logger.SocketReceiveLoopFinished(Id);
}
}
#endif
#if !NETSTANDARD2_0
/// <summary>
@@ -783,6 +772,7 @@ namespace CryptoExchange.Net.Sockets.Default
break;
}
LastReceiveTime = DateTime.UtcNow;
if (receiveResult.MessageType == WebSocketMessageType.Close)
{
// Connection closed
@@ -891,22 +881,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 +891,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 +899,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>
@@ -257,6 +257,7 @@ namespace CryptoExchange.Net.Sockets.Default
}
}
private bool _pausedActivity;
#if NET9_0_OR_GREATER
private readonly Lock _listenersLock = new Lock();
@@ -268,12 +269,12 @@ namespace CryptoExchange.Net.Sockets.Default
private SocketStatus _status;
private readonly IMessageSerializer _serializer;
private IByteMessageAccessor? _stringMessageAccessor;
private IByteMessageAccessor? _byteMessageAccessor;
private ISocketMessageHandler? _byteMessageConverter;
private ISocketMessageHandler? _textMessageConverter;
private long _lastSequenceNumber;
/// <summary>
/// The task that is sending periodic data on the websocket. Can be used for sending Ping messages every x seconds or similar. Not necessary.
/// </summary>
@@ -288,12 +289,7 @@ 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
/// </summary>
@@ -307,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;
@@ -340,6 +335,7 @@ namespace CryptoExchange.Net.Sockets.Default
{
Status = SocketStatus.Closed;
Authenticated = false;
_lastSequenceNumber = 0;
if (ApiClient._socketConnections.ContainsKey(SocketId))
ApiClient._socketConnections.TryRemove(SocketId, out _);
@@ -371,6 +367,7 @@ namespace CryptoExchange.Net.Sockets.Default
Status = SocketStatus.Reconnecting;
DisconnectTime = DateTime.UtcNow;
Authenticated = false;
_lastSequenceNumber = 0;
lock (_listenersLock)
{
@@ -566,8 +563,12 @@ namespace CryptoExchange.Net.Sockets.Default
if (deserializationType == null)
{
// No handler found for identifier either, can't process
_logger.LogWarning("Failed to determine message type for identifier {Identifier}. Data: {Message}", typeIdentifier, Encoding.UTF8.GetString(data.ToArray()));
if (!ApiClient.HandleUnhandledMessage(this, typeIdentifier, data))
{
// No handler found for identifier either, can't process
_logger.LogWarning("Failed to determine message type for identifier {Identifier}. Data: {Message}", typeIdentifier, Encoding.UTF8.GetString(data.ToArray()));
}
return;
}
@@ -636,12 +637,12 @@ namespace CryptoExchange.Net.Sockets.Default
|| route.TopicFilter == null
|| route.TopicFilter.Equals(topicFilter, StringComparison.Ordinal))
{
processed = true;
if (isQuery && query!.Completed)
continue;
processed = true;
processor.Handle(this, receiveTime, originalData, result, route);
if (isQuery && !route.MultipleReaders)
{
complete = true;
@@ -657,146 +658,11 @@ 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)
{
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))
{
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, topicFilter!,
string.Join(",", _listeners.Select(x => string.Join(",", x.MessageRouter.Routes.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();
}
}
@@ -878,7 +744,7 @@ namespace CryptoExchange.Net.Sockets.Default
bool anyDuplicateSubscription;
lock (_listenersLock)
anyDuplicateSubscription = _listeners.OfType<Subscription>().Any(x => x != subscription && x.MessageMatcher.HandlerLinks.All(l => subscription.MessageMatcher.ContainsCheck(l)));
anyDuplicateSubscription = _listeners.OfType<Subscription>().Any(x => x != subscription && x.MessageRouter.Routes.All(l => subscription.MessageRouter.ContainsCheck(l)));
bool shouldCloseConnection;
lock (_listenersLock)
@@ -926,16 +792,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>
@@ -1228,10 +1087,17 @@ namespace CryptoExchange.Net.Sockets.Default
subQuery.OnComplete = () =>
{
subscription.Status = subQuery.Result!.Success ? SubscriptionStatus.Subscribed : SubscriptionStatus.Pending;
subscription.HandleSubQueryResponse(subQuery.Response);
subscription.HandleSubQueryResponse(this, subQuery.Response);
};
taskList.Add(SendAndWaitQueryAsync(subQuery));
if (!subQuery.ExpectsResponse)
{
// If there won't be an answer we can immediately set this
subscription.Status = SubscriptionStatus.Subscribed;
subscription.HandleSubQueryResponse(this, null);
}
}
await Task.WhenAll(taskList).ConfigureAwait(false);
@@ -1255,6 +1121,7 @@ namespace CryptoExchange.Net.Sockets.Default
return;
await SendAndWaitQueryAsync(unsubscribeRequest).ConfigureAwait(false);
subscription.HandleUnsubQueryResponse(this, unsubscribeRequest.Response);
_logger.SubscriptionUnsubscribed(SocketId, subscription.Id);
}
@@ -1268,10 +1135,28 @@ namespace CryptoExchange.Net.Sockets.Default
return CallResult.SuccessResult;
var result = await SendAndWaitQueryAsync(subQuery).ConfigureAwait(false);
subscription.HandleSubQueryResponse(subQuery.Response!);
subscription.HandleSubQueryResponse(this, subQuery.Response);
return result;
}
/// <summary>
/// Update the sequence number for this connection
/// </summary>
public void UpdateSequenceNumber(long sequenceNumber)
{
if (ApiClient.EnforceSequenceNumbers
&& _lastSequenceNumber != 0 // Initial value is 0
&& _lastSequenceNumber != sequenceNumber // When there are multiple listeners for the same message it's possible this gets recorded multiple times, shouldn't be an issue
&& _lastSequenceNumber + 1 != sequenceNumber) // Expected value
{
// Not sequential
_logger.LogWarning("[Sckt {SocketId}] update not in sequence. Last recorded sequence number: {LastSequence}, update sequence number: {UpdateSequence}. Reconnecting", SocketId, _lastSequenceNumber, sequenceNumber);
_ = TriggerReconnectAsync();
}
_lastSequenceNumber = sequenceNumber;
}
/// <summary>
/// Periodically sends data over a socket connection
/// </summary>
@@ -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>
@@ -149,14 +144,12 @@ namespace CryptoExchange.Net.Sockets.Default
/// <summary>
/// Handle a subscription query response
/// </summary>
/// <param name="message"></param>
public virtual void HandleSubQueryResponse(object? message) { }
public virtual void HandleSubQueryResponse(SocketConnection connection, object? message) { }
/// <summary>
/// Handle an unsubscription query response
/// </summary>
/// <param name="message"></param>
public virtual void HandleUnsubQueryResponse(object message) { }
public virtual void HandleUnsubQueryResponse(SocketConnection connection, object? message) { }
/// <summary>
/// Create a new unsubscription query
@@ -174,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>
@@ -226,12 +206,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>
@@ -240,7 +220,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);
}
}
}
-42
View File
@@ -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>
@@ -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,35 +210,6 @@ 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()
{
@@ -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)
+9 -3
View File
@@ -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);
+173
View File
@@ -0,0 +1,173 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net
{
/// <summary>
/// Manager for timing offsets in APIs
/// </summary>
public static class TimeOffsetManager
{
class SocketTimeOffset
{
private DateTime _lastRollOver = DateTime.UtcNow;
private double? _fallbackLowest;
private double? _currentLowestOffset;
/// <summary>
/// Get the estimated offset, resolves to the lowest offset in time measured in the last two minutes
/// </summary>
public double? Offset
{
get
{
if (_currentLowestOffset == null)
// If there is no current lowest offset return the fallback (which might or might not be null)
return _fallbackLowest;
if (_fallbackLowest == null)
// If there is no fallback return the current lowest offset
return _currentLowestOffset;
// If there is both a fallback and a current offset return the min offset of those
return Math.Min(_currentLowestOffset.Value, _fallbackLowest.Value);
}
}
public void Update(double offsetMs)
{
if (_currentLowestOffset == null || _currentLowestOffset > offsetMs)
{
_currentLowestOffset = offsetMs;
_fallbackLowest = offsetMs;
}
if (DateTime.UtcNow - _lastRollOver > TimeSpan.FromMinutes(1))
{
_fallbackLowest = _currentLowestOffset;
_currentLowestOffset = null;
_lastRollOver = DateTime.UtcNow;
}
}
}
class RestTimeOffset
{
public SemaphoreSlim SemaphoreSlim { get; } = new SemaphoreSlim(1, 1);
public DateTime? LastUpdate { get; set; }
public double? Offset { get; set; }
public void Update(double offsetMs)
{
LastUpdate = DateTime.UtcNow;
Offset = offsetMs;
}
}
private static ConcurrentDictionary<string, SocketTimeOffset> _lastSocketDelays = new ConcurrentDictionary<string, SocketTimeOffset>();
private static ConcurrentDictionary<string, RestTimeOffset> _lastRestDelays = new ConcurrentDictionary<string, RestTimeOffset>();
/// <summary>
/// Update WebSocket API offset
/// </summary>
/// <param name="api">API name</param>
/// <param name="offsetMs">Offset in milliseconds</param>
public static void UpdateSocketOffset(string api, double offsetMs)
{
if (!_lastSocketDelays.TryGetValue(api, out var offsetValues))
{
offsetValues = new SocketTimeOffset();
_lastSocketDelays.TryAdd(api, offsetValues);
}
_lastSocketDelays[api].Update(offsetMs);
}
/// <summary>
/// Update REST API offset
/// </summary>
/// <param name="api">API name</param>
/// <param name="offsetMs">Offset in milliseconds</param>
public static void UpdateRestOffset(string api, double offsetMs)
{
_lastRestDelays[api].Update(offsetMs);
}
/// <summary>
/// Get REST API offset
/// </summary>
/// <param name="api">API name</param>
public static TimeSpan? GetRestOffset(string api) => _lastRestDelays.TryGetValue(api, out var val) && val.Offset != null ? TimeSpan.FromMilliseconds(val.Offset.Value) : null;
/// <summary>
/// Get REST API last update time
/// </summary>
/// <param name="api">API name</param>
public static DateTime? GetRestLastUpdateTime(string api) => _lastRestDelays.TryGetValue(api, out var val) && val.LastUpdate != null ? val.LastUpdate.Value : null;
/// <summary>
/// Register a REST API client to be tracked
/// </summary>
/// <param name="api"></param>
internal static void RegisterRestApi(string api)
{
if (!_lastRestDelays.ContainsKey(api))
_lastRestDelays.TryAdd(api, new RestTimeOffset());
}
/// <summary>
/// Enter exclusive access for the API to update the time offset
/// </summary>
/// <param name="api"></param>
/// <returns></returns>
public static async ValueTask EnterAsync(string api)
{
await _lastRestDelays[api].SemaphoreSlim.WaitAsync().ConfigureAwait(false);
}
/// <summary>
/// Release exclusive access for the API
/// </summary>
/// <param name="api"></param>
public static void Release(string api) => _lastRestDelays[api].SemaphoreSlim.Release();
/// <summary>
/// Get WebSocket API offset
/// </summary>
/// <param name="api">API name</param>
public static TimeSpan? GetSocketOffset(string api) => _lastSocketDelays.TryGetValue(api, out var val) && val.Offset != null ? TimeSpan.FromMilliseconds(val.Offset.Value) : null;
/// <summary>
/// Get a dictionary of API/Client name -> time offset for Rest api's
/// </summary>
public static Dictionary<string, TimeSpan?> GetRestOffsets()
{
return _lastRestDelays.ToDictionary(
x => x.Key,
x => (x.Value.Offset == null ? (TimeSpan?)null : TimeSpan.FromMilliseconds(x.Value.Offset.Value)));
}
/// <summary>
/// Get a dictionary of API/Client name -> time offset for Websocket api's
/// </summary>
public static Dictionary<string, TimeSpan?> GetWebsocketOffsets()
{
return _lastSocketDelays.ToDictionary(
x => x.Key,
x => (x.Value.Offset == null ? (TimeSpan?)null : TimeSpan.FromMilliseconds(x.Value.Offset.Value)));
}
/// <summary>
/// Reset the WebSocket API update timestamp to trigger a new time offset calculation
/// </summary>
/// <param name="api">API name</param>
public static void ResetRestUpdateTime(string api)
{
_lastRestDelays[api].LastUpdate = null;
}
}
}
@@ -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,41 @@
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>
/// Currently tracked symbols. Data for these symbols will be requested when polling.
/// Websocket updates will be available for all symbols regardless.
/// When new data is received for a symbol which is not yet being tracked it will be added to this list and polled in the future unless the `OnlyTrackProvidedSymbols` option is set in the configuration.
/// </summary>
IEnumerable<SharedSymbol> TrackedSymbols { get; }
/// <summary>
/// On connection status change. Might trigger multiple times with the same status depending on the underlying subscriptions.
/// </summary>
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,61 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using System;
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>
/// 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();
}
}
@@ -0,0 +1,57 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.SharedApis;
using CryptoExchange.Net.Trackers.UserData.Objects;
using System;
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>
/// 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();
}
}
@@ -0,0 +1,94 @@
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,
IBalanceRestClient restClient,
IBalanceSocketClient? socketClient,
SharedAccountType accountType,
TrackerItemConfig config,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Balances, restClient.Exchange, config, false, null)
{
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,298 @@
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;
internal event Func<UpdateSource, SharedUserTrade[], Task>? OnTradeUpdate;
/// <summary>
/// ctor
/// </summary>
public FuturesOrderTracker(
ILogger logger,
IFuturesOrderRestClient restClient,
IFuturesOrderSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Orders, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
_requiresSymbolParameterOpenOrders = restClient.GetOpenFuturesOrdersOptions.RequiredOptionalParameters.Any(x => x.Name == "Symbol");
}
/// <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)
// 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 _symbols.ToList())
{
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);
}
}
}
foreach (var symbol in _symbols.ToList())
{
var fromTimeOrders = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
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
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
// Filter orders to only include where close time is after the start time
var relevantOrders = closedOrdersResult.Data.Where(x =>
x.UpdateTime != null && x.UpdateTime >= _startTime // Updated after the tracker start time
|| x.CreateTime != null && x.CreateTime >= _startTime // Created after the tracker start time
|| x.CreateTime == null && x.UpdateTime == null // Unknown time
).ToArray();
// Check for orders which are no longer returned in either open/closed and assume they're canceled without fill
var openOrdersNotReturned = Values.Where(x =>
x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset // Orders for the same symbol
&& x.QuantityFilled?.IsZero == true // With no filled value
&& !openOrders.Any(r => r.OrderId == x.OrderId) // Not returned in open orders
&& !relevantOrders.Any(r => r.OrderId == x.OrderId) // Not return in closed orders
).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);
}
}
return anyError;
}
}
}
@@ -0,0 +1,98 @@
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,
IFuturesOrderRestClient restClient,
IUserTradeSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Trades, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
}
/// <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;
foreach (var symbol in _symbols)
{
var fromTimeTrades = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
var tradesResult = await _restClient.GetFuturesUserTradesAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!tradesResult.Success)
{
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);
}
}
return anyError;
}
/// <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,
IFuturesOrderRestClient restClient,
IPositionSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
bool websocketPositionUpdatesAreFullSnapshots,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Positions, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
{
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);
}
else if (_onlyTrackProvidedSymbols
&& !_symbols.Any(y => y.TradingMode == symbolModel.SharedSymbol!.TradingMode && y.BaseAsset == symbolModel.SharedSymbol.BaseAsset && y.QuoteAsset == symbolModel.SharedSymbol.QuoteAsset))
{
toRemove ??= new List<SharedPosition>();
toRemove.Add(item);
}
}
}
if (toRemove != null)
@event = @event.Except(toRemove).ToArray();
if (!_onlyTrackProvidedSymbols)
UpdateSymbolsList(@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,312 @@
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;
internal event Func<UpdateSource, SharedUserTrade[], Task>? OnTradeUpdate;
/// <summary>
/// ctor
/// </summary>
public SpotOrderTracker(
ILogger logger,
ISpotOrderRestClient restClient,
ISpotOrderSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Orders, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
_requiresSymbolParameterOpenOrders = restClient.GetOpenSpotOrdersOptions.RequiredOptionalParameters.Any(x => x.Name == "Symbol");
}
/// <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)
// 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 _symbols.ToList())
{
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;
foreach (var symbol in _symbols.ToList())
{
var fromTimeOrders = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
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
{
_lastDataTimeBeforeDisconnect = null;
_lastPollTime = updatedPollTime;
// Filter orders to only include where close time is after the start time
var relevantOrders = closedOrdersResult.Data.Where(x =>
x.UpdateTime != null && x.UpdateTime >= _startTime // Updated after the tracker start time
|| x.CreateTime != null && x.CreateTime >= _startTime // Created after the tracker start time
|| x.CreateTime == null && x.UpdateTime == null // Unknown time
).ToArray();
// Check for orders which are no longer returned in either open/closed and assume they're canceled without fill
var openOrdersNotReturned = Values.Where(x =>
x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset // Orders for the same symbol
&& x.QuantityFilled?.IsZero == true // With no filled value
&& !openOrders.Any(r => r.OrderId == x.OrderId) // Not returned in open orders
&& !relevantOrders.Any(r => r.OrderId == x.OrderId) // Not return in closed orders
).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);
}
}
return anyError;
}
}
}
@@ -0,0 +1,98 @@
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,
ISpotOrderRestClient restClient,
IUserTradeSocketClient? socketClient,
TrackerItemConfig config,
IEnumerable<SharedSymbol> symbols,
bool onlyTrackProvidedSymbols,
ExchangeParameters? exchangeParameters = null
) : base(logger, UserDataType.Trades, restClient.Exchange, config, onlyTrackProvidedSymbols, symbols)
{
if (_socketClient == null)
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
_restClient = restClient;
_socketClient = socketClient;
_exchangeParameters = exchangeParameters;
}
/// <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;
foreach (var symbol in _symbols)
{
var fromTimeTrades = _lastDataTimeBeforeDisconnect ?? _lastPollTime ?? _startTime;
var updatedPollTime = DateTime.UtcNow;
var tradesResult = await _restClient.GetSpotUserTradesAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!tradesResult.Success)
{
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);
}
}
return anyError;
}
/// <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,533 @@
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>
/// Tracked symbols list
/// </summary>
protected readonly List<SharedSymbol> _symbols;
/// <summary>
/// Symbol lock
/// </summary>
protected object _symbolLock = new object();
/// <summary>
/// Only track provided symbols setting
/// </summary>
protected bool _onlyTrackProvidedSymbols;
/// <summary>
/// Is SharedSymbol model
/// </summary>
protected bool _isSymbolModel;
/// <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;
/// <inheritdoc />
public IEnumerable<SharedSymbol> TrackedSymbols => _symbols;
/// <summary>
/// ctor
/// </summary>
public UserDataItemTracker(ILogger logger, UserDataType dataType, string exchange, TrackerItemConfig config, bool onlyTrackProvidedSymbols, IEnumerable<SharedSymbol>? symbols) : base(logger, dataType, exchange)
{
_onlyTrackProvidedSymbols = onlyTrackProvidedSymbols;
_symbols = symbols?.ToList() ?? [];
_pollIntervalDisconnected = config.PollIntervalDisconnected;
_pollIntervalConnected = config.PollIntervalConnected;
_pollAtStart = config.PollAtStart;
_retentionTime = config is TrackerTimedItemConfig timeConfig ? timeConfig.RetentionTime : TimeSpan.MaxValue;
_isSymbolModel = typeof(T).IsSubclassOf(typeof(SharedSymbolModel));
}
/// <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>
/// Update the tracked symbol list with potential new symbols
/// </summary>
/// <param name="symbols"></param>
protected void UpdateSymbolsList(IEnumerable<SharedSymbol> symbols)
{
lock (_symbolLock)
{
foreach (var symbol in symbols.Distinct())
{
if (!_symbols.Any(x => x.TradingMode == symbol.TradingMode && x.BaseAsset == symbol.BaseAsset && x.QuoteAsset == symbol.QuoteAsset))
{
_symbols.Add(symbol);
_logger.LogDebug("Adding {BaseAsset}/{QuoteAsset} to symbol tracking list", symbol.BaseAsset, symbol.QuoteAsset);
}
}
}
}
/// <summary>
/// Handle an update
/// </summary>
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);
}
else if (_onlyTrackProvidedSymbols
&& !_symbols.Any(y => y.TradingMode == symbolModel.SharedSymbol!.TradingMode && y.BaseAsset == symbolModel.SharedSymbol.BaseAsset && y.QuoteAsset == symbolModel.SharedSymbol.QuoteAsset))
{
toRemove ??= new List<T>();
toRemove.Add(item);
}
}
}
if (toRemove != null)
@event = @event.Except(toRemove).ToArray();
if (!_onlyTrackProvidedSymbols)
UpdateSymbolsList(@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,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,111 @@
using CryptoExchange.Net.Objects;
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.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>
/// List of data trackers
/// </summary>
protected abstract UserDataItemTracker[] DataTrackers { 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>
/// 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;
Exchange = exchange;
UserIdentifier = userIdentifier;
}
/// <summary>
/// Start the data tracker
/// </summary>
public async Task<CallResult> StartAsync()
{
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");
var tasks = new List<Task>();
foreach (var dataTracker in DataTrackers)
tasks.Add(dataTracker.StopAsync());
await Task.WhenAll(tasks).ConfigureAwait(false);
_logger.LogDebug("Stopped UserDataTracker");
}
}
}
@@ -0,0 +1,124 @@
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;
/// <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;
var trackers = new List<UserDataItemTracker>();
var balanceAccountType = accountType ?? SharedAccountType.PerpetualLinearFutures;
var balanceTracker = new BalanceTracker(logger, balanceRestClient, balanceSocketClient, balanceAccountType, config.BalancesConfig, exchangeParameters);
Balances = balanceTracker;
trackers.Add(balanceTracker);
var orderTracker = new FuturesOrderTracker(logger, futuresOrderRestClient, futuresOrderSocketClient, config.OrdersConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Orders = orderTracker;
trackers.Add(orderTracker);
var positionTracker = new PositionTracker(logger, futuresOrderRestClient, positionSocketClient, config.PositionConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, WebsocketPositionUpdatesAreFullSnapshots, exchangeParameters);
Positions = positionTracker;
trackers.Add(positionTracker);
if (config.TrackTrades)
{
var tradeTracker = new FuturesUserTradeTracker(logger, 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(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(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!lkResult)
{
_logger.LogWarning("Failed to start UserFuturesDataTracker; listen key request failed: {Error}", lkResult.Error);
return lkResult;
}
_listenKey = lkResult.Data;
}
return CallResult.SuccessResult;
}
}
}
@@ -0,0 +1,100 @@
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;
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;
/// <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, balanceRestClient, balanceSocketClient, SharedAccountType.Spot, config.BalancesConfig, exchangeParameters);
Balances = balanceTracker;
trackers.Add(balanceTracker);
var orderTracker = new SpotOrderTracker(logger, spotOrderRestClient, spotOrderSocketClient, config.OrdersConfig, config.TrackedSymbols, config.OnlyTrackProvidedSymbols, exchangeParameters);
Orders = orderTracker;
trackers.Add(orderTracker);
if (config.TrackTrades)
{
var tradeTracker = new SpotUserTradeTracker(logger, 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;
}
_listenKey = lkResult.Data;
}
return CallResult.SuccessResult;
}
}
}
+26 -26
View File
@@ -5,32 +5,32 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Binance.Net" Version="11.10.0" />
<PackageReference Include="Bitfinex.Net" Version="9.10.0" />
<PackageReference Include="BitMart.Net" Version="2.11.0" />
<PackageReference Include="BloFin.Net" Version="1.3.0" />
<PackageReference Include="Bybit.Net" Version="5.12.0" />
<PackageReference Include="CoinEx.Net" Version="9.10.0" />
<PackageReference Include="CoinW.Net" Version="1.7.0" />
<PackageReference Include="CryptoCom.Net" Version="2.11.0" />
<PackageReference Include="DeepCoin.Net" Version="2.10.0" />
<PackageReference Include="GateIo.Net" Version="2.12.0" />
<PackageReference Include="HyperLiquid.Net" Version="2.16.0" />
<PackageReference Include="JK.BingX.Net" Version="2.10.0" />
<PackageReference Include="JK.Bitget.Net" Version="2.11.0" />
<PackageReference Include="JK.Mexc.Net" Version="3.11.0" />
<PackageReference Include="JK.OKX.Net" Version="3.10.0" />
<PackageReference Include="Jkorf.Aster.Net" Version="1.2.0" />
<PackageReference Include="JKorf.BitMEX.Net" Version="2.10.0" />
<PackageReference Include="JKorf.Coinbase.Net" Version="2.10.0" />
<PackageReference Include="JKorf.HTX.Net" Version="7.10.0" />
<PackageReference Include="JKorf.Upbit.Net" Version="1.1.0" />
<PackageReference Include="KrakenExchange.Net" Version="6.10.0" />
<PackageReference Include="Kucoin.Net" Version="7.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Toobit.Net" Version="1.9.0" />
<PackageReference Include="WhiteBit.Net" Version="2.11.0" />
<PackageReference Include="XT.Net" Version="2.10.0" />
<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="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" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -50,7 +50,7 @@
binanceSocketClient.SpotApi.ExchangeData.SubscribeToTickerUpdatesAsync("ETHBTC", data => UpdateData("Binance", data.Data.LastPrice)),
bingXSocketClient.SpotApi.SubscribeToTickerUpdatesAsync("ETH-BTC", data => UpdateData("BingX", data.Data.LastPrice)),
bitfinexSocketClient.SpotApi.SubscribeToTickerUpdatesAsync("tETHBTC", data => UpdateData("Bitfinex", data.Data.LastPrice)),
bitgetSocketClient.SpotApiV2.SubscribeToTickerUpdatesAsync("ETHBTC", data => UpdateData("Bitget", data.Data.LastPrice)),
bitgetSocketClient.SpotApiV2.SubscribeToTickerUpdatesAsync("ETHBTC", data => UpdateData("Bitget", data.Data.First().LastPrice)),
bitmartSocketClient.SpotApi.SubscribeToTickerUpdatesAsync("ETH_BTC", data => UpdateData("BitMart", data.Data.LastPrice)),
bitmexSocketClient.ExchangeApi.SubscribeToSymbolUpdatesAsync("ETH_XBT", data => UpdateData("BitMEX", data.Data.LastPrice ?? 0)),
// BloFin doesn't support the ETH/BTC pair
@@ -62,6 +62,7 @@
{
<div style="margin-bottom: 20px; flex: 1; min-width: 300px;">
<h4>@book.Key - @book.Value.Symbol</h4>
<div>@(book.Value.DataAge?.TotalMilliseconds)ms since last update</div>
@if (book.Value.AskCount >= 3 && book.Value.BidCount >= 3)
{
for (var i = 0; i < 3; i++)
+14 -14
View File
@@ -6,20 +6,20 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Binance.Net" Version="11.10.0" />
<PackageReference Include="Bitfinex.Net" Version="9.10.0" />
<PackageReference Include="BitMart.Net" Version="2.11.0" />
<PackageReference Include="Bybit.Net" Version="5.12.0" />
<PackageReference Include="CoinEx.Net" Version="9.10.0" />
<PackageReference Include="CryptoCom.Net" Version="2.11.0" />
<PackageReference Include="GateIo.Net" Version="2.12.0" />
<PackageReference Include="JK.Bitget.Net" Version="2.11.0" />
<PackageReference Include="JK.Mexc.Net" Version="3.11.0" />
<PackageReference Include="JK.OKX.Net" Version="3.10.0" />
<PackageReference Include="JKorf.Coinbase.Net" Version="2.10.0" />
<PackageReference Include="JKorf.HTX.Net" Version="7.10.0" />
<PackageReference Include="KrakenExchange.Net" Version="6.10.0" />
<PackageReference Include="Kucoin.Net" Version="7.10.0" />
<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" />
</ItemGroup>
</Project>
+3 -3
View File
@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Binance.Net" Version="11.10.0" />
<PackageReference Include="BitMart.Net" Version="2.11.0" />
<PackageReference Include="JK.OKX.Net" Version="3.10.0" />
<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" />
</ItemGroup>
</Project>
+86 -1
View File
@@ -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](https://raw.githubusercontent.com/JKorf/Aster.Net/refs/heads/main/Aster.Net/Icon/icon.png)|Aster|DEX|[JKorf/Aster.Net](https://github.com/JKorf/Aster.Net)|[![Nuget version](https://img.shields.io/nuget/v/JKorf.Aster.net.svg?style=flat-square)](https://www.nuget.org/packages/JKorf.Aster.Net)|[Link](https://www.asterdex.com/en/referral/FD2E11)|4%|
|![Binance](https://raw.githubusercontent.com/JKorf/Binance.Net/refs/heads/master/Binance.Net/Icon/icon.png)|Binance|CEX|[JKorf/Binance.Net](https://github.com/JKorf/Binance.Net)|[![Nuget version](https://img.shields.io/nuget/v/Binance.net.svg?style=flat-square)](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](https://raw.githubusercontent.com/JKorf/Kucoin.Net/refs/heads/master/Kucoin.Net/Icon/icon.png)|Kucoin|CEX|[JKorf/Kucoin.Net](https://github.com/JKorf/Kucoin.Net)|[![Nuget version](https://img.shields.io/nuget/v/Kucoin.net.svg?style=flat-square)](https://www.nuget.org/packages/Kucoin.Net)|[Link](https://www.kucoin.com/r/rf/QBS4FPED)|-|
|![Mexc](https://raw.githubusercontent.com/JKorf/Mexc.Net/refs/heads/main/Mexc.Net/Icon/icon.png)|Mexc|CEX|[JKorf/Mexc.Net](https://github.com/JKorf/Mexc.Net)|[![Nuget version](https://img.shields.io/nuget/v/JK.Mexc.net.svg?style=flat-square)](https://www.nuget.org/packages/JK.Mexc.Net)|-|-|
|![OKX](https://raw.githubusercontent.com/JKorf/OKX.Net/refs/heads/main/OKX.Net/Icon/icon.png)|OKX|CEX|[JKorf/OKX.Net](https://github.com/JKorf/OKX.Net)|[![Nuget version](https://img.shields.io/nuget/v/JK.OKX.net.svg?style=flat-square)](https://www.nuget.org/packages/JK.OKX.Net)|[Link](https://www.okx.com/join/14592495)|20%|
|![Polymarket](https://raw.githubusercontent.com/JKorf/Polymarket.Net/main/Polymarket.Net/Icon/icon.png)|Polymarket|DEX|[JKorf/Polymarket.Net](https://github.com/JKorf/Polymarket.Net)|[![Nuget version](https://img.shields.io/nuget/v/Polymarket.net.svg?style=flat-square)](https://www.nuget.org/packages/Polymarket.Net)|-|-|
|![Toobit](https://raw.githubusercontent.com/JKorf/Toobit.Net/refs/heads/main/Toobit.Net/Icon/icon.png)|Toobit|CEX|[JKorf/Toobit.Net](https://github.com/JKorf/Toobit.Net)|[![Nuget version](https://img.shields.io/nuget/v/Toobit.net.svg?style=flat-square)](https://www.nuget.org/packages/Toobit.Net)|[Link](https://www.toobit.com/en-US/register?invite_code=zsV19h)|-|
|![Upbit](https://raw.githubusercontent.com/JKorf/Upbit.Net/refs/heads/main/Upbit.Net/Icon/icon.png)|Upbit|CEX|[JKorf/Upbit.Net](https://github.com/JKorf/Upbit.Net)|[![Nuget version](https://img.shields.io/nuget/v/JKorf.Upbit.net.svg?style=flat-square)](https://www.nuget.org/packages/JKorf.Upbit.Net)|-|-|
|![WhiteBit](https://raw.githubusercontent.com/JKorf/WhiteBit.Net/refs/heads/main/WhiteBit.Net/Icon/icon.png)|WhiteBit|CEX|[JKorf/WhiteBit.Net](https://github.com/JKorf/WhiteBit.Net)|[![Nuget version](https://img.shields.io/nuget/v/WhiteBit.net.svg?style=flat-square)](https://www.nuget.org/packages/WhiteBit.Net)|[Link](https://whitebit.com/referral/a8e59b59-186c-4662-824c-3095248e0edf)|-|
@@ -66,6 +67,90 @@ 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.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
* Version 10.2.4 - 17 Jan 2026
* Added WaitUntilFirstUpdateBufferedAsync method on SymbolOrderBook
* Added some util methods
* Added CommaSplitStringConverter
* Fixed sequence validation bug SymbolOrderBook
* Version 10.2.3 - 14 Jan 2026
* Added HandleUnhandledMessage virtual method to SocketApiClient to allow some processing for messages which couldn't be mapped via the normal way
* Fixed semaphore exception when creating a new REST client while time sync is in progress on another client
* Version 10.2.2 - 13 Jan 2026
* Allow the same websocket connection sequence number to be recorded multiple times
* Version 10.2.1 - 13 Jan 2026
* Removed duplicate logging for rest responses in Trace verbosity
* Fixed parameter URL creation for array values with ArrayParametersSerialization.MultipleValues
* Version 10.2.0 - 12 Jan 2026
* Added EnforceSequenceNumbers property on SocketApiClient to configure whether websocket message contain sequence numbers and if these should be checked to be sequential
* Added fallback to existing websocket connection if no dedicated request connection was found
* Added IntBoolConverter base class for arbitrary int value to bool mapping
* Added SequenceNumber property to DataEvent object
* Added _skipSequenceCheckFirstUpdateAfterSnapshotSet property for SymbolOrderBook implementations
* Updated SymbolOrderBook sequenceNumber validation
* Updated SymbolOrderBook log verbosities
* Renamed SetInitialOrderBook to SetSnapshot in SymbolOrderBook
* Renamed updateId references to sequenceNumber in SymbolOrderBook
* Version 10.1.0 - 07 Jan 2026
* Updated time sync / time offset management for REST API's
* Added time offset tracking for WebSocket API's
* Added GetAuthenticationQuery virtual method on AuthenticationProvider
* Updated AuthenticationProvider GetTimestamp methods to include a one second offset by default
* Added AuthenticationProvider GetTimestamp methods for SocketApiClient instances
* Added ClientName property on BaseApiClient, resolving to the type name
* Added ObjectOrArrayConverter JsonConverterFactory implementation for resolving json data which might be returned as object or array
* Added UpdateServerTime, UpdateLocalTime and DataAge properties to (I)SymbolOrderBook
* Added OutputToConsoleAsync method to (I)SymbolOrderBook
* Updated SymbolOrderBook string representation
* Added DataTimeLocal and DataAge properties to DataEvent object
* Added SocketConnection parameter to subscription HandleSubQueryResponse and HandleUnsubQueryResponse methods
* Added some utility methods
* Version 10.0.2 - 19 Dec 2025
* Fixed duplicate subscription check with updated deserialization
* Added exception handlers for REST response processing
* Version 10.0.1 - 18 Dec 2025
* Fixed query array parameter serialization
* Version 10.0.0 - 16 Dec 2025
* -