Zwei Maengel, sichtbar geworden beim Pruefen der vier Tabellen nach dem Order-Nachweis (#293). core_trade_history.Symbol war varchar(20) und traegt seit der Optionserweiterung den Instrumentenschluessel statt des Ticker-Symbols. "MSFT|20260918|P|302.5" sind 21 Zeichen - gekuerzt auf "MSFT|20260918|P|302." liest sich das als Strike 302. Ein still verfaelschter Eintrag in der Handelshistorie ist schlimmer als gar keiner. Jetzt 64 wie core_position.ContractKey. MarkSubmittedAsync rief niemand auf: im synchronen Pfad gibt es keinen beobachtbaren Zeitpunkt zwischen Absenden und Endstatus. BrokerOrderId blieb dadurch leer, und der Abgleich beim Neustart haette keine Bindung einer Order zuordnen koennen - genau die Aufgabe, fuer die die Spalte da ist. Die ID wird jetzt beim Abschluss festgehalten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
11 KiB
C#
247 lines
11 KiB
C#
using IBKRTrader.Core.Logging;
|
||
using IBKRTrader.Core.Settings;
|
||
using IBKRTrader.Core.Trading;
|
||
|
||
namespace IBKRTrader.Core.Capital;
|
||
|
||
/// <summary>
|
||
/// Führt Konto, Positionen, Reservierungen und Einstellungen zu dem Bild zusammen, gegen das der
|
||
/// <see cref="RiskService"/> entscheidet – und bindet danach das Kapital.
|
||
///
|
||
/// <para>Die Arbeitsteilung ist Absicht: der <see cref="RiskService"/> ist reine Rechnung und in
|
||
/// jedem Fall einzeln prüfbar, dieser Dienst holt die Zahlen. Läge beides zusammen, ließe sich
|
||
/// keine der Regeln ohne Datenbank und Broker testen.</para>
|
||
/// </summary>
|
||
public sealed class CapitalService : ICapitalService
|
||
{
|
||
private const string LogModule = "Kapital";
|
||
|
||
private readonly IPortfolioService _portfolio;
|
||
private readonly ReservationService _reservations;
|
||
private readonly BookRegistry _books;
|
||
private readonly SettingsService _settings;
|
||
private readonly LoggingService _logger;
|
||
|
||
public CapitalService(
|
||
IPortfolioService portfolio,
|
||
ReservationService reservations,
|
||
BookRegistry books,
|
||
SettingsService settings,
|
||
LoggingService logger)
|
||
{
|
||
_portfolio = portfolio;
|
||
_reservations = reservations;
|
||
_books = books;
|
||
_settings = settings;
|
||
_logger = logger;
|
||
}
|
||
|
||
private CapitalSettings Config => _settings.Settings.Capital;
|
||
|
||
/// <summary>Der Pool mit den konfigurierten Anteilen; ohne Vorgabe Gleichverteilung.</summary>
|
||
public CapitalPool Pool
|
||
{
|
||
get
|
||
{
|
||
var configured = Config.BookShares;
|
||
if (configured.Count == 0) return CapitalPool.EqualShares(_books.StrategyBooks.ToList());
|
||
|
||
var shares = configured.ToDictionary(
|
||
kv => kv.Key.Trim().ToUpperInvariant(),
|
||
kv => (decimal)kv.Value);
|
||
return new CapitalPool(shares, defaultShare: 0m);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prüft beim Start, dass die Buch-Anteile in Summe passen. Liefert den Grund, wenn nicht –
|
||
/// der Aufrufer entscheidet, ob das ein Abbruch oder eine Warnung ist.
|
||
/// </summary>
|
||
public string? ValidateConfiguration() =>
|
||
Pool.ValidateShares(_books.StrategyBooks.ToList());
|
||
|
||
/// <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 freeCash = MarginGuard.FreeCash(
|
||
account, houseReserve, openReservations, (decimal)Config.CashHaircutPercent);
|
||
|
||
// Nur die Positionen der handelnden Buecher. MANUAL und UNASSIGNED sind eine Black Box, die
|
||
// 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Wie viel Spielraum ein Buch gerade hat – die Vorab-Abfrage, damit ein Modul seine Order
|
||
/// gleich richtig dimensioniert, statt gegen die Wand zu fahren.
|
||
/// </summary>
|
||
public async Task<Headroom> GetHeadroomAsync(
|
||
BookId book, AccountState account, CancellationToken ct = default)
|
||
{
|
||
var snapshot = await GetSnapshotAsync(account, ct).ConfigureAwait(false);
|
||
var bound = await BoundOfAsync(book, ct).ConfigureAwait(false);
|
||
return Pool.HeadroomOf(book, snapshot, bound);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut den Bewertungskontext für ein Signal: Deckung, Spielraum, Konzentration und der
|
||
/// Zustand des Watchdogs.
|
||
/// </summary>
|
||
public async Task<RiskContext> BuildContextAsync(
|
||
BookId book, InstrumentRef instrument, decimal price, AccountState account,
|
||
CancellationToken ct = default)
|
||
{
|
||
var snapshot = await GetSnapshotAsync(account, ct).ConfigureAwait(false);
|
||
var bound = await BoundOfAsync(book, ct).ConfigureAwait(false);
|
||
var headroom = Pool.HeadroomOf(book, snapshot, bound);
|
||
|
||
// Deckung: Positionen des Buchs auf den Basiswert, abzueglich dessen, was bereits durch
|
||
// offene Reservierungen gebunden ist. Ohne den zweiten Teil saehen zwei gleichzeitig
|
||
// geprueften Signale dieselben 100 Aktien als frei an.
|
||
var underlying = await _portfolio
|
||
.GetPositionsForUnderlyingAsync(book.Value, instrument.Symbol, ct).ConfigureAwait(false);
|
||
var coverage = CoverageCalculator.For(underlying);
|
||
var reservedShares = await _reservations
|
||
.GetOpenSharesAsync(book, instrument.Symbol, ct).ConfigureAwait(false);
|
||
coverage = coverage with
|
||
{
|
||
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 held = await _portfolio
|
||
.GetPositionQuantityAsync(book.Value, instrument, ct).ConfigureAwait(false);
|
||
|
||
return new RiskContext
|
||
{
|
||
Price = price,
|
||
NetLiquidation = account.NetLiquidation,
|
||
ModuleExposure = bound,
|
||
ExistingQuantity = held,
|
||
Coverage = coverage,
|
||
AvailableCashForReservation = headroom.Available,
|
||
BookHeadroom = headroom.Available,
|
||
SymbolExposure = symbolExposure,
|
||
SymbolLimit = symbolLimit,
|
||
BuyingHalted = MarginGuard.BuyingShouldHalt(
|
||
account, (decimal)Config.WatchdogMinCashPercent)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bindet das Kapital für einen freigegebenen Auftrag – atomar gegen den aktuellen
|
||
/// Reservierungsstand. Erst hier entsteht der Schutz gegen zwei Module, die dasselbe Geld sehen:
|
||
/// der Bewertungskontext oben ist eine Momentaufnahme, diese Prüfung läuft unter dem Order-Lock.
|
||
/// </summary>
|
||
/// <param name="closingQuantity">
|
||
/// Der Teil, der eine bestehende Position schließt. Er bindet nichts – er gibt frei.
|
||
/// </param>
|
||
public async Task<ReservationService.ReserveResult> ReserveAsync(
|
||
BookId book, TradeSignal signal, InstrumentRef instrument, TradeSide side,
|
||
int quantity, int closingQuantity, decimal price, OrderType orderType,
|
||
AccountState account, string? currency = null, CancellationToken ct = default)
|
||
{
|
||
var opening = Math.Max(0, quantity - closingQuantity);
|
||
|
||
// Die Waehrung kommt aus dem Instrument, nicht vom Aufrufer: sie ist Teil der Identitaet
|
||
// des Kontrakts, und die Deckungspruefung muss gegen das Bargeld GENAU dieser Waehrung
|
||
// laufen. Ein Aufrufer, der hier etwas anderes uebergeben koennte, koennte die Pruefung
|
||
// gegen den falschen Topf laufen lassen.
|
||
currency = instrument.Currency;
|
||
|
||
return await _reservations.ReserveAsync(
|
||
book, instrument, side, quantity, currency, signal.SignalId,
|
||
load =>
|
||
{
|
||
if (opening == 0)
|
||
return ReservationService.ReservationRequest.Bind(0m); // reines Schliessen
|
||
|
||
var (amount, shares) = RequiredBinding(instrument, side, opening, price, orderType);
|
||
|
||
// Schicht C: der Kauf muss aus Bargeld derselben Waehrung finanzierbar sein.
|
||
if (amount > 0m)
|
||
{
|
||
var breach = MarginGuard.CheckCurrencyCash(
|
||
account, currency, amount, load.CashInCurrency);
|
||
if (breach is not null) return ReservationService.ReservationRequest.Reject(breach);
|
||
}
|
||
|
||
return ReservationService.ReservationRequest.Bind(amount, shares);
|
||
}, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Was ein eröffnender Auftrag bindet: Bargeld, Aktien, oder beides nicht.
|
||
/// </summary>
|
||
private (decimal Amount, int Shares) RequiredBinding(
|
||
InstrumentRef instrument, TradeSide side, int quantity, decimal price, OrderType orderType)
|
||
{
|
||
if (side == TradeSide.Buy)
|
||
{
|
||
// Reserviert wird mit Puffer, weil der Ausfuehrungspreis noch offen ist. Bei einer
|
||
// Limit-Order ist das Limit selbst schon die Obergrenze, deshalb ein kleiner Puffer;
|
||
// bei Market ist er groesser (Slippage). Die Differenz zum echten Fill fliesst beim
|
||
// Abschluss zurueck.
|
||
var buffer = orderType == OrderType.Limit
|
||
? (decimal)Config.LimitOrderBufferPercent
|
||
: (decimal)Config.MarketOrderBufferPercent;
|
||
|
||
var cost = quantity * price * instrument.Multiplier;
|
||
return (cost * (1m + buffer / 100m), 0);
|
||
}
|
||
|
||
if (instrument.Option is not { } option)
|
||
return (0m, 0); // Aktienverkauf ohne Bestand kommt hier nicht mehr an (Schicht B)
|
||
|
||
// Geschriebene Option. Der Put bindet Bargeld in exakt bekannter Hoehe - kein Puffer noetig,
|
||
// Strike x Kontraktgroesse ist der Betrag, den eine Zuteilung kostet, auf den Cent.
|
||
return option.Right == OptionRight.Put
|
||
? (quantity * option.CashCoveragePerContract, 0)
|
||
: (0m, quantity * option.CoverageShares);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task SettleReservationAsync(
|
||
long reservationId, int filledQuantity, decimal actualAmount, string? brokerOrderId = null,
|
||
CancellationToken ct = default) =>
|
||
_reservations.SettleAsync(reservationId, filledQuantity, actualAmount, brokerOrderId, ct);
|
||
|
||
/// <inheritdoc />
|
||
public Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default) =>
|
||
_reservations.CancelAsync(reservationId, reason, ct);
|
||
|
||
/// <summary>Gebundenes Kapital eines Buchs: Positionen plus offene Reservierungen.</summary>
|
||
private async Task<decimal> BoundOfAsync(BookId book, CancellationToken ct)
|
||
{
|
||
var positions = await _portfolio.GetModuleExposureAsync(book.Value, ct).ConfigureAwait(false);
|
||
var reserved = await _reservations.GetOpenAmountAsync(book, ct).ConfigureAwait(false);
|
||
return positions + reserved;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Löst den Modulnamen eines Signals in ein Buch auf. <c>null</c>, wenn das Modul nicht
|
||
/// registriert ist – <c>SourceModule</c> kommt aus dem Modul und ist kein Ausweis.
|
||
/// </summary>
|
||
public BookId? ResolveBook(string? sourceModule)
|
||
{
|
||
var book = _books.Resolve(sourceModule);
|
||
if (book is null)
|
||
_logger.Error(LogModule,
|
||
$"Signal von unbekanntem Buch '{sourceModule}' abgelehnt. Zulässig sind nur die beim " +
|
||
$"Start registrierten Module: {string.Join(", ", _books.StrategyBooks.Select(b => b.Value))}.");
|
||
return book;
|
||
}
|
||
}
|