mirror of
https://github.com/JKorf/CryptoExchange.Net.git
synced 2026-08-20 12:53:02 +00:00
b29cdc41f3
Updated INextPageToken parameter on Shared interfaces to PageRequest type, functionality unchanged Added SupportsAscending and SupportsDescending properties to PaginatedEndpointOptions to expose supported data directions Added MaxAge property to PaginatedEndpointOptions to expose the max age of data that can be requested Added Direction property to Shared interfaces paginated requests to configure pagination data direction Removed PaginationSupport property from PaginatedEndpointOptions, replaced by above new properties Updated Shared GetTradeHistoryRequest EndTime property to be optional Updated I(Futures/Spot)OrderRestClient.GetClosed(Futures/Spot)OrdersOptions from PaginatedEndpointOptions<GetClosedOrdersRequest> to GetClosedOrdersOptions Updated I(Futures/Spot)OrderRestClient.Get(Futures/Spot)UserTradesOptions from PaginatedEndpointOptions<GetUserTradesRequest> to GetUserTradesOptions Updated rate limiting PathStartFilter to ignore added or missing slash before the path Fixed KlineTracker throwing exception if there is no data in the initial snapshot
499 lines
17 KiB
C#
499 lines
17 KiB
C#
using CryptoExchange.Net.Logging.Extensions;
|
|
using CryptoExchange.Net.Objects;
|
|
using CryptoExchange.Net.Objects.Sockets;
|
|
using CryptoExchange.Net.SharedApis;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace CryptoExchange.Net.Trackers.Trades
|
|
{
|
|
/// <inheritdoc />
|
|
public class TradeTracker : ITradeTracker
|
|
{
|
|
private readonly ITradeSocketClient _socketClient;
|
|
private readonly IRecentTradeRestClient? _recentRestClient;
|
|
private readonly ITradeHistoryRestClient? _historyRestClient;
|
|
private SyncStatus _status;
|
|
private long _snapshotId;
|
|
private bool _startWithSnapshot;
|
|
|
|
/// <summary>
|
|
/// The internal data structure
|
|
/// </summary>
|
|
protected readonly List<SharedTrade> _data = new List<SharedTrade>();
|
|
/// <summary>
|
|
/// The pre-snapshot queue buffering updates received before the snapshot is set and which will be applied after the snapshot was set
|
|
/// </summary>
|
|
protected readonly List<SharedTrade> _preSnapshotQueue = new List<SharedTrade>();
|
|
|
|
/// <summary>
|
|
/// The last time the window was applied
|
|
/// </summary>
|
|
protected DateTime _lastWindowApplied = DateTime.MinValue;
|
|
/// <summary>
|
|
/// Whether or not the data has changed since last window was applied
|
|
/// </summary>
|
|
protected bool _changed = false;
|
|
/// <summary>
|
|
/// Lock for accessing _data
|
|
/// </summary>
|
|
#if NET9_0_OR_GREATER
|
|
private readonly Lock _lock = new Lock();
|
|
#else
|
|
private readonly object _lock = new object();
|
|
#endif
|
|
/// <summary>
|
|
/// Whether the snapshot has been set
|
|
/// </summary>
|
|
protected bool _snapshotSet;
|
|
/// <summary>
|
|
/// Logger
|
|
/// </summary>
|
|
protected readonly ILogger _logger;
|
|
/// <summary>
|
|
/// Update subscription
|
|
/// </summary>
|
|
protected UpdateSubscription? _updateSubscription;
|
|
|
|
/// <summary>
|
|
/// The timestamp of the first item
|
|
/// </summary>
|
|
protected DateTime? _firstTimestamp;
|
|
|
|
/// <inheritdoc />
|
|
public string Exchange { get; }
|
|
|
|
/// <inheritdoc />
|
|
public string SymbolName { get; }
|
|
|
|
/// <inheritdoc />
|
|
public SharedSymbol Symbol { get; }
|
|
|
|
/// <inheritdoc/>
|
|
public int? Limit { get; }
|
|
/// <inheritdoc/>
|
|
public TimeSpan? Period { get; }
|
|
|
|
/// <inheritdoc/>
|
|
public SyncStatus Status
|
|
{
|
|
get => _status;
|
|
set
|
|
{
|
|
if (value == _status)
|
|
return;
|
|
|
|
var old = _status;
|
|
_status = value;
|
|
_logger.TradeTrackerStatusChanged(SymbolName, old, value);
|
|
OnStatusChanged?.Invoke(old, _status);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int Count
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
ApplyWindow(true);
|
|
return _data.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DateTime? SyncedFrom
|
|
{
|
|
get
|
|
{
|
|
if (Period == null)
|
|
return _firstTimestamp;
|
|
|
|
var max = DateTime.UtcNow - Period.Value;
|
|
if (_firstTimestamp > max)
|
|
return _firstTimestamp;
|
|
|
|
return max;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public SharedTrade? Last
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
ApplyWindow(true);
|
|
return _data.LastOrDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public event Func<SharedTrade, Task>? OnAdded;
|
|
/// <inheritdoc />
|
|
public event Func<SharedTrade, Task>? OnRemoved;
|
|
/// <inheritdoc />
|
|
public event Func<SyncStatus, SyncStatus, Task>? OnStatusChanged;
|
|
|
|
/// <summary>
|
|
/// ctor
|
|
/// </summary>
|
|
public TradeTracker(
|
|
ILogger? logger,
|
|
IRecentTradeRestClient? recentRestClient,
|
|
ITradeHistoryRestClient? historyRestClient,
|
|
ITradeSocketClient socketClient,
|
|
SharedSymbol symbol,
|
|
int? limit = null,
|
|
TimeSpan? period = null)
|
|
{
|
|
_logger = logger ?? new NullLogger<TradeTracker>();
|
|
_recentRestClient = recentRestClient;
|
|
_historyRestClient = historyRestClient;
|
|
_socketClient = socketClient;
|
|
Exchange = socketClient.Exchange;
|
|
Symbol = symbol;
|
|
SymbolName = socketClient.FormatSymbol(symbol.BaseAsset, symbol.QuoteAsset, symbol.TradingMode, symbol.DeliverTime);
|
|
Limit = limit;
|
|
Period = period;
|
|
}
|
|
|
|
private static TradesStats GetStats(IEnumerable<SharedTrade> trades)
|
|
{
|
|
if (!trades.Any())
|
|
return new TradesStats();
|
|
|
|
return new TradesStats
|
|
{
|
|
TradeCount = trades.Count(),
|
|
FirstTradeTime = trades.First().Timestamp,
|
|
LastTradeTime = trades.Last().Timestamp,
|
|
AveragePrice = Math.Round(trades.Select(d => d.Price).DefaultIfEmpty().Average(), 8),
|
|
VolumeWeightedAveragePrice = trades.Any() ? Math.Round(trades.Select(d => d.Price * d.Quantity).DefaultIfEmpty().Sum() / trades.Select(d => d.Quantity).DefaultIfEmpty().Sum(), 8) : null,
|
|
Volume = Math.Round(trades.Sum(d => d.Quantity), 8),
|
|
QuoteVolume = Math.Round(trades.Sum(d => d.Quantity * d.Price), 8),
|
|
BuySellRatio = Math.Round(trades.Where(x => x.Side == SharedOrderSide.Buy).Sum(x => x.Quantity) / trades.Sum(x => x.Quantity), 8)
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public TradesStats GetStats(DateTime? fromTimestamp = null, DateTime? toTimestamp = null)
|
|
{
|
|
var compareTime = SyncedFrom?.AddSeconds(-2);
|
|
var stats = GetStats(GetData(fromTimestamp, toTimestamp));
|
|
stats.Complete = (fromTimestamp == null || fromTimestamp >= compareTime) && (toTimestamp == null || toTimestamp >= compareTime);
|
|
return stats;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<CallResult> StartAsync(bool startWithSnapshot = true)
|
|
{
|
|
if (Status != SyncStatus.Disconnected)
|
|
throw new InvalidOperationException($"Can't start syncing unless state is {SyncStatus.Disconnected}. Current state: {Status}");
|
|
|
|
_startWithSnapshot = startWithSnapshot;
|
|
Status = SyncStatus.Syncing;
|
|
_logger.TradeTrackerStarting(SymbolName);
|
|
var subResult = await _socketClient.SubscribeToTradeUpdatesAsync(new SubscribeTradeRequest(Symbol),
|
|
update =>
|
|
{
|
|
AddData(update.Data);
|
|
}).ConfigureAwait(false);
|
|
|
|
if (!subResult)
|
|
{
|
|
_logger.TradeTrackerStartFailed(SymbolName, subResult.Error!.Message ?? subResult.Error!.ErrorDescription!, subResult.Error.Exception);
|
|
Status = SyncStatus.Disconnected;
|
|
return subResult;
|
|
}
|
|
|
|
_updateSubscription = subResult.Data;
|
|
_updateSubscription.ConnectionLost += HandleConnectionLost;
|
|
_updateSubscription.ConnectionClosed += HandleConnectionClosed;
|
|
_updateSubscription.ConnectionRestored += HandleConnectionRestored;
|
|
|
|
var result = await DoStartAsync().ConfigureAwait(false);
|
|
if (!result)
|
|
{
|
|
_ = subResult.Data.CloseAsync();
|
|
Status = SyncStatus.Disconnected;
|
|
return result;
|
|
}
|
|
|
|
SetSyncStatus();
|
|
_logger.TradeTrackerStarted(SymbolName);
|
|
return CallResult.SuccessResult;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task StopAsync()
|
|
{
|
|
_logger.TradeTrackerStopping(SymbolName);
|
|
Status = SyncStatus.Disconnected;
|
|
await DoStopAsync().ConfigureAwait(false);
|
|
_data.Clear();
|
|
_preSnapshotQueue.Clear();
|
|
_logger.TradeTrackerStopped(SymbolName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The start procedure needed for trade syncing, generally subscribing to an update stream and requesting the snapshot
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
protected virtual async Task<CallResult> DoStartAsync()
|
|
{
|
|
if (!_startWithSnapshot)
|
|
return CallResult.SuccessResult;
|
|
|
|
if (_historyRestClient != null)
|
|
{
|
|
var startTime = Period == null ? DateTime.UtcNow.AddMinutes(-5) : DateTime.UtcNow.Add(-Period.Value);
|
|
var request = new GetTradeHistoryRequest(Symbol, startTime, DateTime.UtcNow);
|
|
var data = new List<SharedTrade>();
|
|
await foreach (var result in ExchangeHelpers.ExecutePages(_historyRestClient.GetTradeHistoryAsync, request).ConfigureAwait(false))
|
|
{
|
|
if (!result)
|
|
return result;
|
|
|
|
if (Limit != null && data.Count > Limit)
|
|
break;
|
|
|
|
data.AddRange(result.Data);
|
|
}
|
|
|
|
SetInitialData(data);
|
|
}
|
|
else if (_recentRestClient != null)
|
|
{
|
|
int? limit = null;
|
|
if (Limit.HasValue)
|
|
limit = Math.Min(_recentRestClient.GetRecentTradesOptions.MaxLimit, Limit.Value);
|
|
|
|
var snapshot = await _recentRestClient.GetRecentTradesAsync(new GetRecentTradesRequest(Symbol, limit)).ConfigureAwait(false);
|
|
if (!snapshot)
|
|
{
|
|
return snapshot;
|
|
}
|
|
|
|
SetInitialData(snapshot.Data);
|
|
}
|
|
|
|
return CallResult.SuccessResult;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The stop procedure needed, generally stopping the update stream
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
protected virtual Task DoStopAsync() => _updateSubscription?.CloseAsync() ?? Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public SharedTrade[] GetData(DateTime? since = null, DateTime? until = null)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
ApplyWindow(true);
|
|
|
|
IEnumerable<SharedTrade> result = _data;
|
|
if (since != null)
|
|
result = result.Where(d => d.Timestamp >= since);
|
|
if (until != null)
|
|
result = result.Where(d => d.Timestamp <= until);
|
|
|
|
return result.ToArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the initial trade data snapshot
|
|
/// </summary>
|
|
/// <param name="data"></param>
|
|
protected void SetInitialData(IEnumerable<SharedTrade> data)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_data.Clear();
|
|
|
|
IEnumerable<SharedTrade> items = data.OrderByDescending(d => d.Timestamp);
|
|
if (Limit != null)
|
|
items = items.Take(Limit.Value);
|
|
if (Period != null)
|
|
items = items.Where(e => e.Timestamp >= DateTime.UtcNow.Add(-Period.Value));
|
|
|
|
if (items.Any())
|
|
{
|
|
_snapshotId = data.Max(d => d.Timestamp.Ticks);
|
|
foreach (var item in items.OrderBy(d => d.Timestamp))
|
|
_data.Add(item);
|
|
}
|
|
|
|
_snapshotSet = true;
|
|
_changed = true;
|
|
|
|
_logger.TradeTrackerInitialDataSet(SymbolName, _data.Count, _snapshotId);
|
|
|
|
foreach (var item in _preSnapshotQueue)
|
|
{
|
|
if (_snapshotId >= item.Timestamp.Ticks)
|
|
{
|
|
_logger.TradeTrackerPreSnapshotSkip(SymbolName, item.Timestamp.Ticks);
|
|
continue;
|
|
}
|
|
|
|
_logger.TradeTrackerPreSnapshotApplied(SymbolName, item.Timestamp.Ticks);
|
|
_data.Add(item);
|
|
}
|
|
|
|
if (_data.Count != 0)
|
|
_firstTimestamp = _data.Min(v => v.Timestamp);
|
|
|
|
ApplyWindow(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add a trade
|
|
/// </summary>
|
|
/// <param name="item"></param>
|
|
protected void AddData(SharedTrade item) => AddData(new[] { item });
|
|
|
|
/// <summary>
|
|
/// Add a list of trades
|
|
/// </summary>
|
|
/// <param name="items"></param>
|
|
protected void AddData(IEnumerable<SharedTrade> items)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if ((_recentRestClient != null || _historyRestClient != null) && _startWithSnapshot && !_snapshotSet)
|
|
{
|
|
_preSnapshotQueue.AddRange(items);
|
|
return;
|
|
}
|
|
|
|
foreach (var item in items)
|
|
{
|
|
_logger.TradeTrackerTradeAdded(SymbolName, item.Timestamp.Ticks);
|
|
_data.Add(item);
|
|
OnAdded?.Invoke(item);
|
|
}
|
|
|
|
_firstTimestamp = _data.Min(x => x.Timestamp);
|
|
_changed = true;
|
|
SetSyncStatus();
|
|
ApplyWindow(true);
|
|
}
|
|
}
|
|
|
|
private void ApplyWindow(bool broadcastEvents)
|
|
{
|
|
if (!_changed && (DateTime.UtcNow - _lastWindowApplied) < TimeSpan.FromSeconds(1))
|
|
return;
|
|
|
|
if (Period != null)
|
|
{
|
|
var compareDate = DateTime.UtcNow.Add(-Period.Value);
|
|
for(var i = 0; i < _data.Count; i++)
|
|
{
|
|
var item = _data[0];
|
|
if (item.Timestamp >= compareDate)
|
|
break;
|
|
|
|
_data.Remove(item);
|
|
if (broadcastEvents)
|
|
OnRemoved?.Invoke(item);
|
|
}
|
|
}
|
|
|
|
if (Limit != null && _data.Count > Limit.Value)
|
|
{
|
|
var toRemove = _data.Count - Limit.Value;
|
|
for (var i = 0; i < toRemove; i++)
|
|
{
|
|
var item = _data[0];
|
|
_data.Remove(item);
|
|
if (broadcastEvents)
|
|
OnRemoved?.Invoke(item);
|
|
}
|
|
}
|
|
|
|
_lastWindowApplied = DateTime.UtcNow;
|
|
_changed = false;
|
|
|
|
if (Status == SyncStatus.PartiallySynced)
|
|
// Need to check if sync status should be changed even if there may not be any new data
|
|
SetSyncStatus();
|
|
}
|
|
|
|
private void HandleConnectionLost()
|
|
{
|
|
_logger.TradeTrackerConnectionLost(SymbolName);
|
|
if (Status != SyncStatus.Disconnected)
|
|
{
|
|
Status = SyncStatus.Syncing;
|
|
_snapshotSet = false;
|
|
_firstTimestamp = null;
|
|
_preSnapshotQueue.Clear();
|
|
}
|
|
}
|
|
|
|
private void HandleConnectionClosed()
|
|
{
|
|
_logger.TradeTrackerConnectionClosed(SymbolName);
|
|
Status = SyncStatus.Disconnected;
|
|
_ = StopAsync();
|
|
}
|
|
|
|
private async void HandleConnectionRestored(TimeSpan _)
|
|
{
|
|
Status = SyncStatus.Syncing;
|
|
var success = false;
|
|
while (!success)
|
|
{
|
|
if (Status != SyncStatus.Syncing)
|
|
return;
|
|
|
|
var resyncResult = await DoStartAsync().ConfigureAwait(false);
|
|
success = resyncResult;
|
|
}
|
|
|
|
_logger.TradeTrackerConnectionRestored(SymbolName);
|
|
SetSyncStatus();
|
|
}
|
|
|
|
private void SetSyncStatus()
|
|
{
|
|
if (Status == SyncStatus.Synced)
|
|
return;
|
|
|
|
if (Period != null)
|
|
{
|
|
if (_firstTimestamp <= DateTime.UtcNow - Period.Value)
|
|
Status = SyncStatus.Synced;
|
|
else
|
|
Status = SyncStatus.PartiallySynced;
|
|
}
|
|
|
|
if (Limit != null)
|
|
{
|
|
if (_data.Count == Limit.Value)
|
|
Status = SyncStatus.Synced;
|
|
else
|
|
Status = SyncStatus.PartiallySynced;
|
|
}
|
|
|
|
if (Period == null && Limit == null)
|
|
Status = SyncStatus.Synced;
|
|
}
|
|
}
|
|
}
|