using FluentAssertions;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Tests.Capital;
///
/// Die Margin-Sperre in vier Schichten (#304). Sie ist testpflichtig, weil sie das Einzige ist,
/// was zwischen einem Cash-Konto mit brokerseitig verfügbarer Margin und einem kreditfinanzierten
/// Modul-Handel steht.
///
[Trait("cat", "unit")]
public class MarginGuardTests
{
private static AccountState Account(
decimal netLiq = 100_000m, decimal available = 50_000m, decimal cash = 20_000m,
Dictionary? byCurrency = null) =>
new(netLiq, available, cash, byCurrency);
private static OptionSpec Put150 => new("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put);
private static OptionSpec Call150 => new("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Call);
// ─── Schicht A: Bemessungsgrundlage ──────────────────────────────────────────
[Fact]
public void FreeCash_UsesRealCash_NotTheLeveragedAvailableFunds()
{
// AvailableFunds 50.000 ist auf einem Margin-Konto bereits gehebelt. Massgeblich sind die
// 20.000 echtes Bargeld.
MarginGuard.FreeCash(Account(available: 50_000m, cash: 20_000m), 0m, 0m)
.Should().Be(20_000m);
}
[Fact]
public void FreeCash_SubtractsHouseReserveAndOpenReservations()
{
MarginGuard.FreeCash(Account(cash: 20_000m), houseReserve: 5_000m, openReservations: 3_000m)
.Should().Be(12_000m);
}
[Fact]
public void FreeCash_AppliesTheHaircutBeforeTheDeductions()
{
// 20.000 abzueglich 10 % = 18.000, davon 5.000 Reserve = 13.000.
MarginGuard.FreeCash(Account(cash: 20_000m), 5_000m, 0m, haircutPercent: 10m)
.Should().Be(13_000m);
}
[Fact]
public void ManualMarginPosition_ShrinksModuleBuyingPowerAutomatically()
{
// Der Betreiber eroeffnet von Hand eine Margin-Position: TotalCashValue faellt auf 0,
// AvailableFunds bleibt hoch. Die Modul-Kaufkraft ist damit null - ohne Sonderlogik.
MarginGuard.FreeCash(Account(available: 40_000m, cash: 0m), 0m, 0m).Should().Be(0m);
}
[Fact]
public void FreeCash_IsNeverNegative()
{
MarginGuard.FreeCash(Account(cash: 1_000m), houseReserve: 9_000m, openReservations: 0m)
.Should().Be(0m);
}
// ─── Schicht B: kein ungedeckter Short ───────────────────────────────────────
private static CoverageCalculator.Coverage Cover(int shares, int bound = 0) =>
new(shares, bound, 0m);
[Fact]
public void SellingStockWithoutHolding_IsAlwaysRefused()
{
// Ein Aktien-Short ist immer ein Kredit. Diese Schicht bleibt unveraendert scharf.
var breach = MarginGuard.CheckShortCoverage(
InstrumentRef.ForStock("AAPL"), quantity: 10, heldQuantity: 0, Cover(0), 1_000_000m);
breach.Should().NotBeNull();
breach!.Kind.Should().Be(LimitKind.Coverage);
breach.MaxQuantity.Should().Be(0);
}
[Fact]
public void SellingStockYouHold_IsNotACoverageQuestion()
{
MarginGuard.CheckShortCoverage(
InstrumentRef.ForStock("AAPL"), quantity: 10, heldQuantity: 10, Cover(10), 0m)
.Should().BeNull();
}
[Fact]
public void CoveredCall_IsAllowed_WhenEnoughFreeShares()
{
MarginGuard.CheckShortCoverage(
InstrumentRef.ForOption(Call150), quantity: 1, heldQuantity: 0, Cover(100), 0m)
.Should().BeNull();
}
[Fact]
public void SecondCallAgainstTheSameHundredShares_IsRefused()
{
// 100 Aktien decken EINEN Call, nicht zwei. Der erste hat sie bereits gebunden.
var breach = MarginGuard.CheckShortCoverage(
InstrumentRef.ForOption(Call150), quantity: 1, heldQuantity: 0,
Cover(shares: 100, bound: 100), 1_000_000m);
breach.Should().NotBeNull();
breach!.Kind.Should().Be(LimitKind.Coverage);
}
[Fact]
public void CashSecuredPut_NeedsStrikeTimesMultiplierInCash()
{
var instrument = InstrumentRef.ForOption(Put150);
// 15.000 reichen fuer genau einen Kontrakt.
MarginGuard.CheckShortCoverage(instrument, 1, 0, Cover(0), 15_000m).Should().BeNull();
var breach = MarginGuard.CheckShortCoverage(instrument, 1, 0, Cover(0), 14_999m);
breach.Should().NotBeNull();
breach!.Current.Should().Be(15_000m);
breach.Limit.Should().Be(14_999m);
}
[Fact]
public void PartlyCoveredSale_ReportsWhatWouldStillBePossible()
{
// 32.000 Bargeld tragen zwei Kontrakte zu je 15.000, nicht drei.
var breach = MarginGuard.CheckShortCoverage(
InstrumentRef.ForOption(Put150), quantity: 3, heldQuantity: 0, Cover(0), 32_000m);
breach.Should().NotBeNull();
breach!.MaxQuantity.Should().Be(2);
}
// ─── Schicht C: Währung ──────────────────────────────────────────────────────
[Fact]
public void BuyingInACurrency_NeedsCashInThatCurrency()
{
var account = Account(byCurrency: new() { ["USD"] = 10_000m, ["EUR"] = 500m });
MarginGuard.CheckCurrencyCash(account, "USD", 5_000m, 0m).Should().BeNull();
var breach = MarginGuard.CheckCurrencyCash(account, "EUR", 5_000m, 0m);
breach.Should().NotBeNull();
breach!.Kind.Should().Be(LimitKind.CurrencyCash);
breach.Scope.Should().Be("EUR");
}
[Fact]
public void ReservationsInTheSameCurrency_ReduceWhatIsAvailable()
{
var account = Account(byCurrency: new() { ["USD"] = 10_000m });
MarginGuard.CheckCurrencyCash(account, "USD", 5_000m, reservedInCurrency: 6_000m)
.Should().NotBeNull();
}
[Fact]
public void WithoutACurrencyBreakdown_TheBuyIsRefused_NotWavedThrough()
{
// Eine unbekannte Waehrungsdeckung ist keine vorhandene. Ein positiver Gesamtsaldo sagt
// nichts darueber, ob in DIESER Waehrung Geld da ist - ein Sollsaldo darin waere ein Kredit.
MarginGuard.CheckCurrencyCash(Account(byCurrency: null), "USD", 100m, 0m)
.Should().NotBeNull();
}
// ─── Schicht D: Watchdog ─────────────────────────────────────────────────────
[Fact]
public void BuyingHalts_WhenCashFallsBelowTheThreshold()
{
MarginGuard.BuyingShouldHalt(Account(netLiq: 100_000m, cash: 1_500m), minCashPercent: 2m)
.Should().BeTrue();
MarginGuard.BuyingShouldHalt(Account(netLiq: 100_000m, cash: 2_500m), minCashPercent: 2m)
.Should().BeFalse();
}
[Fact]
public void UnknownAccountValue_HaltsBuying()
{
MarginGuard.BuyingShouldHalt(Account(netLiq: 0m, cash: 0m), 2m).Should().BeTrue();
}
}