mirror of
https://github.com/JKorf/CryptoExchange.Net
synced 2026-04-07 02:01:12 +00:00
415 lines
19 KiB
C#
415 lines
19 KiB
C#
using CryptoExchange.Net.Objects;
|
|
using CryptoExchange.Net.Objects.Sockets;
|
|
using CryptoExchange.Net.SharedApis;
|
|
using CryptoExchange.Net.Trackers.UserData.Objects;
|
|
using Microsoft.Extensions.Logging;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
|
|
{
|
|
/// <summary>
|
|
/// Spot order tracker
|
|
/// </summary>
|
|
public class SpotOrderTracker : UserDataItemTracker<SharedSpotOrder>
|
|
{
|
|
private readonly ISpotOrderRestClient _restClient;
|
|
private readonly ISpotOrderSocketClient? _socketClient;
|
|
private readonly ExchangeParameters? _exchangeParameters;
|
|
private readonly bool _requiresSymbolParameterOpenOrders;
|
|
private readonly Dictionary<string, int> _openOrderNotReturnedTimes = new();
|
|
private readonly TimeSpan _pollOverlapPeriod = TimeSpan.FromSeconds(3);
|
|
|
|
internal event Func<UpdateSource, SharedUserTrade[], Task>? OnTradeUpdate;
|
|
|
|
/// <summary>
|
|
/// ctor
|
|
/// </summary>
|
|
public SpotOrderTracker(
|
|
ILogger logger,
|
|
UserDataSymbolTracker symbolTracker,
|
|
ISpotOrderRestClient restClient,
|
|
ISpotOrderSocketClient? socketClient,
|
|
TrackerItemConfig config,
|
|
IEnumerable<SharedSymbol> symbols,
|
|
bool onlyTrackProvidedSymbols,
|
|
ExchangeParameters? exchangeParameters = null
|
|
) : base(logger, symbolTracker, UserDataType.Orders, restClient.Exchange, config)
|
|
{
|
|
if (_socketClient == null)
|
|
config = config with { PollIntervalConnected = config.PollIntervalDisconnected };
|
|
|
|
_restClient = restClient;
|
|
_socketClient = socketClient;
|
|
_exchangeParameters = exchangeParameters;
|
|
|
|
_requiresSymbolParameterOpenOrders = restClient.GetOpenSpotOrdersOptions.RequiredOptionalParameters.Any(x => x.Name == "Symbol");
|
|
}
|
|
|
|
internal void ClearDataForSymbol(SharedSymbol symbol)
|
|
{
|
|
foreach(var order in _store)
|
|
{
|
|
if (order.Value.SharedSymbol!.TradingMode == symbol.TradingMode
|
|
&& order.Value.SharedSymbol.BaseAsset == symbol.BaseAsset
|
|
&& order.Value.SharedSymbol.QuoteAsset == symbol.QuoteAsset)
|
|
{
|
|
_store.TryRemove(order.Key, out _);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override bool Update(SharedSpotOrder existingItem, SharedSpotOrder updateItem)
|
|
{
|
|
var changed = false;
|
|
if (updateItem.AveragePrice != null && updateItem.AveragePrice != existingItem.AveragePrice)
|
|
{
|
|
existingItem.AveragePrice = updateItem.AveragePrice;
|
|
changed = true;
|
|
}
|
|
|
|
if (updateItem.OrderPrice != null && updateItem.OrderPrice != existingItem.OrderPrice)
|
|
{
|
|
existingItem.OrderPrice = updateItem.OrderPrice;
|
|
changed = true;
|
|
}
|
|
|
|
if (updateItem.Fee != null && updateItem.Fee != existingItem.Fee)
|
|
{
|
|
existingItem.Fee = updateItem.Fee;
|
|
changed = true;
|
|
}
|
|
|
|
if (updateItem.FeeAsset != null && updateItem.FeeAsset != existingItem.FeeAsset)
|
|
{
|
|
existingItem.FeeAsset = updateItem.FeeAsset;
|
|
changed = true;
|
|
}
|
|
|
|
if (updateItem.OrderQuantity != null && updateItem.OrderQuantity != existingItem.OrderQuantity)
|
|
{
|
|
existingItem.OrderQuantity ??= new SharedOrderQuantity();
|
|
if (updateItem.OrderQuantity.QuantityInBaseAsset != null)
|
|
{
|
|
existingItem.OrderQuantity.QuantityInBaseAsset = updateItem.OrderQuantity.QuantityInBaseAsset;
|
|
changed = true;
|
|
}
|
|
if (updateItem.OrderQuantity.QuantityInQuoteAsset != null)
|
|
{
|
|
existingItem.OrderQuantity.QuantityInQuoteAsset = updateItem.OrderQuantity.QuantityInQuoteAsset;
|
|
changed = true;
|
|
}
|
|
if (updateItem.OrderQuantity.QuantityInContracts != null)
|
|
{
|
|
existingItem.OrderQuantity.QuantityInContracts = updateItem.OrderQuantity.QuantityInContracts;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (updateItem.QuantityFilled != null && updateItem.QuantityFilled != existingItem.QuantityFilled)
|
|
{
|
|
existingItem.QuantityFilled ??= new SharedOrderQuantity();
|
|
if (updateItem.QuantityFilled.QuantityInBaseAsset != null)
|
|
{
|
|
existingItem.QuantityFilled.QuantityInBaseAsset = updateItem.QuantityFilled.QuantityInBaseAsset;
|
|
changed = true;
|
|
}
|
|
if (updateItem.QuantityFilled.QuantityInQuoteAsset != null)
|
|
{
|
|
existingItem.QuantityFilled.QuantityInQuoteAsset = updateItem.QuantityFilled.QuantityInQuoteAsset;
|
|
changed = true;
|
|
}
|
|
if (updateItem.QuantityFilled.QuantityInContracts != null)
|
|
{
|
|
existingItem.QuantityFilled.QuantityInContracts = updateItem.QuantityFilled.QuantityInContracts;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (updateItem.Status != existingItem.Status)
|
|
{
|
|
existingItem.Status = updateItem.Status;
|
|
changed = true;
|
|
}
|
|
|
|
if (updateItem.UpdateTime != null && updateItem.UpdateTime != existingItem.UpdateTime)
|
|
{
|
|
existingItem.UpdateTime = updateItem.UpdateTime;
|
|
changed = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override string GetKey(SharedSpotOrder item) => item.OrderId;
|
|
/// <inheritdoc />
|
|
protected override TimeSpan GetAge(DateTime time, SharedSpotOrder item) => item.Status == SharedOrderStatus.Open ? TimeSpan.Zero : time - (item.UpdateTime ?? item.CreateTime ?? time);
|
|
|
|
/// <inheritdoc />
|
|
protected override bool? CheckIfUpdateShouldBeApplied(SharedSpotOrder existingItem, SharedSpotOrder updateItem)
|
|
{
|
|
if (existingItem.Status == SharedOrderStatus.Open && updateItem.Status != SharedOrderStatus.Open)
|
|
// status changed from open to not open
|
|
return true;
|
|
|
|
if (existingItem.Status != SharedOrderStatus.Open
|
|
&& updateItem.Status != SharedOrderStatus.Open
|
|
&& existingItem.Status != updateItem.Status)
|
|
{
|
|
_logger.LogWarning("Invalid order update detected for order {OrderId}; current status: {OldStatus}, new status: {NewStatus}", existingItem.OrderId, existingItem.Status, updateItem.Status);
|
|
return false;
|
|
}
|
|
|
|
if (existingItem.Status != SharedOrderStatus.Open && updateItem.Status == SharedOrderStatus.Open)
|
|
// status changed from not open to open; stale
|
|
return false;
|
|
|
|
if (existingItem.UpdateTime != null && updateItem.UpdateTime != null)
|
|
{
|
|
// If both have an update time base of that
|
|
if (existingItem.UpdateTime < updateItem.UpdateTime)
|
|
return true;
|
|
|
|
if (existingItem.UpdateTime > updateItem.UpdateTime)
|
|
return false;
|
|
}
|
|
|
|
if (existingItem.QuantityFilled != null && updateItem.QuantityFilled != null)
|
|
{
|
|
if (existingItem.QuantityFilled.QuantityInBaseAsset != null && updateItem.QuantityFilled.QuantityInBaseAsset != null)
|
|
{
|
|
// If base quantity is not null we can base it on that
|
|
if (existingItem.QuantityFilled.QuantityInBaseAsset < updateItem.QuantityFilled.QuantityInBaseAsset)
|
|
return true;
|
|
|
|
else if (existingItem.QuantityFilled.QuantityInBaseAsset > updateItem.QuantityFilled.QuantityInBaseAsset)
|
|
return false;
|
|
}
|
|
|
|
if (existingItem.QuantityFilled.QuantityInQuoteAsset != null && updateItem.QuantityFilled.QuantityInQuoteAsset != null)
|
|
{
|
|
// If quote quantity is not null we can base it on that
|
|
if (existingItem.QuantityFilled.QuantityInQuoteAsset < updateItem.QuantityFilled.QuantityInQuoteAsset)
|
|
return true;
|
|
|
|
else if (existingItem.QuantityFilled.QuantityInQuoteAsset > updateItem.QuantityFilled.QuantityInQuoteAsset)
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (existingItem.Fee != null && updateItem.Fee != null)
|
|
{
|
|
// Higher fee means later processing
|
|
if (existingItem.Fee < updateItem.Fee)
|
|
return true;
|
|
|
|
if (existingItem.Fee > updateItem.Fee)
|
|
return false;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected internal override async Task HandleUpdateAsync(UpdateSource source, SharedSpotOrder[] @event)
|
|
{
|
|
await base.HandleUpdateAsync(source, @event).ConfigureAwait(false);
|
|
|
|
var trades = @event.Where(x => x.LastTrade != null).Select(x => x.LastTrade!).ToArray();
|
|
if (trades.Length != 0 && OnTradeUpdate != null)
|
|
await OnTradeUpdate(source, trades).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override Task<CallResult<UpdateSubscription?>> DoSubscribeAsync(string? listenKey)
|
|
{
|
|
if (_socketClient == null)
|
|
return Task.FromResult(new CallResult<UpdateSubscription?>(data: null));
|
|
|
|
return ExchangeHelpers.ProcessQueuedAsync<SharedSpotOrder[]>(
|
|
async handler => await _socketClient.SubscribeToSpotOrderUpdatesAsync(new SubscribeSpotOrderRequest(listenKey, exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
|
|
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task<bool> DoPollAsync()
|
|
{
|
|
var anyError = false;
|
|
List<SharedSpotOrder> openOrders = new List<SharedSpotOrder>();
|
|
|
|
if (!_requiresSymbolParameterOpenOrders)
|
|
{
|
|
var openOrdersResult = await _restClient.GetOpenSpotOrdersAsync(new GetOpenOrdersRequest(exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
|
|
if (!openOrdersResult.Success)
|
|
{
|
|
anyError = true;
|
|
|
|
_initialPollingError ??= openOrdersResult.Error;
|
|
if (!_firstPollDone)
|
|
return anyError;
|
|
}
|
|
else
|
|
{
|
|
openOrders.AddRange(openOrdersResult.Data);
|
|
await HandleUpdateAsync(UpdateSource.Poll, openOrdersResult.Data).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
|
|
{
|
|
var openOrdersResult = await _restClient.GetOpenSpotOrdersAsync(new GetOpenOrdersRequest(symbol, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
|
|
if (!openOrdersResult.Success)
|
|
{
|
|
anyError = true;
|
|
|
|
_initialPollingError ??= openOrdersResult.Error;
|
|
if (!_firstPollDone)
|
|
break;
|
|
}
|
|
else
|
|
{
|
|
openOrders.AddRange(openOrdersResult.Data);
|
|
await HandleUpdateAsync(UpdateSource.Poll, openOrdersResult.Data).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!_firstPollDone && anyError)
|
|
return anyError;
|
|
|
|
// Check all current open orders
|
|
// Keep track of the orders no longer returned in the open list
|
|
// Order should be set to canceled state when it's no longer returned in the open list
|
|
// but also is not returned in the closed list
|
|
foreach (var order in Values.Where(x => x.Status == SharedOrderStatus.Open))
|
|
{
|
|
if (openOrders.Any(x => x.OrderId == order.OrderId))
|
|
continue;
|
|
|
|
if (!_openOrderNotReturnedTimes.ContainsKey(order.OrderId))
|
|
_openOrderNotReturnedTimes[order.OrderId] = 0;
|
|
|
|
_openOrderNotReturnedTimes[order.OrderId] += 1;
|
|
}
|
|
|
|
var updatedPollTime = DateTime.UtcNow;
|
|
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
|
|
{
|
|
DateTime? fromTimeOrders = GetClosedOrdersRequestStartTime(symbol);
|
|
|
|
|
|
var closedOrdersResult = await _restClient.GetClosedSpotOrdersAsync(new GetClosedOrdersRequest(symbol, startTime: fromTimeOrders, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
|
|
if (!closedOrdersResult.Success)
|
|
{
|
|
anyError = true;
|
|
|
|
_initialPollingError ??= closedOrdersResult.Error;
|
|
if (!_firstPollDone)
|
|
break;
|
|
}
|
|
else
|
|
{
|
|
// Filter orders to only include where close time is after the start time
|
|
var relevantOrders = closedOrdersResult.Data.Where(x =>
|
|
(x.UpdateTime != null && x.UpdateTime >= _startTime) // Updated after the tracker start time
|
|
|| (x.CreateTime != null && x.CreateTime >= _startTime) // Created after the tracker start time
|
|
|| (x.CreateTime == null && x.UpdateTime == null) // Unknown time
|
|
|| (Values.Any(e => e.OrderId == x.OrderId && x.Status == SharedOrderStatus.Open)) // Or we're currently tracking this open order
|
|
).ToArray();
|
|
|
|
// Check for orders which are no longer returned in either open/closed and assume they're canceled without fill
|
|
var openOrdersNotReturned = Values.Where(x =>
|
|
// Orders for the same symbol
|
|
x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset
|
|
// With no filled value
|
|
&& x.QuantityFilled?.IsZero == true
|
|
// Not returned in open orders
|
|
&& !openOrders.Any(r => r.OrderId == x.OrderId)
|
|
// Not returned in closed orders
|
|
&& !relevantOrders.Any(r => r.OrderId == x.OrderId)
|
|
// Open order has not been returned in the open list at least 2 times
|
|
&& (_openOrderNotReturnedTimes.TryGetValue(x.OrderId, out var notReturnedTimes) ? notReturnedTimes >= 2 : false)
|
|
).ToList();
|
|
|
|
var additionalUpdates = new List<SharedSpotOrder>();
|
|
foreach (var order in openOrdersNotReturned)
|
|
{
|
|
additionalUpdates.Add(order with
|
|
{
|
|
Status = SharedOrderStatus.Canceled
|
|
});
|
|
}
|
|
|
|
relevantOrders = relevantOrders.Concat(additionalUpdates).ToArray();
|
|
if (relevantOrders.Length > 0)
|
|
await HandleUpdateAsync(UpdateSource.Poll, relevantOrders).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
if (!anyError)
|
|
{
|
|
_lastDataTimeBeforeDisconnect = null;
|
|
_lastPollTime = updatedPollTime;
|
|
}
|
|
|
|
return anyError;
|
|
}
|
|
|
|
private DateTime? GetClosedOrdersRequestStartTime(SharedSymbol symbol)
|
|
{
|
|
// Determine the timestamp from which we need to check order status
|
|
// Use the timestamp we last know the correct state of the data
|
|
DateTime? fromTime = null;
|
|
string? source = null;
|
|
|
|
// Use the last timestamp we we received data from the websocket as state should be correct at that time.
|
|
if (_lastDataTimeBeforeDisconnect.HasValue && (fromTime == null || fromTime > _lastDataTimeBeforeDisconnect.Value))
|
|
{
|
|
fromTime = _lastDataTimeBeforeDisconnect.Value.Add(-_pollOverlapPeriod);
|
|
source = "LastDataTimeBeforeDisconnect";
|
|
}
|
|
|
|
// If we've previously polled use that timestamp to request data from
|
|
if (_lastPollTime.HasValue && (fromTime == null || _lastPollTime.Value > fromTime))
|
|
{
|
|
fromTime = _lastPollTime.Value.Add(-_pollOverlapPeriod);
|
|
source = "LastPollTime";
|
|
}
|
|
|
|
// If we known open orders with a create time before this time we need to use that timestamp to make sure that order is included in the response
|
|
var trackedOrdersMinOpenTime = Values
|
|
.Where(x => x.Status == SharedOrderStatus.Open && x.SharedSymbol!.BaseAsset == symbol.BaseAsset && x.SharedSymbol.QuoteAsset == symbol.QuoteAsset)
|
|
.OrderBy(x => x.CreateTime)
|
|
.FirstOrDefault()?.CreateTime;
|
|
if (trackedOrdersMinOpenTime.HasValue && (fromTime == null || trackedOrdersMinOpenTime.Value < fromTime))
|
|
{
|
|
// Could be improved by only requesting the specific open orders if there are only a few that would be better than trying to request a long
|
|
// history if the open order is far back
|
|
fromTime = trackedOrdersMinOpenTime.Value.AddSeconds(-1);
|
|
source = "OpenOrder";
|
|
}
|
|
|
|
if (fromTime == null)
|
|
{
|
|
fromTime = _startTime;
|
|
source = "StartTime";
|
|
}
|
|
|
|
if (DateTime.UtcNow - fromTime < TimeSpan.FromSeconds(5))
|
|
{
|
|
// Set it to at least 5 seconds in the past to prevent issues when local time isn't in sync
|
|
fromTime = DateTime.UtcNow.AddSeconds(-5);
|
|
}
|
|
|
|
_logger.LogTrace("{DataType}.{Symbol} UserDataTracker poll startTime filter based on {Source}: {Time:yyyy-MM-dd HH:mm:ss.fff}",
|
|
DataType, $"{symbol.BaseAsset}/{symbol.QuoteAsset}", source, fromTime);
|
|
return fromTime!.Value;
|
|
}
|
|
}
|
|
}
|