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>
|
||||
|
||||
Reference in New Issue
Block a user