using FluentAssertions; using IBKRTrader.Core.Capital; using IBKRTrader.Core.Trading; namespace IBKRTrader.Tests.Capital; /// /// Bücher, Pool und Obergrenzen (#302, #303) – die Invarianten aus dem Kapitalmodell, jede /// einzeln prüfbar, weil die Rechnung ohne Datenbank und ohne Broker auskommt. /// [Trait("cat", "unit")] public class CapitalModelTests { private static readonly BookId Ct = BookId.ForModule("CT"); private static readonly BookId Ow = BookId.ForModule("OW"); // ─── Bücher und Eigentumsregeln ────────────────────────────────────────────── [Theory] [InlineData("MANUAL")] [InlineData("manual")] [InlineData("UNASSIGNED")] [InlineData("HOUSE")] public void ReservedBookNames_CannotBeClaimedByAModule(string name) { // Sonst gaebe sich ein Modul als MANUAL aus und waere damit von jedem Limit befreit. var act = () => BookId.ForModule(name); act.Should().Throw(); } [Fact] public void OnlyStrategyBooks_MayTrade_AndAreSubjectToLimits() { Ct.MayTrade.Should().BeTrue(); Ct.SubjectToLimits.Should().BeTrue(); BookId.Manual.MayTrade.Should().BeFalse(); BookId.Manual.SubjectToLimits.Should().BeFalse(); BookId.Unassigned.MayTrade.Should().BeFalse(); BookId.House.MayTrade.Should().BeFalse(); } [Fact] public void Registry_RejectsUnknownModules() { var registry = new BookRegistry(["CT", "OW"]); registry.Resolve("CT").Should().Be(Ct); registry.Resolve("ct").Should().Be(Ct); // Schreibweise egal registry.Resolve("Tippfehler").Should().BeNull(); // ein Buch ohne Cap waere unbegrenzt registry.Resolve(null).Should().BeNull(); } [Fact] public void Registry_NeverCreatesReservedBooks() { var registry = new BookRegistry(["CT", "MANUAL", "HOUSE"]); registry.StrategyBooks.Should().ContainSingle().Which.Should().Be(Ct); } // ─── Pool ──────────────────────────────────────────────────────────────────── [Fact] public void Distributable_IsFreeCashPlusPositions_WithTheHouseReserveTakenOnlyOnce() { // Die Hausreserve steckt bereits in FreeCash (§4 Schicht A). Wuerde sie hier erneut // abgezogen, laege bei 5 % Reserve rund ein Zehntel des Kapitals still, ohne dass es // irgendwo sichtbar waere. var snapshot = new CapitalSnapshot( FreeCash: 9_500m, PositionValue: 4_000m, HouseReserve: 500m, NetLiquidation: 14_000m); snapshot.Distributable.Should().Be(13_500m); } [Fact] public void Distributable_IsNeverNegative() { new CapitalSnapshot(0m, 0m, 5_000m, 1_000m).Distributable.Should().Be(0m); } [Fact] public void EqualShares_SplitsTheDistributableEvenly() { var pool = CapitalPool.EqualShares([Ct, Ow]); var snapshot = new CapitalSnapshot(10_000m, 0m, 0m, 10_000m); pool.CapOf(Ct, snapshot).Should().Be(5_000m); pool.CapOf(Ow, snapshot).Should().Be(5_000m); } [Fact] public void SharesOverOneHundredPercent_AreRefusedAtStartup() { // Sonst entscheidet nicht die Konfiguration, wer wie viel bekommt, sondern wer zuerst // handelt - ein schnelles Buch hungerte ein langsames aus. var pool = new CapitalPool( new Dictionary { ["CT"] = 0.7m, ["OW"] = 0.6m }, defaultShare: 0m); pool.ValidateShares([Ct, Ow]).Should().NotBeNull().And.Contain("verdrängte"); } [Fact] public void SharesWithinOneHundredPercent_AreAccepted() { var pool = new CapitalPool( new Dictionary { ["CT"] = 0.5m, ["OW"] = 0.4m }, defaultShare: 0m); pool.ValidateShares([Ct, Ow]).Should().BeNull(); } [Fact] public void NegativeShare_IsRefused() { var pool = new CapitalPool( new Dictionary { ["CT"] = -0.1m }, defaultShare: 0m); pool.ValidateShares([Ct]).Should().NotBeNull().And.Contain("Negativer Anteil"); } // ─── Spielraum ─────────────────────────────────────────────────────────────── [Fact] public void Headroom_IsTheSmallerOfRemainingCapAndFreeCash() { // Cap 5.000, davon 1.000 gebunden -> 4.000 Rest; aber nur 2.500 Bargeld frei. var headroom = new Headroom(Ct, Cap: 5_000m, Bound: 1_000m, FreePoolCash: 2_500m); headroom.Available.Should().Be(2_500m); headroom.OverCap.Should().BeFalse(); } [Fact] public void BookOverItsCap_IsLegal_ButHasNoHeadroomLeft() { // Ein zulaessiger Zustand: steigen die Positionen im Wert, passiert nichts, ausser dass // nicht mehr nachgekauft werden darf. Geheilt wird er nie - es wird niemals eine Position // geschlossen, um eine Zielquote herzustellen. var headroom = new Headroom(Ct, Cap: 5_000m, Bound: 9_000m, FreePoolCash: 20_000m); headroom.OverCap.Should().BeTrue(); headroom.Available.Should().Be(0m); } // ─── Konzentration ─────────────────────────────────────────────────────────── [Fact] public void SymbolExposure_CountsEveryBook_IncludingManual() { // Haelt der Betreiber die Haelfte seines Kontos in AAPL von Hand, sind die Module bei AAPL // gesperrt. So gewollt: MANUAL ist von der Pruefung befreit, zaehlt aber voll in den Zaehler. var positions = new[] { new Position("CT", InstrumentRef.ForStock("AAPL"), 10, 100m), new Position("MANUAL", InstrumentRef.ForStock("AAPL"), 40, 100m), new Position("CT", InstrumentRef.ForStock("MSFT"), 10, 200m) }; CapitalPool.SymbolExposure(positions, "AAPL", AsIs).Value.Should().Be(5_000m); CapitalPool.SymbolExposure(positions, "aapl", AsIs).Value.Should().Be(5_000m); CapitalPool.SymbolExposure(positions, "MSFT", AsIs).Value.Should().Be(2_000m); } /// Umrechnung als Identität – für Fälle, in denen alles in einer Währung notiert. private static decimal? AsIs(Position p) => p.BoundCapital; [Fact] public void SymbolExposure_OfAShortPut_CountsTheObligation() { var spec = new OptionSpec("AAPL", new DateOnly(2026, 9, 18), 150m, OptionRight.Put); var positions = new[] { new Position("OW", InstrumentRef.ForOption(spec), -1, 2.50m) }; // Nicht 250 (Praemie), sondern 15.000 (Verpflichtung). CapitalPool.SymbolExposure(positions, "AAPL", AsIs).Value.Should().Be(15_000m); } // ─── Konzentration über Währungen hinweg (#480) ────────────────────────────── [Fact] public void SymbolExposure_ConvertsForeignCurrency_BeforeAdding() { // Derselbe Basiswert in zwei Währungen: die EUR-Notierung und die USD-Notierung. Ohne // Umrechnung stünden hier 2.000 "Einheiten", die es so nicht gibt. var positions = new[] { new Position("OW", InstrumentRef.ForStock("SAP", currency: "EUR"), 10, 100m), new Position("CT", InstrumentRef.ForStock("SAP", currency: "USD"), 10, 100m) }; // EUR steht bei 1,10 zur Basiswährung USD. var account = new AccountState( NetLiquidation: 100_000m, AvailableFunds: 50_000m, TotalCashValue: 50_000m, CashByCurrency: null, ExchangeRates: new Dictionary { ["EUR"] = 1.10m, ["USD"] = 1m }, BaseCurrency: "USD"); var (value, complete) = CapitalPool.SymbolExposure( positions, "SAP", p => account.ToBase(p.BoundCapital, p.Instrument.Currency)); value.Should().Be(2_100m, "1.000 EUR sind 1.100 USD, dazu 1.000 USD"); complete.Should().BeTrue(); } [Fact] public void SymbolExposure_WithoutRate_ReportsIncomplete() { var positions = new[] { new Position("OW", InstrumentRef.ForStock("SAP", currency: "EUR"), 10, 100m), new Position("CT", InstrumentRef.ForStock("SAP", currency: "USD"), 10, 100m) }; // Der Broker hat für EUR keinen Kurs geliefert. var account = new AccountState( NetLiquidation: 100_000m, AvailableFunds: 50_000m, TotalCashValue: 50_000m, CashByCurrency: null, ExchangeRates: new Dictionary { ["USD"] = 1m }, BaseCurrency: "USD"); var (value, complete) = CapitalPool.SymbolExposure( positions, "SAP", p => account.ToBase(p.BoundCapital, p.Instrument.Currency)); value.Should().Be(1_000m, "der EUR-Teil fehlt – das Ergebnis ist eine Untergrenze"); complete.Should().BeFalse("und genau das muss der Aufrufer erfahren"); } // ─── Umrechnung selbst ────────────────────────────────────────────────────── [Fact] public void ToBase_UsesTheBrokersRate_AndAnswersNullWhenUnknown() { var account = new AccountState( NetLiquidation: 0m, AvailableFunds: 0m, TotalCashValue: 0m, CashByCurrency: null, ExchangeRates: new Dictionary { ["EUR"] = 1.10m, ["USD"] = 1m }, BaseCurrency: "USD"); account.ToBase(100m, "EUR").Should().Be(110m); account.ToBase(100m, "eur").Should().Be(110m, "Währungskürzel sind nicht schreibungsabhängig"); account.ToBase(100m, "USD").Should().Be(100m); account.ToBase(100m, "CHF").Should().BeNull("ohne Kurs gibt es kein Ergebnis, nur eine Lücke"); account.ToBase(100m, null).Should().BeNull(); } [Fact] public void ToBase_WithoutAnyRates_KnowsOnlyTheBaseCurrency() { // Ein Konto, das nur in einer Währung geführt wird: der Broker liefert dann keine Kurse. var account = new AccountState(NetLiquidation: 1_000m, AvailableFunds: 1_000m); account.ToBase(100m, "USD").Should().BeNull( "ohne Basiswährung und ohne Kurse ist keine Umrechnung belegbar"); } }