using FluentAssertions; using IBKRTrader.Core.Capital; using IBKRTrader.Core.Trading; namespace IBKRTrader.Tests.Trading; /// /// Sell-to-open und Deckungsprüfung im Core (#300). /// /// Bis hierher lehnte EvaluateSell jeden Verkauf ohne Bestand grundsätzlich ab – und /// genau das ist die Kernoperation des OptionsWheels. Die Prüfung sitzt bewusst im Core: ein /// Modul, das sich selbst prüft, prüft sich irgendwann nicht mehr. /// [Trait("cat", "unit")] public class RiskServiceOptionTests { private readonly RiskService _risk = new(); private static readonly DateOnly Expiry = new(2026, 9, 18); private static readonly RiskParameters Params = new(5m, 20m, 5m); private static OptionSpec Put(decimal strike = 150m) => new("AAPL", Expiry, strike, OptionRight.Put); private static OptionSpec Call(decimal strike = 150m) => new("AAPL", Expiry, strike, OptionRight.Call); private static TradeSignal Sell(OptionSpec spec, int? qty = 1) => new() { Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "OW", Option = spec, SuggestedQuantity = qty }; private static TradeSignal Buy(OptionSpec spec, int? qty = null) => new() { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "OW", Option = spec, SuggestedQuantity = qty }; /// Reichlich Konto, reichlich Spielraum – damit nur die geprüfte Regel greift. private static RiskContext Ctx( decimal price = 2.50m, int held = 0, int shares = 0, int boundShares = 0, decimal cash = 1_000_000m, decimal bookHeadroom = 1_000_000m, decimal symbolExposure = 0m, decimal symbolLimit = 0m, bool halted = false) => new() { Price = price, NetLiquidation = 1_000_000m, ExistingQuantity = held, Coverage = new CoverageCalculator.Coverage(shares, boundShares, 0m), AvailableCashForReservation = cash, BookHeadroom = bookHeadroom, SymbolExposure = symbolExposure, SymbolLimit = symbolLimit, BuyingHalted = halted }; // ─── Cash-Secured Put ──────────────────────────────────────────────────────── [Fact] public void SellingAPutWithoutHolding_IsApproved_WhenCashCoversTheObligation() { var d = _risk.Evaluate(Sell(Put()), Ctx(cash: 15_000m), Params); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(1); d.ClosingQuantity.Should().Be(0); // eroeffnet, schliesst nichts } [Fact] public void SellingAPut_IsRefused_WhenCashIsShort() { var d = _risk.Evaluate(Sell(Put()), Ctx(cash: 14_999m), Params); d.Approved.Should().BeFalse(); d.Breach!.Kind.Should().Be(LimitKind.Coverage); d.Reason.Should().Contain("Deckung"); } [Fact] public void SellingThreePuts_WithCashForTwo_IsRefused_AndSaysTwoWouldWork() { var d = _risk.Evaluate(Sell(Put(), qty: 3), Ctx(cash: 32_000m), Params); d.Approved.Should().BeFalse(); d.Breach!.MaxQuantity.Should().Be(2); } // ─── Covered Call ──────────────────────────────────────────────────────────── [Fact] public void SellingACall_IsApproved_WithOneHundredFreeShares() { var d = _risk.Evaluate(Sell(Call()), Ctx(shares: 100), Params); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(1); } [Fact] public void SellingASecondCallAgainstTheSameShares_IsRefused() { var d = _risk.Evaluate(Sell(Call()), Ctx(shares: 100, boundShares: 100), Params); d.Approved.Should().BeFalse(); d.Breach!.Kind.Should().Be(LimitKind.Coverage); } [Fact] public void CashDoesNotCoverACall_OnlySharesDo() { // Ein Call wird mit Stuecken gedeckt, nicht mit Geld. Eine Million Bargeld traegt keinen // einzigen geschriebenen Call. var d = _risk.Evaluate(Sell(Call()), Ctx(shares: 0, cash: 1_000_000m), Params); d.Approved.Should().BeFalse(); } // ─── Schließen ist nie limitiert ───────────────────────────────────────────── [Fact] public void BuyingToCloseAWrittenContract_IsAllowed_EvenWhenBuyingIsHalted() { // Ein Rueckkauf baut eine Verpflichtung ab. Der Kaufstopp soll Neuengagements verhindern, // nicht deren Aufloesung - sonst sperrt die Sicherung den Ausgang. var d = _risk.Evaluate(Buy(Put()), Ctx(held: -2, halted: true, cash: 0m, bookHeadroom: 0m), Params); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(2); d.ClosingQuantity.Should().Be(2); } [Fact] public void BuyingToClose_IsCappedAtTheWrittenQuantity() { var d = _risk.Evaluate(Buy(Put(), qty: 5), Ctx(held: -2), Params); d.Quantity.Should().Be(2); } [Fact] public void SellingToCloseABoughtContract_IsAllowed_WithoutAnyCoverage() { var d = _risk.Evaluate(Sell(Put(), qty: 1), Ctx(held: 3, cash: 0m), Params); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(1); d.ClosingQuantity.Should().Be(1); } // ─── Grenzen des eröffnenden Teils ─────────────────────────────────────────── [Fact] public void OpeningIsRefused_WhenTheBookHasNoHeadroomLeft() { var d = _risk.Evaluate(Sell(Put()), Ctx(cash: 1_000_000m, bookHeadroom: 10_000m), Params); d.Approved.Should().BeFalse(); d.Breach!.Kind.Should().Be(LimitKind.BookCap); } [Fact] public void OpeningIsRefused_WhenItWouldBreachTheSymbolConcentration() { var d = _risk.Evaluate(Sell(Put()), Ctx(cash: 1_000_000m, symbolExposure: 40_000m, symbolLimit: 50_000m), Params); d.Approved.Should().BeFalse(); d.Breach!.Kind.Should().Be(LimitKind.SymbolConcentration); } [Fact] public void BuyingAnOption_SizesByContractCost_NotByShareCost() { // Kurs 2,50 je Kontrakt = 250 je Stueck. Bei 5 % von 1.000.000 = 50.000 Budget waeren das // 200 Kontrakte - ohne den Multiplikator kaeme das Hundertfache heraus. var d = _risk.Evaluate(Buy(Put()), Ctx(price: 2.50m), Params); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(200); } [Fact] public void BuyingIsRefused_WhileTheWatchdogHoldsTheLine() { var d = _risk.Evaluate(Buy(Put()), Ctx(halted: true), Params); d.Approved.Should().BeFalse(); d.Breach!.Kind.Should().Be(LimitKind.BuyingHalted); } // ─── Der Aktienpfad bleibt, wie er war ─────────────────────────────────────── [Fact] public void SellingStockWithoutHolding_IsStillRefused() { var signal = new TradeSignal { Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT" }; var d = _risk.Evaluate(signal, Ctx(price: 100m, cash: 1_000_000m), Params); d.Approved.Should().BeFalse(); d.Reason.Should().Contain("Keine Position"); } [Fact] public void SellingStockYouHold_StillClosesTheWholePosition() { var signal = new TradeSignal { Symbol = "AAPL", Side = TradeSide.Sell, SourceModule = "CT" }; var d = _risk.Evaluate(signal, Ctx(price: 100m, held: 7), Params); d.Approved.Should().BeTrue(); d.Quantity.Should().Be(7); d.ClosingQuantity.Should().Be(7); } }