Options-Fundament und Kapitalmodell im Core; Modul OptionsWheel
Das OptionsWheel setzt auf Dingen auf, die es im Core nicht gab. Diese Sitzung baut sie. #298 Optionen als Instrument. InstrumentKind/OptionSpec und ein druckbarer Instrumentenschluessel ("AAPL|20260918|C|150"); TradeSignal und OrderRequest tragen ein optionales OptionSpec, ohne das sich der Aktienpfad unveraendert verhaelt. Position.Notional rechnet ab jetzt mit der Kontraktgroesse - vorher war jede Option um Faktor 100 zu klein bewertet und damit jedes Risikolimit wirkungslos, lautlos und in die gefaehrliche Richtung. Daneben neu BoundCapital: ein Short Put bindet Strike x Kontraktgroesse, nicht die vereinnahmte Praemie. #300 sell-to-open. Jeder Auftrag zerfaellt in einen schliessenden und einen eroeffnenden Teil; der schliessende wird von keinem Limit verhindert - auch nicht vom Kaufstopp, denn ein Rueckkauf baut eine Verpflichtung ab. Deckungspruefung im Core, nicht im Modul: Short Call nur mit freien Aktien, Short Put nur mit reservierbarem Bargeld. #302-#305 Kapital- und Buchmodell. Buecher mit reservierten Namen und Eigentumsregeln, Pool mit Caps und Headroom, Margin-Sperre in vier Schichten, core_reservation mit Lebenszyklus, Frist, Idempotenz ueber (SignalId, ContractKey) und Quarantaene beim Start. #309/#310 Modul OptionsWheel: ow_-Schema mit fuenf Tabellen, Zustandsautomat, StrikeSelector, RollDecider, PremiumMath - vollstaendig ohne Broker testbar. Der Handelsbetrieb bleibt gesperrt, solange #299 und #294 fehlen; das Modul sagt das ueber GetActivationBlocker, statt still nichts zu tun. #293 --verify-order am Daemon: eine Order durch die vollstaendige Kette (Buch, Risiko, Bindung, Order, Fill, Buchung, Abschluss). Ohne --confirm ein Trockenlauf; Abbruch ausserhalb von Paper-Modus UND Paper-Port. Drei Widersprueche in den Konzepten sind dabei entschieden worden: Schicht B heisst ab jetzt "kein UNGEDECKTER Short" (der Grund der Regel war der Kredit, nicht der Short), die Hausreserve wird einmal abgezogen statt zweimal, und der Positionsschluessel ist eine Spalte statt eines Sechser-Verbunds (MySQL verlangt NOT NULL fuer Schluesselspalten). Begruendungen stehen in docs/ROADMAP.md. Enthaelt ausserdem die noch nicht committete Arbeit aus der vorigen Sitzung: die Fehlerbehebungen aus Epic X (WorkerBase-Intervall, BackupWorker, --check gegen die Datenbank, CapitolTrades-429, SIGTERM) samt DatabaseEndpoint, MysqlDumpTool und der Roadmap-Umstellung. Sie lag im Arbeitsbaum und liess sich nicht abtrennen, weil beide Staende dieselben Dateien beruehren. 213 -> 369 Tests, Build ohne Warnung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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 = "USD", CancellationToken ct = default)
|
||||
{
|
||||
var opening = Math.Max(0, quantity - closingQuantity);
|
||||
|
||||
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, CancellationToken ct = default) =>
|
||||
_reservations.SettleAsync(reservationId, filledQuantity, actualAmount, 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user