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();
}
}