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