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