Kapitalmodell rechnet Waehrungen um, statt sie zu addieren
Solange jedes Instrument in USD notierte, war die Frage gegenstandslos. Mit waehrungsfaehigen Instrumenten ist sie es nicht mehr: CapitalService summierte BoundCapital ueber alle Positionen unabhaengig von deren Waehrung und verglich das Ergebnis mit NetLiquidation in Kontobasiswaehrung. Dasselbe galt fuer die Konzentration je Basiswert und fuer die offenen Reservierungen, die von einer Groesse in Basiswaehrung abgezogen wurden. Auf dem Testkonto ist die Basis EUR und USD steht bei 0,8611: jede USD-Position ging bisher rund 16 Prozent zu hoch in das verteilbare Kapital ein. Die Richtung des Fehlers wechselt mit dem Kurs - bei einer starken Fremdwaehrung wird die Auslastung unterschaetzt und ein Buch darf mehr aufmachen als es soll. Die Kurse kommen aus dem Ledger des Brokers ($LEDGER:ALL, Tag ExchangeRate), nicht aus einer eigenen Quelle. Das ist Absicht: NetLiquidation und TotalCashValue sind mit genau diesen Kursen gebildet. Mit einem fremden Kurs verglichen wir den Positionswert gegen eine Kontogroesse, die anders gerechnet wurde. Fuer die Buchhaltung gilt das Gegenteil - dort ist der amtliche Tageskurs der richtige, das ist ein anderer Zweck (#321). Fehlt ein Kurs, wird nicht geraten: der Snapshot meldet FxComplete=false, der Positionswert gilt als Untergrenze, und der Kauf wird angehalten. Schliessen bleibt moeglich. Eine Reservierung ohne Kurs wird dagegen roh angesetzt statt uebersprungen - sie zu ignorieren hiesse, gebundenes Geld als frei zu fuehren. Gemessen am Paper-Gateway: "Konto: Umrechnungskurse EUR 1, USD 0.8611 (Basis EUR)". 388 Tests gruen. Behebt #480. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -107,8 +107,18 @@ public sealed record CapitalSnapshot(
|
||||
decimal FreeCash,
|
||||
decimal PositionValue,
|
||||
decimal HouseReserve,
|
||||
decimal NetLiquidation)
|
||||
decimal NetLiquidation,
|
||||
bool FxComplete = true)
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>false</c> 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
|
||||
/// <see cref="PositionValue"/> ist dann eine <b>Untergrenze</b>, 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.
|
||||
/// </summary>
|
||||
public bool FxIncomplete => !FxComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Was insgesamt auf die Bücher verteilt wird. Wächst der Pool durch einen realisierten
|
||||
/// Gewinn, steigt der Spielraum <b>aller</b> Bücher – Diversifikation ohne einen einzigen
|
||||
|
||||
@@ -83,10 +83,33 @@ public sealed class CapitalPool
|
||||
/// Konzentration auf einen Einzelwert, gerechnet über <b>alle</b> 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.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public static decimal SymbolExposure(IEnumerable<Position> allPositions, string symbol)
|
||||
/// <param name="toBase">
|
||||
/// Rechnet den gebundenen Betrag einer Position in die Kontobasiswährung um; <c>null</c>, wenn
|
||||
/// dafür kein Kurs vorliegt.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// Die Konzentration und ob sie vollständig gerechnet werden konnte. Bei <c>Complete = false</c>
|
||||
/// ist der Wert eine Untergrenze.
|
||||
/// </returns>
|
||||
public static (decimal Value, bool Complete) SymbolExposure(
|
||||
IEnumerable<Position> allPositions, string symbol, Func<Position, decimal?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,30 @@ public sealed class CapitalService : ICapitalService
|
||||
/// <summary>Momentaufnahme des Kapitals, aus der sich alle Obergrenzen ableiten.</summary>
|
||||
public async Task<CapitalSnapshot> 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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gebundenes Kapital einer Position in Kontobasiswährung; <c>null</c>, 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.
|
||||
/// </summary>
|
||||
private static decimal? ToBase(AccountState account, Position position)
|
||||
{
|
||||
var bound = position.BoundCapital;
|
||||
return bound == 0m ? 0m : account.ToBase(bound, position.Instrument.Currency);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,27 @@ public sealed class ReservationService
|
||||
return await db.Reservations.Where(r => OpenStates.Contains(r.State)).SumAsync(r => r.Amount, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, decimal>> 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);
|
||||
}
|
||||
|
||||
/// <summary>Offen gebundene Barmittel eines Buchs.</summary>
|
||||
public async Task<decimal> GetOpenAmountAsync(BookId book, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -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<string, string> Values = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Bargeld je Waehrung aus dem $LEDGER-Abschnitt.</summary>
|
||||
public readonly Dictionary<string, decimal> Cash = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Umrechnungskurs je Waehrung in die Kontobasiswaehrung ($LEDGER, Tag ExchangeRate).</summary>
|
||||
public readonly Dictionary<string, decimal> Rates = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class ContractSlot : Slot
|
||||
|
||||
@@ -150,11 +150,41 @@ public sealed record AccountState(
|
||||
decimal NetLiquidation,
|
||||
decimal AvailableFunds,
|
||||
decimal TotalCashValue = 0m,
|
||||
IReadOnlyDictionary<string, decimal>? CashByCurrency = null)
|
||||
IReadOnlyDictionary<string, decimal>? CashByCurrency = null,
|
||||
IReadOnlyDictionary<string, decimal>? ExchangeRates = null,
|
||||
string? BaseCurrency = null)
|
||||
{
|
||||
/// <summary>Bargeld in einer bestimmten Währung; <c>null</c>, wenn dazu nichts vorliegt.</summary>
|
||||
public decimal? CashIn(string currency) =>
|
||||
CashByCurrency is not null && CashByCurrency.TryGetValue(currency, out var v) ? v : null;
|
||||
|
||||
/// <summary>
|
||||
/// Rechnet einen Betrag in die Kontobasiswährung um – <c>null</c>, wenn dafür kein Kurs
|
||||
/// vorliegt.
|
||||
///
|
||||
/// <para>Die Kurse kommen aus dem Ledger des Brokers, nicht aus einer eigenen Quelle. Das ist
|
||||
/// Absicht: <see cref="NetLiquidation"/> und <see cref="TotalCashValue"/> 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.</para>
|
||||
///
|
||||
/// <para><c>null</c> ist ein Ergebnis, kein Fehler: der Aufrufer muss entscheiden, und die
|
||||
/// sichere Entscheidung ist, den Betrag nicht als klein anzunehmen.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Umrechnung als Identität – für Fälle, in denen alles in einer Währung notiert.</summary>
|
||||
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<string, decimal> { ["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<string, decimal> { ["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<string, decimal> { ["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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user