1
0
mirror of https://github.com/JKorf/CryptoExchange.Net synced 2026-02-16 14:13:46 +00:00

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
This commit is contained in:
Jan Korf 2026-02-05 16:05:13 +01:00 committed by GitHub
parent 2fd3912795
commit 74e5cf6fc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2438 additions and 7 deletions

View File

@ -95,6 +95,10 @@ namespace CryptoExchange.Net.Objects
/// </summary> /// </summary>
public void Set() public void Set()
{ {
if (!_autoReset && _signaled)
// Already signaled and not resetting
return;
lock (_waitersLock) lock (_waitersLock)
{ {
if (_autoReset) if (_autoReset)

View File

@ -109,6 +109,21 @@ namespace CryptoExchange.Net.Objects.Sockets
/// </summary> /// </summary>
public int Id => _subscription.Id; 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> /// <summary>
/// ctor /// ctor
/// </summary> /// </summary>

View File

@ -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
}
}

View File

@ -44,15 +44,21 @@ namespace CryptoExchange.Net.SharedApis
/// </summary> /// </summary>
public bool Completed { get; set; } public bool Completed { get; set; }
/// <summary>
/// Status of the deposit
/// </summary>
public SharedTransferStatus Status { get; set; }
/// <summary> /// <summary>
/// ctor /// ctor
/// </summary> /// </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; Asset = asset;
Quantity = quantity; Quantity = quantity;
Timestamp = timestamp; Timestamp = timestamp;
Completed = completed; Completed = completed;
Status = status;
} }
} }

View File

@ -20,6 +20,10 @@ namespace CryptoExchange.Net.SharedApis
/// </summary> /// </summary>
public SharedPositionSide PositionSide { get; set; } public SharedPositionSide PositionSide { get; set; }
/// <summary> /// <summary>
/// Whether the position is one way mode
/// </summary>
public SharedPositionMode PositionMode { get; set; }
/// <summary>
/// Average open price /// Average open price
/// </summary> /// </summary>
public decimal? AverageOpenPrice { get; set; } public decimal? AverageOpenPrice { get; set; }

View File

@ -21,6 +21,11 @@ namespace CryptoExchange.Net.SharedApis
/// </summary> /// </summary>
public decimal? QuantityInContracts { get; set; } public decimal? QuantityInContracts { get; set; }
/// <summary>
/// Whether all values are null or zero
/// </summary>
public bool IsZero => !(QuantityInBaseAsset > 0) && !(QuantityInQuoteAsset > 0) && !(QuantityInContracts > 0);
/// <summary> /// <summary>
/// ctor /// ctor
/// </summary> /// </summary>

View File

@ -49,7 +49,6 @@ namespace CryptoExchange.Net.Sockets.Default
private int _reconnectAttempt; private int _reconnectAttempt;
private readonly int _receiveBufferSize; private readonly int _receiveBufferSize;
private const int _defaultReceiveBufferSize = 1048576;
private const int _sendBufferSize = 4096; private const int _sendBufferSize = 4096;
private int _bytesReceived = 0; private int _bytesReceived = 0;
@ -71,7 +70,7 @@ namespace CryptoExchange.Net.Sockets.Default
/// <summary> /// <summary>
/// The timestamp this socket has been active for the last time /// The timestamp this socket has been active for the last time
/// </summary> /// </summary>
public DateTime LastActionTime { get; private set; } public DateTime? LastReceiveTime { get; private set; }
/// <inheritdoc /> /// <inheritdoc />
public Uri Uri => Parameters.Uri; public Uri Uri => Parameters.Uri;
@ -622,6 +621,7 @@ namespace CryptoExchange.Net.Sockets.Default
break; break;
} }
LastReceiveTime = DateTime.UtcNow;
if (receiveResult.MessageType == WebSocketMessageType.Close) if (receiveResult.MessageType == WebSocketMessageType.Close)
{ {
// Connection closed // Connection closed
@ -772,6 +772,7 @@ namespace CryptoExchange.Net.Sockets.Default
break; break;
} }
LastReceiveTime = DateTime.UtcNow;
if (receiveResult.MessageType == WebSocketMessageType.Close) if (receiveResult.MessageType == WebSocketMessageType.Close)
{ {
// Connection closed // Connection closed
@ -880,7 +881,6 @@ namespace CryptoExchange.Net.Sockets.Default
/// <returns></returns> /// <returns></returns>
protected void ProcessDataNew(WebSocketMessageType type, ReadOnlySpan<byte> data) protected void ProcessDataNew(WebSocketMessageType type, ReadOnlySpan<byte> data)
{ {
LastActionTime = DateTime.UtcNow;
_connection.HandleStreamMessage2(type, data); _connection.HandleStreamMessage2(type, data);
} }
@ -891,7 +891,7 @@ namespace CryptoExchange.Net.Sockets.Default
protected async Task CheckTimeoutAsync() protected async Task CheckTimeoutAsync()
{ {
_logger.SocketStartingTaskForNoDataReceivedCheck(Id, Parameters.Timeout); _logger.SocketStartingTaskForNoDataReceivedCheck(Id, Parameters.Timeout);
LastActionTime = DateTime.UtcNow; LastReceiveTime = DateTime.UtcNow;
try try
{ {
while (true) while (true)
@ -899,7 +899,7 @@ namespace CryptoExchange.Net.Sockets.Default
if (_ctsSource.IsCancellationRequested) if (_ctsSource.IsCancellationRequested)
return; return;
if (DateTime.UtcNow - LastActionTime > Parameters.Timeout) if (DateTime.UtcNow - LastReceiveTime > Parameters.Timeout)
{ {
_logger.SocketNoDataReceiveTimoutReconnect(Id, Parameters.Timeout); _logger.SocketNoDataReceiveTimoutReconnect(Id, Parameters.Timeout);
_ = ReconnectAsync().ConfigureAwait(false); _ = ReconnectAsync().ConfigureAwait(false);

View File

@ -69,6 +69,10 @@ namespace CryptoExchange.Net.Sockets.Default.Interfaces
/// </summary> /// </summary>
bool IsOpen { get; } bool IsOpen { get; }
/// <summary> /// <summary>
/// Last timestamp something was received from the server
/// </summary>
DateTime? LastReceiveTime { get; }
/// <summary>
/// Connect the socket /// Connect the socket
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>

View File

@ -178,6 +178,11 @@ namespace CryptoExchange.Net.Sockets.Default
/// </summary> /// </summary>
public DateTime? DisconnectTime { get; set; } public DateTime? DisconnectTime { get; set; }
/// <summary>
/// Last timestamp something was received from the server
/// </summary>
public DateTime? LastReceiveTime => _socket.LastReceiveTime;
/// <summary> /// <summary>
/// Tag for identification /// Tag for identification
/// </summary> /// </summary>

View File

@ -46,7 +46,7 @@ namespace CryptoExchange.Net.Sockets.Default
return; return;
_status = value; _status = value;
Task.Run(() => StatusChanged?.Invoke(value)); StatusChanged?.Invoke(value);
} }
} }

View File

@ -39,6 +39,7 @@ namespace CryptoExchange.Net.Testing.Implementations
public Func<Task<Uri?>>? GetReconnectionUrl { get; set; } public Func<Task<Uri?>>? GetReconnectionUrl { get; set; }
public static int lastId = 0; public static int lastId = 0;
public DateTime? LastReceiveTime { get; }
#if NET9_0_OR_GREATER #if NET9_0_OR_GREATER
public static readonly Lock lastIdLock = new Lock(); public static readonly Lock lastIdLock = new Lock();
#else #else

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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))!;
}
}
}

View File

@ -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.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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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))!;
}
}
}

View File

@ -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.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 && 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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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
}
}

View File

@ -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));
}
}

View File

@ -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
}
}

View File

@ -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;
}
}
}

View File

@ -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");
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}