diff --git a/src/IBKRTrader.Core/Capital/CapitalModels.cs b/src/IBKRTrader.Core/Capital/CapitalModels.cs index 9995346..c961440 100644 --- a/src/IBKRTrader.Core/Capital/CapitalModels.cs +++ b/src/IBKRTrader.Core/Capital/CapitalModels.cs @@ -107,8 +107,18 @@ public sealed record CapitalSnapshot( decimal FreeCash, decimal PositionValue, decimal HouseReserve, - decimal NetLiquidation) + decimal NetLiquidation, + bool FxComplete = true) { + /// + /// false heißt: mindestens ein Betrag ließ sich nicht in die Kontobasiswährung + /// umrechnen, weil der Broker für dessen Währung keinen Kurs geliefert hat. Der Wert von + /// ist dann eine Untergrenze, kein Ergebnis – und aus einer + /// Untergrenze darf kein Spielraum abgeleitet werden. Der Aufrufer hält den Kauf an, statt zu + /// raten; Schließen bleibt möglich. + /// + public bool FxIncomplete => !FxComplete; + /// /// Was insgesamt auf die Bücher verteilt wird. Wächst der Pool durch einen realisierten /// Gewinn, steigt der Spielraum aller Bücher – Diversifikation ohne einen einzigen diff --git a/src/IBKRTrader.Core/Capital/CapitalPool.cs b/src/IBKRTrader.Core/Capital/CapitalPool.cs index 9e5b397..9ad8803 100644 --- a/src/IBKRTrader.Core/Capital/CapitalPool.cs +++ b/src/IBKRTrader.Core/Capital/CapitalPool.cs @@ -83,10 +83,33 @@ public sealed class CapitalPool /// Konzentration auf einen Einzelwert, gerechnet über alle Bücher – MANUAL /// eingeschlossen. Der Nenner ist der Kontowert, nicht der Positionswert: sonst wäre das Limit /// bei viel Bargeld absurd eng und bei wenig Bargeld absurd weit. + /// + /// Gerechnet wird in Kontobasiswährung. Ein Basiswert kann in mehreren Währungen im Buch + /// stehen – dieselbe Aktie als EUR-Notierung und als USD-ADR –, und ohne Umrechnung addierte + /// das Limit Beträge, die nicht gleich viel wert sind. /// - public static decimal SymbolExposure(IEnumerable allPositions, string symbol) + /// + /// Rechnet den gebundenen Betrag einer Position in die Kontobasiswährung um; null, wenn + /// dafür kein Kurs vorliegt. + /// + /// + /// Die Konzentration und ob sie vollständig gerechnet werden konnte. Bei Complete = false + /// ist der Wert eine Untergrenze. + /// + public static (decimal Value, bool Complete) SymbolExposure( + IEnumerable allPositions, string symbol, Func toBase) { - var ticker = symbol.Trim().ToUpperInvariant(); - return allPositions.Where(p => p.Symbol == ticker).Sum(p => p.BoundCapital); + var ticker = symbol.Trim().ToUpperInvariant(); + var sum = 0m; + var complete = true; + + foreach (var position in allPositions.Where(p => p.Symbol == ticker)) + { + var converted = toBase(position); + if (converted is null) { complete = false; continue; } + sum += converted.Value; + } + + return (sum, complete); } } diff --git a/src/IBKRTrader.Core/Capital/CapitalService.cs b/src/IBKRTrader.Core/Capital/CapitalService.cs index ba2e5ac..6da3bae 100644 --- a/src/IBKRTrader.Core/Capital/CapitalService.cs +++ b/src/IBKRTrader.Core/Capital/CapitalService.cs @@ -63,8 +63,30 @@ public sealed class CapitalService : ICapitalService /// Momentaufnahme des Kapitals, aus der sich alle Obergrenzen ableiten. public async Task GetSnapshotAsync(AccountState account, CancellationToken ct = default) { - var openReservations = await _reservations.GetOpenAmountAsync(ct).ConfigureAwait(false); - var houseReserve = account.NetLiquidation * ((decimal)Config.HouseReservePercent / 100m); + var complete = true; + + // Reservierungen liegen in der Waehrung der jeweiligen Order. Sie werden gleich von einer + // Groesse in Kontobasiswaehrung abgezogen - ohne Umrechnung zoege eine EUR-Reservierung + // Dollar ab. + var reservationsByCurrency = await _reservations + .GetOpenAmountByCurrencyAsync(ct).ConfigureAwait(false); + + var openReservations = 0m; + foreach (var (currency, amount) in reservationsByCurrency) + { + var converted = account.ToBase(amount, currency); + if (converted is null) + { + // Eine Reservierung, die sich nicht umrechnen laesst, wird trotzdem angesetzt - + // roh, ohne Kurs. Sie zu ueberspringen hiesse, gebundenes Geld als frei zu fuehren. + complete = false; + openReservations += amount; + continue; + } + openReservations += converted.Value; + } + + var houseReserve = account.NetLiquidation * ((decimal)Config.HouseReservePercent / 100m); var freeCash = MarginGuard.FreeCash( account, houseReserve, openReservations, (decimal)Config.CashHaircutPercent); @@ -73,11 +95,44 @@ public sealed class CapitalService : ICapitalService // ausschliesslich ueber das freie Bargeld wirkt - wuerde man sie hier addieren, vergroesserte // ein von Hand aufgebauter Bestand die Kaufkraft der Module. var all = await _portfolio.GetAllPositionsAsync(ct).ConfigureAwait(false); - var strategyValue = all - .Where(p => _books.IsRegistered(p.Module)) - .Sum(p => p.BoundCapital); - return new CapitalSnapshot(freeCash, strategyValue, houseReserve, account.NetLiquidation); + var strategyValue = 0m; + var unconverted = new List(); + + foreach (var position in all.Where(p => _books.IsRegistered(p.Module))) + { + var converted = ToBase(account, position); + if (converted is null) + { + // Hier gilt das Gegenteil des Reservierungsfalls: der Positionswert vergroessert + // das Verteilbare. Einen Betrag ohne Kurs mitzuzaehlen hiesse, den Buechern + // Spielraum aus einer Zahl zu geben, deren Groesse wir nicht kennen. + complete = false; + unconverted.Add($"{position.Symbol} ({position.Instrument.Currency})"); + continue; + } + strategyValue += converted.Value; + } + + if (unconverted.Count > 0) + _logger.Warn(LogModule, + $"Kein Umrechnungskurs für {string.Join(", ", unconverted)} – diese Positionen " + + "fehlen im verteilbaren Kapital. Neue Käufe werden angehalten, bis der Broker " + + "Kurse liefert."); + + return new CapitalSnapshot( + freeCash, strategyValue, houseReserve, account.NetLiquidation, complete); + } + + /// + /// Gebundenes Kapital einer Position in Kontobasiswährung; null, wenn dafür kein Kurs + /// vorliegt. Bindet eine Position gar nichts (etwa ein gedeckter Call), ist das Ergebnis 0 – + /// dafür braucht es keinen Kurs. + /// + private static decimal? ToBase(AccountState account, Position position) + { + var bound = position.BoundCapital; + return bound == 0m ? 0m : account.ToBase(bound, position.Instrument.Currency); } /// @@ -117,9 +172,11 @@ public sealed class CapitalService : ICapitalService SharesBoundByShortCalls = coverage.SharesBoundByShortCalls + reservedShares }; - var all = await _portfolio.GetAllPositionsAsync(ct).ConfigureAwait(false); - var symbolExposure = CapitalPool.SymbolExposure(all, instrument.Symbol); - var symbolLimit = account.NetLiquidation * ((decimal)Config.MaxSymbolPercent / 100m); + var all = await _portfolio.GetAllPositionsAsync(ct).ConfigureAwait(false); + var symbolLimit = account.NetLiquidation * ((decimal)Config.MaxSymbolPercent / 100m); + + var (symbolExposure, symbolComplete) = + CapitalPool.SymbolExposure(all, instrument.Symbol, p => ToBase(account, p)); var held = await _portfolio .GetPositionQuantityAsync(book.Value, instrument, ct).ConfigureAwait(false); @@ -135,8 +192,13 @@ public sealed class CapitalService : ICapitalService BookHeadroom = headroom.Available, SymbolExposure = symbolExposure, SymbolLimit = symbolLimit, + // Auch ein Kaufstopp, solange die Umrechnung unvollständig ist: Cap, Spielraum und + // Konzentration stehen dann auf Zahlen, deren Groesse wir nicht kennen. Verkaufen und + // Schliessen bleiben moeglich - angehalten wird nur das Aufmachen. BuyingHalted = MarginGuard.BuyingShouldHalt( account, (decimal)Config.WatchdogMinCashPercent) + || snapshot.FxIncomplete + || !symbolComplete }; } diff --git a/src/IBKRTrader.Core/Capital/ReservationService.cs b/src/IBKRTrader.Core/Capital/ReservationService.cs index b3e8840..8b7f2f2 100644 --- a/src/IBKRTrader.Core/Capital/ReservationService.cs +++ b/src/IBKRTrader.Core/Capital/ReservationService.cs @@ -253,6 +253,27 @@ public sealed class ReservationService return await db.Reservations.Where(r => OpenStates.Contains(r.State)).SumAsync(r => r.Amount, ct); } + /// + /// Offen gebundene Barmittel, nach Währung getrennt. Die Summe über alle Währungen ist ohne + /// Umrechnung wertlos – sie addierte Euro auf Dollar. Wer den Gesamtbetrag braucht, rechnet + /// die Posten einzeln in die Kontobasiswährung um. + /// + public async Task> GetOpenAmountByCurrencyAsync( + CancellationToken ct = default) + { + await using var db = await _dbf.CreateDbContextAsync(ct); + var rows = await db.Reservations + .Where(r => OpenStates.Contains(r.State) && r.Amount != 0m) + .GroupBy(r => r.Currency) + .Select(g => new { Currency = g.Key, Amount = g.Sum(r => r.Amount) }) + .ToListAsync(ct); + + return rows.ToDictionary( + r => (r.Currency ?? "").Trim().ToUpperInvariant(), + r => r.Amount, + StringComparer.OrdinalIgnoreCase); + } + /// Offen gebundene Barmittel eines Buchs. public async Task GetOpenAmountAsync(BookId book, CancellationToken ct = default) { diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index ec5bf83..216118a 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -379,11 +379,23 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable slot.Cash.Select(kv => string.Create(CultureInfo.InvariantCulture, $"{kv.Key} {kv.Value:N2}"))) + "."); + // Die Kontobasiswaehrung ist die mit Kurs 1: der Broker nennt sie nicht ausdruecklich, + // aber er rechnet sie auch nicht um. + var baseCurrency = slot.Rates.FirstOrDefault(kv => kv.Value == 1m).Key; + + if (slot.Rates.Count > 0) + _logger.Info(LogModule, "Konto: Umrechnungskurse " + string.Join(", ", + slot.Rates.Select(kv => string.Create(CultureInfo.InvariantCulture, + $"{kv.Key} {kv.Value:0.####}"))) + + (baseCurrency is null ? "." : $" (Basis {baseCurrency}).")); + return new AccountState( ReadDecimal(slot.Values, "NetLiquidation"), ReadDecimal(slot.Values, "AvailableFunds"), ReadDecimal(slot.Values, "TotalCashValue"), - slot.Cash.Count == 0 ? null : slot.Cash); + slot.Cash.Count == 0 ? null : slot.Cash, + slot.Rates.Count == 0 ? null : slot.Rates, + baseCurrency); } finally { @@ -570,6 +582,18 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable { slot.Cash[currency.ToUpperInvariant()] = cash; } + + // Der Umrechnungskurs in die Kontobasiswaehrung, ebenfalls aus dem Ledger. Damit rechnet + // das Kapitalmodell Positionswerte und Reservierungen fremder Waehrungen um - und zwar mit + // genau den Kursen, aus denen der Broker auch NetLiquidation bildet. + if (tag.Equals("ExchangeRate", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(currency) && + !currency.Equals("BASE", StringComparison.OrdinalIgnoreCase) && + decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var rate) && + rate > 0m) + { + slot.Rates[currency.ToUpperInvariant()] = rate; + } } public override void accountSummaryEnd(int reqId) @@ -771,6 +795,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable public readonly Dictionary Values = new(StringComparer.OrdinalIgnoreCase); /// Bargeld je Waehrung aus dem $LEDGER-Abschnitt. public readonly Dictionary Cash = new(StringComparer.OrdinalIgnoreCase); + /// Umrechnungskurs je Waehrung in die Kontobasiswaehrung ($LEDGER, Tag ExchangeRate). + public readonly Dictionary Rates = new(StringComparer.OrdinalIgnoreCase); } private sealed class ContractSlot : Slot diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs index 35f3eba..9eb56a5 100644 --- a/src/IBKRTrader.Core/Trading/TradingModels.cs +++ b/src/IBKRTrader.Core/Trading/TradingModels.cs @@ -150,11 +150,41 @@ public sealed record AccountState( decimal NetLiquidation, decimal AvailableFunds, decimal TotalCashValue = 0m, - IReadOnlyDictionary? CashByCurrency = null) + IReadOnlyDictionary? CashByCurrency = null, + IReadOnlyDictionary? ExchangeRates = null, + string? BaseCurrency = null) { /// Bargeld in einer bestimmten Währung; null, wenn dazu nichts vorliegt. public decimal? CashIn(string currency) => CashByCurrency is not null && CashByCurrency.TryGetValue(currency, out var v) ? v : null; + + /// + /// Rechnet einen Betrag in die Kontobasiswährung um – null, wenn dafür kein Kurs + /// vorliegt. + /// + /// Die Kurse kommen aus dem Ledger des Brokers, nicht aus einer eigenen Quelle. Das ist + /// Absicht: und sind mit genau diesen + /// Kursen gebildet. Mit einem fremden Kurs (etwa dem EZB-Referenzkurs) verglichen wir den + /// Positionswert gegen eine Kontogröße, die anders gerechnet wurde – die Differenz landete + /// unbemerkt im Spielraum der Bücher. Für die Buchhaltung gilt das Gegenteil, dort ist der + /// amtliche Tageskurs der richtige; das ist ein anderer Zweck und ein anderes Item. + /// + /// null ist ein Ergebnis, kein Fehler: der Aufrufer muss entscheiden, und die + /// sichere Entscheidung ist, den Betrag nicht als klein anzunehmen. + /// + public decimal? ToBase(decimal amount, string? currency) + { + if (string.IsNullOrWhiteSpace(currency)) return null; + + var code = currency.Trim().ToUpperInvariant(); + if (BaseCurrency is not null && + code.Equals(BaseCurrency, StringComparison.OrdinalIgnoreCase)) return amount; + + if (ExchangeRates is not null && ExchangeRates.TryGetValue(code, out var rate) && rate > 0m) + return amount * rate; + + return null; + } } /// diff --git a/tests/IBKRTrader.Tests/Capital/CapitalModelTests.cs b/tests/IBKRTrader.Tests/Capital/CapitalModelTests.cs index 203cab0..000ebe9 100644 --- a/tests/IBKRTrader.Tests/Capital/CapitalModelTests.cs +++ b/tests/IBKRTrader.Tests/Capital/CapitalModelTests.cs @@ -156,11 +156,14 @@ public class CapitalModelTests new Position("CT", InstrumentRef.ForStock("MSFT"), 10, 200m) }; - CapitalPool.SymbolExposure(positions, "AAPL").Should().Be(5_000m); - CapitalPool.SymbolExposure(positions, "aapl").Should().Be(5_000m); - CapitalPool.SymbolExposure(positions, "MSFT").Should().Be(2_000m); + 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() { @@ -168,6 +171,84 @@ public class CapitalModelTests var positions = new[] { new Position("OW", InstrumentRef.ForOption(spec), -1, 2.50m) }; // Nicht 250 (Praemie), sondern 15.000 (Verpflichtung). - CapitalPool.SymbolExposure(positions, "AAPL").Should().Be(15_000m); + 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"); } } diff --git a/tests/IBKRTrader.Tests/Capital/ReservationServiceTests.cs b/tests/IBKRTrader.Tests/Capital/ReservationServiceTests.cs index 3f18fdf..b847f82 100644 --- a/tests/IBKRTrader.Tests/Capital/ReservationServiceTests.cs +++ b/tests/IBKRTrader.Tests/Capital/ReservationServiceTests.cs @@ -269,4 +269,37 @@ public class ReservationServiceTests (await sut.RecoverAsync([])).Should().BeEmpty(); } + + // ─── Offene Betraege je Waehrung (#480) ────────────────────────────────────── + + [Fact] + public async Task OpenAmounts_AreGroupedByCurrency_NotSummedAcrossThem() + { + var sut = CreateSut(out _); + var sap = InstrumentRef.ForStock("SAP", "EUR"); + + await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); + await sut.ReserveAsync(Ow, sap, TradeSide.Buy, 5, "EUR", "sig-2", Bind( 500m)); + await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 2, "USD", "sig-3", Bind( 200m)); + + var byCurrency = await sut.GetOpenAmountByCurrencyAsync(); + + byCurrency.Should().HaveCount(2); + byCurrency["USD"].Should().Be(1_200m); + byCurrency["EUR"].Should().Be(500m); + + // Die alte Gesamtsumme addiert weiterhin ueber Waehrungen hinweg - genau deshalb darf + // sie nicht mehr als Geldbetrag verwendet werden. + (await sut.GetOpenAmountAsync()).Should().Be(1_700m); + } + + [Fact] + public async Task ClosedReservations_DoNotShowUpPerCurrency() + { + var sut = CreateSut(out _); + var result = await sut.ReserveAsync(Ow, Aapl, TradeSide.Buy, 10, "USD", "sig-1", Bind(1_000m)); + await sut.CancelAsync(result.Reservation!.Id, "Test"); + + (await sut.GetOpenAmountByCurrencyAsync()).Should().BeEmpty(); + } }