Files
IBKRTrader/src/IBKRTrader.Core/Capital/CapitalService.cs
T
RichardandClaude Opus 5 614392afc3 Handelshistorie fasst Optionsschluessel; Order-ID landet in der Bindung
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>
2026-09-04 15:46:56 +02:00

247 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}