using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; namespace IBKRTrader.Core.Capital; /// /// Führt Konto, Positionen, Reservierungen und Einstellungen zu dem Bild zusammen, gegen das der /// entscheidet – und bindet danach das Kapital. /// /// Die Arbeitsteilung ist Absicht: der 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. /// 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; /// Der Pool mit den konfigurierten Anteilen; ohne Vorgabe Gleichverteilung. 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); } } /// /// 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. /// public string? ValidateConfiguration() => Pool.ValidateShares(_books.StrategyBooks.ToList()); /// Momentaufnahme des Kapitals, aus der sich alle Obergrenzen ableiten. public async Task 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); } /// /// Wie viel Spielraum ein Buch gerade hat – die Vorab-Abfrage, damit ein Modul seine Order /// gleich richtig dimensioniert, statt gegen die Wand zu fahren. /// public async Task 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); } /// /// Baut den Bewertungskontext für ein Signal: Deckung, Spielraum, Konzentration und der /// Zustand des Watchdogs. /// public async Task 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) }; } /// /// 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. /// /// /// Der Teil, der eine bestehende Position schließt. Er bindet nichts – er gibt frei. /// public async Task 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); } /// /// Was ein eröffnender Auftrag bindet: Bargeld, Aktien, oder beides nicht. /// 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); } /// public Task SettleReservationAsync( long reservationId, int filledQuantity, decimal actualAmount, string? brokerOrderId = null, CancellationToken ct = default) => _reservations.SettleAsync(reservationId, filledQuantity, actualAmount, brokerOrderId, ct); /// public Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default) => _reservations.CancelAsync(reservationId, reason, ct); /// Gebundenes Kapital eines Buchs: Positionen plus offene Reservierungen. private async Task 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; } /// /// Löst den Modulnamen eines Signals in ein Buch auf. null, wenn das Modul nicht /// registriert ist – SourceModule kommt aus dem Modul und ist kein Ausweis. /// 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; } }