1
\$\begingroup\$

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 >= that orderId. 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);
    }
}
```
\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.