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