using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Reconciliation;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers.BuiltIn;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace IBKRTrader.Tests.Reconciliation;
///
/// Der Worker um den Abgleich. Die Deutung selbst ist in
/// geprüft – hier geht es um das Verhalten drumherum, vor allem um den Fall, in dem der Broker
/// nichts meldet.
///
[Trait("cat", "unit")]
public class PositionReconcileWorkerTests
{
private sealed class InMemoryFactory(DbContextOptions o) : IDbContextFactory
{
public CoreDbContext CreateDbContext() => new(o);
}
/// Der Worker ohne DB-Protokollierung; ExecuteAsync wird direkt aufgerufen.
private sealed class TestableWorker : PositionReconcileWorker
{
public TestableWorker(IPortfolioService portfolio, IBrokerPortfolioReader reader)
: base(new LoggingService(), Dbf(),
new ReconciliationService(portfolio, reader, new LoggingService())) { }
private static IDbContextFactory Dbf() =>
new InMemoryFactory(new DbContextOptionsBuilder()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
public Task RunOnceAsync(CancellationToken ct = default) => ExecuteAsync(ct);
}
private static readonly InstrumentRef Aapl = InstrumentRef.ForStock("AAPL");
[Fact]
public async Task EmptyBrokerAnswer_DoesNotWipeTheBooks()
{
// Der gefährlichste Fall: Die Leseschnittstelle liefert bei gestörter Verbindung eine
// leere Liste. Würde der Worker sie für bare Münze nehmen, setzte ein Verbindungsabbruch
// jede Position auf null - und mit ihr die gesamte Deckungslage.
var portfolio = Substitute.For();
portfolio.GetAllPositionsAsync(Arg.Any())
.Returns>(_ => [new Position("OW", Aapl, 100, 140m)]);
var reader = Substitute.For();
reader.GetPositionsAsync(Arg.Any())
.Returns>(_ => []);
await new TestableWorker(portfolio, reader).RunOnceAsync();
await portfolio.DidNotReceiveWithAnyArgs().ApplyAdjustmentAsync(
default!, default, default, default, default!, default);
}
[Fact]
public async Task EmptyOnBothSides_IsSimplyNothingToDo()
{
var portfolio = Substitute.For();
portfolio.GetAllPositionsAsync(Arg.Any())
.Returns>(_ => []);
var reader = Substitute.For();
reader.GetPositionsAsync(Arg.Any())
.Returns>(_ => []);
await new TestableWorker(portfolio, reader).RunOnceAsync();
await portfolio.DidNotReceiveWithAnyArgs().ApplyAdjustmentAsync(
default!, default, default, default, default!, default);
}
[Fact]
public async Task ForeignPosition_IsBookedToUnassigned()
{
var portfolio = Substitute.For();
portfolio.GetAllPositionsAsync(Arg.Any())
.Returns>(_ => []);
var msft = InstrumentRef.ForStock("MSFT");
var reader = Substitute.For();
reader.GetPositionsAsync(Arg.Any())
.Returns>(_ =>
[
new BrokerPosition
{
Symbol = "MSFT", SecType = "STK", Currency = "USD", ConId = 1,
Quantity = 50, AvgCost = 400m, Instrument = msft
}
]);
await new TestableWorker(portfolio, reader).RunOnceAsync();
await portfolio.Received(1).ApplyAdjustmentAsync(
"UNASSIGNED",
Arg.Is(i => i.Key == msft.Key),
50,
400m,
Arg.Any(),
Arg.Any());
}
}