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