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;
}
}