using FluentAssertions; using IBKRTrader.Core.Capital; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Tests.Capital; /// /// Reservierungen über den Order-Lebenszyklus (#305), gegen die EF-InMemory-Datenbank – kein /// externer Zugriff, deterministisch. /// [Trait("cat", "unit")] public class ReservationServiceTests { private sealed class InMemoryFactory(DbContextOptions options) : IDbContextFactory { public CoreDbContext CreateDbContext() => new(options); } private static readonly BookId Ow = BookId.ForModule("OW"); private static readonly InstrumentRef Aapl = InstrumentRef.ForStock("AAPL"); private static ReservationService CreateSut( out IDbContextFactory factory, int ttlMinutes = 120) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; factory = new InMemoryFactory(options); var settings = new SettingsService(); settings.Settings.Capital.ReservationTtlMinutes = ttlMinutes; return new ReservationService(factory, settings, new LoggingService()); } private static Func Bind(decimal amount, int shares = 0) => _ => ReservationService.ReservationRequest.Bind(amount, shares); // ─── Binden ────────────────────────────────────────────────────────────────── [Fact] public async Task Reserve_BindsCapital_AndCountsAsOpen() { var sut = CreateSut(out _); var result = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); result.Success.Should().BeTrue(); result.WasExisting.Should().BeFalse(); (await sut.GetOpenAmountAsync()).Should().Be(1_000m); (await sut.GetOpenAmountAsync(Ow)).Should().Be(1_000m); } [Fact] public async Task Reserve_PassesTheCurrentLoadToTheCheck() { // Genau dafuer laeuft die Pruefung unter dem Schloss: sie muss den Stand sehen, der die // bereits gebundenen Betraege enthaelt - sonst geben zwei Signale dasselbe Geld aus. var sut = CreateSut(out _); await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); ReservationService.ReservationLoad seen = default; await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-2", load => { seen = load; return ReservationService.ReservationRequest.Bind(500m); }); seen.TotalCash.Should().Be(1_000m); seen.CashOfBook.Should().Be(1_000m); seen.CashInCurrency.Should().Be(1_000m); } [Fact] public async Task Reserve_CanBeRefusedByTheCheck() { var sut = CreateSut(out _); var breach = new LimitBreach(LimitKind.CashAvailable, "AAPL", 5_000m, 100m, 0, 100m); var result = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", _ => ReservationService.ReservationRequest.Reject(breach)); result.Success.Should().BeFalse(); result.Breach.Should().Be(breach); (await sut.GetOpenAmountAsync()).Should().Be(0m); } [Fact] public async Task SameSignalTwice_ReturnsTheExistingBinding_InsteadOfASecond() { // Der Reconnect-Fall. Ohne diese Sperre entstuende eine zweite Order fuer dasselbe Signal. var sut = CreateSut(out _); var first = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); var second = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); second.WasExisting.Should().BeTrue(); second.Reservation!.Id.Should().Be(first.Reservation!.Id); (await sut.GetOpenAmountAsync()).Should().Be(1_000m); } [Fact] public async Task SameSignalForADifferentContract_IsItsOwnBinding() { var sut = CreateSut(out _); var put = InstrumentRef.ForOption( new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put)); await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); var second = await sut.ReserveAsync(Ow, put, TradeSide.Sell, 1, "USD", "sig-1", Bind(15_000m)); second.WasExisting.Should().BeFalse(); (await sut.GetOpenAmountAsync()).Should().Be(16_000m); } [Fact] public async Task WrittenCalls_BindShares_NotCash() { var sut = CreateSut(out _); var call = InstrumentRef.ForOption( new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Call)); await sut.ReserveAsync(Ow, call, TradeSide.Sell, 1, "USD", "sig-1", Bind(0m, shares: 100)); (await sut.GetOpenAmountAsync()).Should().Be(0m); (await sut.GetOpenSharesAsync(Ow, "AAPL")).Should().Be(100); } // ─── Abschließen ───────────────────────────────────────────────────────────── [Fact] public async Task Settle_FullFill_ReleasesTheBinding_BecauseItBecameAPosition() { // Sonst zaehlte dasselbe Kapital doppelt: einmal als Reservierung, einmal als Position. var sut = CreateSut(out _); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_050m)); await sut.SettleAsync(r.Reservation!.Id, filledQuantity: 10, actualAmount: 1_000m); (await sut.GetOpenAmountAsync()).Should().Be(0m); } [Fact] public async Task Settle_NoFill_ReleasesEverything() { var sut = CreateSut(out _); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_050m)); await sut.SettleAsync(r.Reservation!.Id, filledQuantity: 0, actualAmount: 0m); (await sut.GetOpenAmountAsync()).Should().Be(0m); } [Fact] public async Task Settle_PartialFill_KeepsTheRestBound() { var sut = CreateSut(out var factory); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await sut.SettleAsync(r.Reservation!.Id, filledQuantity: 4, actualAmount: 400m); // 6 von 10 Stueck offen -> 60 % der Bindung bleibt stehen. (await sut.GetOpenAmountAsync()).Should().Be(600m); await using var db = await factory.CreateDbContextAsync(); var row = await db.Reservations.FindAsync(r.Reservation!.Id); row!.State.Should().Be(ReservationState.PartiallyFilled); row.Quantity.Should().Be(6); } [Fact] public async Task Cancel_ReleasesTheBinding() { var sut = CreateSut(out _); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await sut.CancelAsync(r.Reservation!.Id, "Order abgelehnt"); (await sut.GetOpenAmountAsync()).Should().Be(0m); } // ─── Frist ─────────────────────────────────────────────────────────────────── [Fact] public async Task OverdueBinding_IsReported_NotSilentlyReleased() { // Eine haengende Order, deren Reservierung stillschweigend freigegeben wird, gibt dasselbe // Geld zweimal aus. Deshalb: Alarm, und der Zustand heisst Expired, nicht Cancelled. var sut = CreateSut(out var factory, ttlMinutes: 1); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await using (var db = await factory.CreateDbContextAsync()) { var row = await db.Reservations.FindAsync(r.Reservation!.Id); row!.ExpiresAt = DateTime.UtcNow.AddMinutes(-5); await db.SaveChangesAsync(); } var overdue = await sut.ExpireOverdueAsync(); overdue.Should().ContainSingle(); overdue[0].State.Should().Be(ReservationState.Expired); } [Fact] public async Task BindingsWithinTheirDeadline_AreLeftAlone() { var sut = CreateSut(out _); await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); (await sut.ExpireOverdueAsync()).Should().BeEmpty(); } // ─── Absturzwiederherstellung ──────────────────────────────────────────────── [Fact] public async Task Recovery_KeepsBindingsWhoseBrokerOrderIsStillOpen() { var sut = CreateSut(out _); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await sut.MarkSubmittedAsync(r.Reservation!.Id, "O-42"); var orphans = await sut.RecoverAsync(["O-42"]); orphans.Should().BeEmpty(); (await sut.GetOpenAmountAsync()).Should().Be(1_000m); } [Fact] public async Task Recovery_QuarantinesOrphans_AndKeepsTheirCapitalBound() { // Zwei Erklaerungen mit entgegengesetzter Folge: die Order wurde nie abgeschickt (Geld darf // zurueck) oder sie wurde ausgefuehrt, waehrend wir weg waren (es gibt eine Position, die // wir nicht kennen). Welche zutrifft, entscheidet nicht diese Methode. var sut = CreateSut(out _); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await sut.MarkSubmittedAsync(r.Reservation!.Id, "O-42"); var orphans = await sut.RecoverAsync(["O-99"]); orphans.Should().ContainSingle(); orphans[0].State.Should().Be(ReservationState.Quarantined); (await sut.GetOpenAmountAsync()).Should().Be(1_000m); } [Fact] public async Task Recovery_QuarantinesBindingsThatNeverReachedTheBroker() { var sut = CreateSut(out _); await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); var orphans = await sut.RecoverAsync([]); orphans.Should().ContainSingle(); orphans[0].BrokerOrderId.Should().BeNull(); } [Fact] public async Task Recovery_IgnoresFinishedBindings() { var sut = CreateSut(out _); var r = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await sut.SettleAsync(r.Reservation!.Id, 10, 1_000m); (await sut.RecoverAsync([])).Should().BeEmpty(); } // ─── Offene Betraege je Waehrung (#480) ────────────────────────────────────── [Fact] public async Task OpenAmounts_AreGroupedByCurrency_NotSummedAcrossThem() { var sut = CreateSut(out _); var sap = InstrumentRef.ForStock("SAP", "EUR"); await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await sut.ReserveAsync(Ow, sap, TradeSide.Buy, 5, "EUR", "sig-2", Bind( 500m)); await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 2, "USD", "sig-3", Bind( 200m)); var byCurrency = await sut.GetOpenAmountByCurrencyAsync(); byCurrency.Should().HaveCount(2); byCurrency["USD"].Should().Be(1_200m); byCurrency["EUR"].Should().Be(500m); // Die alte Gesamtsumme addiert weiterhin ueber Waehrungen hinweg - genau deshalb darf // sie nicht mehr als Geldbetrag verwendet werden. (await sut.GetOpenAmountAsync()).Should().Be(1_700m); } [Fact] public async Task ClosedReservations_DoNotShowUpPerCurrency() { var sut = CreateSut(out _); var result = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); await sut.CancelAsync(result.Reservation!.Id, "Test"); (await sut.GetOpenAmountByCurrencyAsync()).Should().BeEmpty(); } }