This service is pulling off the whole trade history for the Binance accounts. More precisely, it's a wrapper around https://binance-docs.github.io/apidocs/spot/en/#all-orders-user_data. Since their API doesn't incorporate a pagination system like all normal exchanges, I had to make a workaround i.e. a while loop which takes advantages of the following:
If
orderId
is set, it will get orders >= thatorderId
. Otherwise most recent orders are returned.
There is a hundred percent coverage of the branches in that questionable method. I also take advantage of the Decorator pattern (Scrutor) which I use for Redis caching.
I think I should keep up the Result<T>
style and make GetTradeHistoryAsync
use it. There are other stuff that I'm probably unable to see at the moment, so I would like to get a code review.
public interface IAccountantService
{
Task<List<BinanceOrder>> GetTradeHistoryAsync(
string instrument,
long orderId = 0,
int batchSize = 1000,
CancellationToken cancellationToken = default);
}
public sealed class CachedAccountantService : IAccountantService
{
private readonly IConnectionMultiplexer _connectionMultiplexer;
private readonly IAccountantService _accountantService;
public CachedAccountantService(IConnectionMultiplexer connectionMultiplexer, IAccountantService accountantService)
{
_connectionMultiplexer = connectionMultiplexer;
_accountantService = accountantService;
}
public async Task<List<BinanceOrder>> GetTradeHistoryAsync(
string instrument,
long orderId = 0,
int batchSize = 1000,
CancellationToken cancellationToken = default)
{
var db = _connectionMultiplexer.GetDatabase();
var orders = new List<BinanceOrder>();
var cacheKey = $"trade-history:{instrument}";
var cachedTradeHistory = db.StringGet(cacheKey);
if (cachedTradeHistory.HasValue)
{
orders = JsonSerializer.Deserialize<List<BinanceOrder>>(cachedTradeHistory!)!;
orderId = orders.Last().Id + 1;
}
var result = await _accountantService.GetTradeHistoryAsync(instrument, orderId, batchSize, cancellationToken);
orders.AddRange(result);
if (result.Count > 0)
{
db.StringSet(cacheKey, JsonSerializer.Serialize(orders), TimeSpan.FromMinutes(5));
}
return orders;
}
}
public sealed class AccountantService : IAccountantService
{
private readonly IBinanceClientAdapter _restClient;
public AccountantService(IBinanceClientAdapter restClient)
{
_restClient = restClient;
}
public async Task<List<BinanceOrder>> GetTradeHistoryAsync(
string instrument,
long orderId = 0,
int batchSize = 1000,
CancellationToken cancellationToken = default)
{
if (batchSize is < 0 or > 1000)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size cannot be negative or above 1000");
}
var list = new List<BinanceOrder>();
while (!cancellationToken.IsCancellationRequested)
{
var result = await _restClient.GetOrdersAsync(
instrument,
orderId: orderId,
limit: batchSize,
ct: cancellationToken);
// Failed to fetch the API
if (!result.IsSuccess)
{
throw new InvalidOperationException("Failed to fetch the API");
}
// No trades were found for the specified instrument
if (!result.Entity.Any())
{
break;
}
list.AddRange(result.Entity);
orderId = result.Entity.Last().Id + 1;
}
return list;
}
}
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAccountant(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddTransient<IAccountantService, AccountantService>();
services.Decorate<IAccountantService, CachedAccountantService>();
services.AddSingleton<IConnectionMultiplexer>(x =>
ConnectionMultiplexer.Connect("localhost,password=mypassword"));
services.AddTransient<IBinanceClientAdapter, BinanceClientAdapter>();
return services;
}
}
public interface IBinanceClientAdapter
{
ValueTask<Result<IEnumerable<BinanceOrder>>> GetOrdersAsync(
string symbol,
long? orderId = null,
int? limit = null,
CancellationToken ct = default);
}
public sealed class BinanceClientAdapter : IBinanceClientAdapter
{
private readonly IBinanceClient _restClient;
public BinanceClientAdapter(IBinanceClient restClient)
{
_restClient = restClient;
}
public async ValueTask<Result<IEnumerable<BinanceOrder>>> GetOrdersAsync(
string symbol,
long? orderId = null,
int? limit = null,
CancellationToken ct = default)
{
var result = await _restClient.SpotApi.Trading.GetOrdersAsync(symbol, orderId, limit: limit, ct: ct);
return !result.Success
? new InvalidOperationError(result.Error!.Message)
: Result<IEnumerable<BinanceOrder>>.FromSuccess(result.Data);
}
}
public class AccountantServiceTests
{
private readonly IAccountantService _sut;
private readonly IBinanceClientAdapter _restClient = Substitute.For<IBinanceClientAdapter>();
public AccountantServiceTests()
{
_sut = new AccountantService(_restClient);
}
[Fact]
public async Task GetTradeHistoryAsync_ShouldThrowArgumentOutOfRangeException_WhenGivenNegativeBatchSize()
{
// Arrange
// Act
var action = () => _sut.GetTradeHistoryAsync("SHIBBUSD", orderId: 0, batchSize: -1);
// Assert
await action.Should()
.ThrowAsync<ArgumentOutOfRangeException>()
.WithMessage("Batch size cannot be negative or above 1000 (Parameter 'batchSize')");
}
[Fact]
public async Task GetTradeHistoryAsync_ShouldThrowInvalidOperationException_WhenItFailsToFetchTheAPI()
{
// Arrange
_restClient.GetOrdersAsync(
Arg.Any<string>(),
Arg.Any<long?>(),
Arg.Any<int?>(),
Arg.Any<CancellationToken>()
)
.Returns(Result<IEnumerable<BinanceOrder>>.FromError(new InvalidOperationError()));
// Act
var action = () => _sut.GetTradeHistoryAsync("SHIBBUSD", orderId: 0);
// Assert
await action.Should()
.ThrowAsync<InvalidOperationException>()
.WithMessage("Failed to fetch the API");
}
[Fact]
public async Task GetTradeHistoryAsync_ShouldReturnSixElements_WhenOrderIdIsSetToZero()
{
// Arrange
var orders = new List<BinanceOrder>
{
new() { Id = 1 },
new() { Id = 2 },
new() { Id = 3 }
};
var orders2 = new List<BinanceOrder>
{
new() { Id = 4 },
new() { Id = 5 },
new() { Id = 6 }
};
_restClient.GetOrdersAsync(
Arg.Any<string>(),
Arg.Is<long?>(0),
Arg.Any<int?>(),
Arg.Any<CancellationToken>()
)
.Returns(Result<IEnumerable<BinanceOrder>>.FromSuccess(orders));
_restClient.GetOrdersAsync(
Arg.Any<string>(),
Arg.Is<long?>(4),
Arg.Any<int?>(),
Arg.Any<CancellationToken>()
)
.Returns(Result<IEnumerable<BinanceOrder>>.FromSuccess(orders2));
_restClient.GetOrdersAsync(
Arg.Any<string>(),
Arg.Is<long?>(7),
Arg.Any<int?>(),
Arg.Any<CancellationToken>()
)
.Returns(Result<IEnumerable<BinanceOrder>>.FromSuccess(Enumerable.Empty<BinanceOrder>()));
// Act
var result = await _sut.GetTradeHistoryAsync("SHIBBUSD", orderId: 0);
// Assert
result.Count.Should().Be(6);
}
}
```