Options-Fundament und Kapitalmodell im Core; Modul OptionsWheel
Build & Test / build (ubuntu-latest) (push) Canceled after 0s
Build & Test / build (windows-latest) (push) Canceled after 0s

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:
Richard
2026-09-04 09:35:38 +02:00
co-authored by Claude Opus 5
parent 9f66183f1c
commit 084cd7ef77
99 changed files with 9947 additions and 445 deletions
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Logging;
@@ -37,31 +38,31 @@ public static class DossierBuilder
public static string ToMarkdown(TradeDossier d)
{
var sb = new StringBuilder();
sb.AppendLine($"# Dossier – Signal `{d.SignalId}`");
sb.AppendLine(Inv($"# Dossier – Signal `{d.SignalId}`"));
sb.AppendLine();
sb.AppendLine("## Entscheidungen");
if (d.Decisions.Count == 0) sb.AppendLine("_(keine)_");
foreach (var r in d.Decisions)
sb.AppendLine($"- {r.Timestamp:u} · **{r.Decision}** ({r.Reason}) · {r.Module} · {r.Side} {r.Symbol} @ {r.SignalPrice:F2} · {r.Message}");
sb.AppendLine(Inv($"- {r.Timestamp:u} · **{r.Decision}** ({r.Reason}) · {r.Module} · {r.Side} {r.Symbol} @ {r.SignalPrice:F2} · {r.Message}"));
sb.AppendLine();
sb.AppendLine("## Order-Events");
if (d.OrderEvents.Count == 0) sb.AppendLine("_(keine)_");
foreach (var e in d.OrderEvents)
sb.AppendLine($"- {e.Timestamp:u} · **{e.EventType}** · {e.Side} {e.Quantity}x {e.Symbol} @ {e.Price:F2} ({e.OrderType}) · {e.Response}");
sb.AppendLine(Inv($"- {e.Timestamp:u} · **{e.EventType}** · {e.Side} {e.Quantity}x {e.Symbol} @ {e.Price:F2} ({e.OrderType}) · {e.Response}"));
sb.AppendLine();
sb.AppendLine("## Trades");
if (d.Trades.Count == 0) sb.AppendLine("_(keine)_");
foreach (var t in d.Trades)
sb.AppendLine($"- {t.TradedAt:u} · {t.Action} {t.Quantity}x {t.Symbol} @ {t.Price:F2} = {t.TotalValue:F2} · {t.Status}");
sb.AppendLine(Inv($"- {t.TradedAt:u} · {t.Action} {t.Quantity}x {t.Symbol} @ {t.Price:F2} = {t.TotalValue:F2} · {t.Status}"));
sb.AppendLine();
sb.AppendLine("## Log-Auszug");
if (d.LogLines.Count == 0) sb.AppendLine("_(keine passenden JSONL-Zeilen)_");
foreach (var l in d.LogLines)
sb.AppendLine($"- {l.Ts:u} [{l.Level}] {l.Source}: {l.Message}");
sb.AppendLine(Inv($"- {l.Ts:u} [{l.Level}] {l.Source}: {l.Message}"));
return sb.ToString();
}
@@ -92,4 +93,11 @@ public static class DossierBuilder
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
/// <summary>
/// Ein Dossier ist ein Prüfdokument: dieselbe Zahl muss auf jeder Instanz gleich dastehen,
/// unabhängig von der Kultur des Hosts. Der Prozess läuft ohnehin invariant (AppHostBuilder);
/// hier steht es zusätzlich ausdrücklich, weil der Text das Dokument selbst ist.
/// </summary>
private static string Inv(FormattableString text) => text.ToString(CultureInfo.InvariantCulture);
}
+145
View File
@@ -0,0 +1,145 @@
namespace IBKRTrader.Core.Capital;
/// <summary>Art eines Buchs. Sie entscheidet, ob es handeln darf und ob Limits für es gelten.</summary>
public enum BookKind
{
/// <summary>Ein Strategiemodul. Handelt, Limits gelten.</summary>
Strategy,
/// <summary>Von Hand in TWS angelegt. Handelt nicht über uns, Limits gelten nicht – zählt aber mit.</summary>
Manual,
/// <summary>Beim Broker gefunden, keinem Buch zuzuordnen. Handelt nicht, zählt mit.</summary>
Unassigned,
/// <summary>Nicht allokierte Reserve, FX, Ausgleichsposten.</summary>
House
}
/// <summary>
/// Kennung eines Kapitaltopfs mit zugeordneten Positionen.
///
/// <para><b>Ein Buch ist nicht dasselbe wie ein Modul</b>, auch wenn die Vorgabe
/// <c>BookId == Modulname</c> lautet. Die Trennung erlaubt später mehrere Bücher desselben Moduls
/// und macht MANUAL, UNASSIGNED und HOUSE überhaupt erst modellierbar. Jetzt kostenlos, später
/// teuer nachzurüsten.</para>
/// </summary>
public readonly record struct BookId
{
/// <summary>Für Bestände, die der Betreiber selbst in TWS angelegt hat.</summary>
public static readonly BookId Manual = new("MANUAL", BookKind.Manual);
/// <summary>Für Bestände, die der Abgleich beim Broker fand und niemandem zuordnen konnte.</summary>
public static readonly BookId Unassigned = new("UNASSIGNED", BookKind.Unassigned);
/// <summary>Für die nicht allokierte Reserve.</summary>
public static readonly BookId House = new("HOUSE", BookKind.House);
/// <summary>
/// Buch der Diagnose-Aufträge: der Verifikationsorder aus <c>--verify-order</c> und später des
/// DebugTrading-Moduls.
///
/// <para>Ein eigenes Buch, damit eine Prüforder nicht in der Buchführung eines Strategiemoduls
/// landet. Sonst verfälschte sie dessen Auslastung, dessen GuV und dessen Konzentration – und
/// zwar dauerhaft, weil die Position echt ist.</para>
///
/// <para>Es handelt, also ist es ein Strategiebuch und unterliegt allen Limits.</para>
/// </summary>
public static readonly BookId Diagnostics = new("DIAG", BookKind.Strategy);
private BookId(string value, BookKind kind)
{
Value = value;
Kind = kind;
}
public string Value { get; }
public BookKind Kind { get; }
/// <summary>Buch eines Strategiemoduls. Die reservierten Namen sind hier nicht zulässig.</summary>
public static BookId ForModule(string module)
{
var name = (module ?? "").Trim().ToUpperInvariant();
if (name.Length == 0)
throw new ArgumentException("Buchname darf nicht leer sein.", nameof(module));
if (IsReserved(name))
throw new ArgumentException(
$"'{name}' ist ein reservierter Buchname und keinem Modul zuweisbar.", nameof(module));
return new BookId(name, BookKind.Strategy);
}
/// <summary>Liest einen gespeicherten Buchnamen zurück, einschließlich der reservierten.</summary>
public static BookId Parse(string value)
{
var name = (value ?? "").Trim().ToUpperInvariant();
return name switch
{
"MANUAL" => Manual,
"UNASSIGNED" => Unassigned,
"HOUSE" => House,
_ => ForModule(name)
};
}
public static bool IsReserved(string name) =>
name.Equals("MANUAL", StringComparison.OrdinalIgnoreCase) ||
name.Equals("UNASSIGNED", StringComparison.OrdinalIgnoreCase) ||
name.Equals("HOUSE", StringComparison.OrdinalIgnoreCase);
/// <summary>Darf dieses Buch Signale senden und empfangen? Nur Strategiebücher dürfen das.</summary>
public bool MayTrade => Kind == BookKind.Strategy;
/// <summary>
/// Gelten die Kapital- und Konzentrationslimits für dieses Buch? MANUAL ist ausgenommen –
/// zählt aber im Zähler jeder Konzentrationsprüfung voll mit. Hält der Betreiber 50 % AAPL von
/// Hand, sind die Module bei AAPL gesperrt. So gewollt.
/// </summary>
public bool SubjectToLimits => Kind == BookKind.Strategy;
public override string ToString() => Value;
}
/// <summary>
/// Welche Bücher es gibt und welches Modul zu welchem gehört.
///
/// <para><b>Warum das nicht das Modul selbst entscheidet:</b> <c>TradeSignal.SourceModule</c> kommt
/// aus dem Modul und ist damit kein Ausweis. Ohne diese Prüfung könnte ein fehlerhaftes Modul sich
/// als MANUAL ausgeben und damit jede Limitprüfung umgehen, oder mit einem Tippfehler ein
/// Phantombuch erzeugen, für das nie ein Cap konfiguriert wurde. Die zulässigen Namen stammen
/// deshalb aus der Modul-Registrierung des Hosts, nicht aus dem Signal.</para>
/// </summary>
public sealed class BookRegistry
{
private readonly Dictionary<string, BookId> _byModule;
/// <param name="moduleNames">
/// Die Namen der beim Start registrierten Strategiemodule – dieselbe Liste, aus der der Host
/// seine Module baut.
/// </param>
public BookRegistry(IEnumerable<string> moduleNames)
{
_byModule = new Dictionary<string, BookId>(StringComparer.OrdinalIgnoreCase);
foreach (var name in moduleNames)
{
var trimmed = (name ?? "").Trim();
if (trimmed.Length == 0 || BookId.IsReserved(trimmed)) continue;
_byModule[trimmed] = BookId.ForModule(trimmed);
}
}
/// <summary>Alle handelnden Bücher.</summary>
public IReadOnlyCollection<BookId> StrategyBooks => _byModule.Values;
/// <summary>
/// Buch eines Modulnamens, oder <c>null</c>, wenn der Name nicht registriert ist. Ein
/// unbekannter Name wird abgelehnt und nicht angelegt – ein Buch, das erst durch ein Signal
/// entsteht, hat kein Cap und wäre damit unbegrenzt.
/// </summary>
public BookId? Resolve(string? moduleName) =>
moduleName is not null && _byModule.TryGetValue(moduleName.Trim(), out var book)
? book
: null;
public bool IsRegistered(string? moduleName) => Resolve(moduleName) is not null;
}
@@ -0,0 +1,125 @@
using System.Globalization;
namespace IBKRTrader.Core.Capital;
/// <summary>Welche Grenze wurde verletzt? Die Reaktion entscheidet die Strategie, nicht der Core.</summary>
public enum LimitKind
{
/// <summary>Konzentration auf einen Einzelwert über alle Bücher hinweg.</summary>
SymbolConcentration,
/// <summary>Obergrenze des Buchs (Cap).</summary>
BookCap,
/// <summary>Freies Bargeld im Pool reicht nicht.</summary>
CashAvailable,
/// <summary>Kein oder zu wenig Bargeld in der Währung des Instruments (§4 Schicht C).</summary>
CurrencyCash,
/// <summary>Die Deckung eines verkauften Kontrakts fehlt (§4 Schicht B).</summary>
Coverage,
/// <summary>Der Watchdog hat den Kauf global gesperrt (§4 Schicht D).</summary>
BuyingHalted
}
/// <summary>
/// Rückmeldung an das Modul, wenn eine Grenze greift.
///
/// <para>Das Gate ist <b>hart</b>: der Core trimmt nicht selbstständig auf die zulässige Menge
/// herunter. Er sagt aber, was noch ginge – ein gut gebautes Modul kann daraufhin seine Order
/// richtig dimensionieren, statt gegen dieselbe Wand zu laufen.</para>
/// </summary>
/// <param name="Kind">Art der Grenze.</param>
/// <param name="Scope">Worauf sie sich bezieht: Symbol, Buchname oder Währung.</param>
/// <param name="Current">Aktueller Wert.</param>
/// <param name="Limit">Die Grenze.</param>
/// <param name="MaxQuantity">Was jetzt noch ginge, in Stück bzw. Kontrakten.</param>
/// <param name="MaxNotional">Was jetzt noch ginge, in Geld.</param>
public sealed record LimitBreach(
LimitKind Kind,
string Scope,
decimal Current,
decimal Limit,
int MaxQuantity,
decimal MaxNotional)
{
/// <summary>
/// Bezeichnung der Grenze im Klartext. Der Enum-Name landet sonst unübersetzt in Logzeilen und
/// im Entscheidungsjournal – also genau dort, wo im Störungsfall gelesen wird.
/// </summary>
public string Label => Kind switch
{
LimitKind.SymbolConcentration => "Konzentrationslimit",
LimitKind.BookCap => "Modul-Limit",
LimitKind.CashAvailable => "Freies Bargeld",
LimitKind.CurrencyCash => "Währungsdeckung",
LimitKind.Coverage => "Deckung",
LimitKind.BuyingHalted => "Kaufstopp",
_ => Kind.ToString()
};
// Ohne Tausendertrennzeichen (F0 statt N0): InvariantCulture schriebe "5,000", was in einer
// deutschen Logzeile als Fuenf-Komma-Null gelesen wird.
public override string ToString() => Kind switch
{
LimitKind.BuyingHalted => $"{Label} [{Scope}]: Käufe sind gesperrt (Bargeld unter der Schwelle).",
_ => string.Create(CultureInfo.InvariantCulture,
$"{Label} [{Scope}]: benötigt {Current:F0}, verfügbar {Limit:F0}; " +
$"möglich wären noch {MaxQuantity} Stück ({MaxNotional:F0}).")
};
}
/// <summary>
/// Wie viel Spielraum ein Buch gerade hat. Über die Vorab-Abfrage bekommt ein Modul das,
/// <i>bevor</i> es ein Signal schickt.
/// </summary>
/// <param name="Book">Das Buch.</param>
/// <param name="Cap">Obergrenze des Buchs in Geld.</param>
/// <param name="Bound">Bereits gebunden: Positionen plus offene Reservierungen.</param>
/// <param name="FreePoolCash">Freies Bargeld im gemeinsamen Pool.</param>
public sealed record Headroom(BookId Book, decimal Cap, decimal Bound, decimal FreePoolCash)
{
/// <summary>
/// Was das Buch jetzt noch einsetzen darf: das Kleinere aus verbleibendem Cap und freiem
/// Pool-Bargeld. Nie negativ – ein Buch über seinem Cap darf nichts mehr aufmachen, aber die
/// Zahl "minus 4.000" wäre als Spielraum sinnlos.
/// </summary>
public decimal Available => Math.Max(0m, Math.Min(Cap - Bound, FreePoolCash));
/// <summary>
/// Liegt das Buch über seinem Cap? Ein <b>zulässiger</b> Zustand: steigen die Positionen im
/// Wert, passiert nichts, außer dass nicht mehr nachgekauft werden darf. Er wird angezeigt und
/// gemeldet, aber niemals durch einen Verkauf geheilt.
/// </summary>
public bool OverCap => Bound > Cap;
}
/// <summary>
/// Momentaufnahme des Kapitals, aus der sich alle Obergrenzen ableiten.
/// </summary>
/// <param name="FreeCash">Freies Bargeld nach Haircut, Hausreserve und offenen Reservierungen (§4 Schicht A).</param>
/// <param name="PositionValue">Marktwert der Positionen aller Bücher, die Kapital binden.</param>
/// <param name="HouseReserve">Der Teil, der bewusst nicht verteilt wird.</param>
/// <param name="NetLiquidation">Kontowert – Nenner des Konzentrationslimits, nicht der Kapitalführung.</param>
public sealed record CapitalSnapshot(
decimal FreeCash,
decimal PositionValue,
decimal HouseReserve,
decimal NetLiquidation)
{
/// <summary>
/// Was insgesamt auf die Bücher verteilt wird. Wächst der Pool durch einen realisierten
/// Gewinn, steigt der Spielraum <b>aller</b> Bücher – Diversifikation ohne einen einzigen
/// Zwangsverkauf.
///
/// <para><b>Die Hausreserve steckt bereits in <see cref="FreeCash"/></b> und wird hier nicht
/// erneut abgezogen. Das Kapitalmodell zieht sie an zwei Stellen ab – in §4 Schicht A aus dem
/// freien Bargeld und in §3 noch einmal aus dem Verteilbaren. Das ist dieselbe Rücklage, zweimal
/// beschrieben, nicht zwei Rücklagen: der doppelte Abzug würde bei 5 % Reserve rund ein Zehntel
/// des Kapitals stillegen, ohne dass es irgendwo sichtbar wäre. <see cref="HouseReserve"/> steht
/// hier deshalb nur zur Anzeige.</para>
/// </summary>
public decimal Distributable => Math.Max(0m, FreeCash + PositionValue);
}
@@ -0,0 +1,92 @@
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Core.Capital;
/// <summary>
/// Ein gemeinsamer Cash-Pool, je Buch eine Obergrenze – die Kapitalführung des Systems, als reine
/// Rechnung ohne Datenbank und ohne Broker.
///
/// <code>
/// Verteilbar = FreiesCash + Σ Positionswert − Hausreserve
/// Cap(b) = Anteil(b) × Verteilbar
/// Gebunden(b) = Positionen von b + offene Reservierungen von b
/// Spielraum(b)= min( Cap(b) − Gebunden(b), freies Pool-Bargeld )
/// </code>
///
/// <para><b>Kein Kapitaltransfer zwischen Büchern.</b> Kein Buch-Cash, keine Ausgleichsläufe, keine
/// Rebalancierung. Und <b>niemals</b> wird eine Position geschlossen, um eine Zielquote
/// herzustellen: ein Buch über seinem Cap ist ein zulässiger Zustand, der angezeigt und gemeldet,
/// aber nicht geheilt wird.</para>
///
/// <para>Die Kapitalführung ist ausdrücklich <b>nicht</b> die Erfolgsmessung. Die GuV je Buch ist
/// eine Auswertung über die Trade-Historie und steuert nichts. Diese Entkopplung ist Absicht.</para>
/// </summary>
public sealed class CapitalPool
{
private readonly IReadOnlyDictionary<string, decimal> _shares;
/// <param name="shares">
/// Anteil je Buch am Verteilbaren, als Faktor (0,25 = ein Viertel). Bücher ohne Eintrag
/// bekommen <paramref name="defaultShare"/>.
/// </param>
/// <param name="defaultShare">Anteil für Bücher ohne ausdrückliche Vorgabe.</param>
public CapitalPool(IReadOnlyDictionary<string, decimal> shares, decimal defaultShare)
{
_shares = shares;
DefaultShare = defaultShare;
}
public decimal DefaultShare { get; }
/// <summary>
/// Gleichverteilung auf die übergebenen Bücher – die Vorgabe, solange nichts konfiguriert ist.
/// </summary>
public static CapitalPool EqualShares(IReadOnlyCollection<BookId> books) =>
new(new Dictionary<string, decimal>(), books.Count == 0 ? 0m : 1m / books.Count);
public decimal ShareOf(BookId book) =>
_shares.TryGetValue(book.Value, out var share) ? share : DefaultShare;
/// <summary>Obergrenze eines Buchs in Geld.</summary>
public decimal CapOf(BookId book, CapitalSnapshot snapshot) =>
snapshot.Distributable * ShareOf(book);
/// <summary>Spielraum eines Buchs: Cap, Gebundenes und freies Pool-Bargeld zusammengeführt.</summary>
public Headroom HeadroomOf(BookId book, CapitalSnapshot snapshot, decimal bound) =>
new(book, CapOf(book, snapshot), bound, snapshot.FreeCash);
/// <summary>
/// Prüft beim Start, dass die Anteile aller Bücher zusammen den verteilbaren Teil nicht
/// übersteigen. Liefert <c>null</c>, wenn alles stimmt, sonst den Grund.
///
/// <para>Ohne diese Prüfung könnte ein schnelles Buch ein langsames aushungern: sind in Summe
/// 150 % vergeben, entscheidet nicht die Konfiguration, wer wie viel bekommt, sondern wer
/// zuerst kommt.</para>
/// </summary>
public string? ValidateShares(IReadOnlyCollection<BookId> books)
{
if (books.Count == 0) return null;
var total = books.Sum(ShareOf);
if (total > 1m)
return $"Die Buch-Anteile ergeben zusammen {total:P1} und damit mehr als das verteilbare " +
$"Kapital. Wer zuerst handelt, verdrängte sonst die übrigen Bücher.";
var negative = books.Where(b => ShareOf(b) < 0m).ToList();
if (negative.Count > 0)
return $"Negativer Anteil für: {string.Join(", ", negative.Select(b => b.Value))}.";
return null;
}
/// <summary>
/// Konzentration auf einen Einzelwert, gerechnet über <b>alle</b> Bücher – MANUAL
/// eingeschlossen. Der Nenner ist der Kontowert, nicht der Positionswert: sonst wäre das Limit
/// bei viel Bargeld absurd eng und bei wenig Bargeld absurd weit.
/// </summary>
public static decimal SymbolExposure(IEnumerable<Position> allPositions, string symbol)
{
var ticker = symbol.Trim().ToUpperInvariant();
return allPositions.Where(p => p.Symbol == ticker).Sum(p => p.BoundCapital);
}
}
@@ -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;
}
}
@@ -0,0 +1,76 @@
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Core.Capital;
/// <summary>
/// Welche Aktien und welches Bargeld sind frei, und welche sind bereits durch geschriebene
/// Optionen gebunden? Reine Rechnung über die Positionen eines Buchs.
///
/// <para>Ohne diese Unterscheidung ist jede Deckungsprüfung wertlos: 100 Aktien decken
/// <b>einen</b> Call, nicht drei. Wer nur „hat er die Aktien?" fragt, schreibt beim zweiten Call
/// eine ungedeckte Option und merkt es nicht.</para>
/// </summary>
public static class CoverageCalculator
{
/// <summary>Deckungslage eines Basiswerts innerhalb eines Buchs.</summary>
/// <param name="Shares">Gehaltene Aktien (nie negativ – Aktien-Shorts sind gesperrt).</param>
/// <param name="SharesBoundByShortCalls">Durch geschriebene Calls gebundene Aktien.</param>
/// <param name="CashBoundByShortPuts">Durch geschriebene Puts gebundenes Bargeld.</param>
public readonly record struct Coverage(
int Shares,
int SharesBoundByShortCalls,
decimal CashBoundByShortPuts)
{
/// <summary>Aktien, die noch einen weiteren Call decken könnten.</summary>
public int FreeShares => Math.Max(0, Shares - SharesBoundByShortCalls);
}
/// <summary>
/// Rechnet die Deckungslage eines Basiswerts aus den Positionen eines Buchs.
/// </summary>
/// <param name="positions">
/// Alle Positionen des Buchs auf diesen Basiswert – die Aktie <b>und</b> jeder Kontrakt darauf.
/// </param>
public static Coverage For(IEnumerable<Position> positions)
{
var shares = 0;
var boundShares = 0;
var boundCash = 0m;
foreach (var p in positions)
{
if (p.Instrument.Option is not { } option)
{
// Aktienbestand. Ein negativer Bestand dürfte nicht vorkommen (Schicht B); käme er
// durch einen Abgleich doch zustande, zählt er nicht als Deckung – ein Short-Bestand
// deckt gar nichts.
shares += Math.Max(0, p.Quantity);
continue;
}
if (!p.IsShort) continue; // gekaufte Optionen binden nichts
var contracts = Math.Abs(p.Quantity);
if (option.Right == OptionRight.Call) boundShares += contracts * option.CoverageShares;
else boundCash += contracts * option.CashCoveragePerContract;
}
return new Coverage(shares, boundShares, boundCash);
}
/// <summary>
/// Wie viele Kontrakte lassen sich <b>gedeckt</b> zusätzlich schreiben?
/// </summary>
/// <param name="spec">Der zu schreibende Kontrakt.</param>
/// <param name="coverage">Deckungslage des Buchs für den Basiswert.</param>
/// <param name="availableCash">
/// Bargeld, das für eine neue Reservierung noch zur Verfügung steht (bereits um bestehende
/// Reservierungen bereinigt).
/// </param>
public static int MaxCoveredContracts(OptionSpec spec, Coverage coverage, decimal availableCash) =>
spec.Right == OptionRight.Call
? coverage.FreeShares / spec.CoverageShares
: spec.CashCoveragePerContract <= 0m
? 0
: (int)Math.Floor(Math.Max(0m, availableCash) / spec.CashCoveragePerContract);
}
@@ -0,0 +1,41 @@
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Core.Capital;
/// <summary>
/// Der Kapital-Seam des Handelspfads: Buchzuordnung, Bewertungskontext, Kapitalbindung und deren
/// Abschluss. Der <see cref="Trading.ExecutionService"/> kennt nur diesen Vertrag – dadurch bleibt
/// er ohne Datenbank prüfbar.
/// </summary>
public interface ICapitalService
{
/// <summary>
/// Buch eines Modulnamens, oder <c>null</c>, wenn das Modul nicht registriert ist.
/// <c>TradeSignal.SourceModule</c> kommt aus dem Modul und ist kein Ausweis.
/// </summary>
BookId? ResolveBook(string? sourceModule);
/// <summary>Spielraum eines Buchs – die Vorab-Abfrage für gut gebaute Module.</summary>
Task<Headroom> GetHeadroomAsync(BookId book, AccountState account, CancellationToken ct = default);
/// <summary>Alles, was die Risikoprüfung über Konto, Buch und Deckung wissen muss.</summary>
Task<RiskContext> BuildContextAsync(
BookId book, InstrumentRef instrument, decimal price, AccountState account,
CancellationToken ct = default);
/// <summary>Bindet Kapital für einen freigegebenen Auftrag – atomar unter dem Order-Lock.</summary>
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);
/// <summary>
/// Schließt eine Bindung nach dem Ergebnis der Order ab. Die Differenz zwischen Reservierung
/// (mit Puffer) und tatsächlichem Bedarf fließt zurück in den Pool.
/// </summary>
Task SettleReservationAsync(
long reservationId, int filledQuantity, decimal actualAmount, CancellationToken ct = default);
/// <summary>Gibt eine Bindung frei, ohne dass ausgeführt wurde.</summary>
Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default);
}
+148
View File
@@ -0,0 +1,148 @@
using IBKRTrader.Core.Trading;
namespace IBKRTrader.Core.Capital;
/// <summary>
/// Die Margin-Sperre: Modul-Handel ist strikt bargeldgedeckt, manueller Handel darf Margin nutzen.
///
/// <para>Der Kontotyp ist ein Cash-Konto, brokerseitig kann trotzdem Margin verfügbar sein – die
/// Sperre in der Software ist deshalb zwingend und nicht bloß eine Bestätigung dessen, was der
/// Broker ohnehin täte.</para>
///
/// <para>Vier Schichten, alle als reine Rechnung:</para>
/// <list type="bullet">
/// <item><b>A – richtige Bemessungsgrundlage.</b> Nicht <c>AvailableFunds</c>, sondern das echte Bargeld.</item>
/// <item><b>B – kein ungedeckter Short.</b> Siehe die Anmerkung unten.</item>
/// <item><b>C – Währung.</b> Ein Kauf in X nur aus Bargeld in X.</item>
/// <item><b>D – Watchdog.</b> Globaler Kaufstopp bei Unterschreitung, niemals automatisches Verkaufen.</item>
/// </list>
///
/// <para><b>Anmerkung zu Schicht B.</b> Das Kapitalmodell formulierte sie ursprünglich als „kein
/// Short – Verkäufe werden auf die Buchmenge gekappt, Shorts sind implizit Margin". Für Aktien
/// gilt das unverändert. Für Optionen wäre es allerdings gleichbedeutend mit „kein OptionsWheel",
/// denn dessen Kernoperation ist ein verkaufter Kontrakt. Der Grund der Regel war nie der Short an
/// sich, sondern der <i>Kredit</i>: ein Cash-Secured Put mit reservierter Deckung über
/// Strike × Kontraktgröße und ein Covered Call mit gesperrten Aktien nehmen keinen Kredit auf. Die
/// Schicht lautet deshalb ab hier <b>kein ungedeckter Short</b> – die Sperre wird nicht gelockert,
/// sondern auf ihren eigentlichen Gegenstand gestellt. Ein nackter Verkauf bleibt unmöglich, und
/// zwar im Core, damit ein Modulfehler ihn nicht herbeiführen kann.</para>
/// </summary>
public static class MarginGuard
{
/// <summary>
/// <b>Schicht A.</b> Das Bargeld, mit dem die Module rechnen dürfen.
///
/// <code>FreiesCash = min(TotalCashValue, AvailableFunds) − Hausreserve − offene Reservierungen</code>
///
/// <para><c>AvailableFunds</c> ist auf einem Margin-Konto bereits gehebelt und als Basis
/// unbrauchbar; <c>TotalCashValue</c> ist das echte Bargeld. Das Minimum aus beiden zu nehmen
/// ist die sichere Richtung. Eröffnet der Betreiber von Hand eine Margin-Position, sinkt
/// <c>TotalCashValue</c> und die Modul-Kaufkraft schrumpft automatisch mit – bei 0 ist sie
/// null, ohne Sonderfall. Module können einen manuellen Kredit strukturell nicht ausweiten.</para>
/// </summary>
/// <param name="account">Kontostand des Brokers.</param>
/// <param name="houseReserve">Der Teil, der bewusst nicht angerührt wird.</param>
/// <param name="openReservations">Summe der offenen Reservierungen.</param>
/// <param name="haircutPercent">
/// Sicherheitsabschlag auf das Bargeld in Prozent. 0 = kein Abschlag.
/// </param>
public static decimal FreeCash(
AccountState account, decimal houseReserve, decimal openReservations, decimal haircutPercent = 0m)
{
var cash = Math.Min(account.TotalCashValue, account.AvailableFunds);
// Kein TotalCashValue vom Broker (aeltere Antwort, Verbindung weg): dann ist die
// Bemessungsgrundlage unbekannt. Null ist hier die richtige Antwort - eine unbekannte
// Kaufkraft als "so viel wie AvailableFunds" zu lesen, waere genau der Hebel, den
// Schicht A verhindern soll.
if (account.TotalCashValue <= 0m) cash = 0m;
var afterHaircut = cash * (1m - haircutPercent / 100m);
return Math.Max(0m, afterHaircut - houseReserve - openReservations);
}
/// <summary>
/// <b>Schicht B.</b> Prüft, ob ein Verkauf ungedeckt wäre. Liefert <c>null</c>, wenn er gedeckt
/// ist, sonst den Grund.
/// </summary>
/// <param name="instrument">Das verkaufte Instrument.</param>
/// <param name="quantity">Zu verkaufende Menge, positiv.</param>
/// <param name="heldQuantity">Vorzeichenbehafteter Bestand des Buchs in genau diesem Instrument.</param>
/// <param name="coverage">Deckungslage des Buchs für den Basiswert.</param>
/// <param name="availableCash">Bargeld, das noch reserviert werden könnte.</param>
public static LimitBreach? CheckShortCoverage(
InstrumentRef instrument, int quantity, int heldQuantity,
CoverageCalculator.Coverage coverage, decimal availableCash)
{
// Ein Verkauf, der nur bestehenden Bestand abbaut, braucht keine Deckung.
var closing = Math.Max(0, Math.Min(quantity, heldQuantity));
var opening = quantity - closing;
if (opening <= 0) return null;
if (instrument.Option is not { } option)
{
// Aktien: unveraendert gesperrt. Ein Aktien-Short ist immer ein Kredit.
return new LimitBreach(
LimitKind.Coverage, instrument.Key,
Current: opening, Limit: 0m,
MaxQuantity: closing, MaxNotional: 0m);
}
var possible = CoverageCalculator.MaxCoveredContracts(option, coverage, availableCash);
if (opening <= possible) return null;
var required = option.Right == OptionRight.Call
? opening * option.CoverageShares
: opening * option.CashCoveragePerContract;
var have = option.Right == OptionRight.Call ? coverage.FreeShares : availableCash;
return new LimitBreach(
LimitKind.Coverage, instrument.Key,
Current: required, Limit: have,
MaxQuantity: closing + possible,
MaxNotional: option.Right == OptionRight.Call ? 0m : availableCash);
}
/// <summary>
/// <b>Schicht C.</b> Ein Kauf in Währung X darf nur aus Bargeld in X finanziert werden.
/// Andernfalls entsteht ein Sollsaldo in X – ein Margin-Kredit, auch wenn das Konto in Summe
/// positiv aussieht. Liefert <c>null</c>, wenn gedeckt.
///
/// <para>Meldet der Broker keine Aufschlüsselung, wird <b>abgelehnt</b> statt durchgelassen:
/// eine unbekannte Währungsdeckung ist keine vorhandene.</para>
/// </summary>
public static LimitBreach? CheckCurrencyCash(
AccountState account, string currency, decimal amount, decimal reservedInCurrency)
{
if (amount <= 0m) return null;
var cash = account.CashIn(currency);
if (cash is null)
return new LimitBreach(
LimitKind.CurrencyCash, currency,
Current: amount, Limit: 0m, MaxQuantity: 0, MaxNotional: 0m);
var free = cash.Value - reservedInCurrency;
return amount <= free
? null
: new LimitBreach(
LimitKind.CurrencyCash, currency,
Current: amount, Limit: Math.Max(0m, free),
MaxQuantity: 0, MaxNotional: Math.Max(0m, free));
}
/// <summary>
/// <b>Schicht D.</b> Ist das Bargeld unter die Schwelle gefallen? Dann gilt ein globaler
/// Kaufstopp.
///
/// <para><b>Und niemals automatisches Verkaufen zur Heilung.</b> Ein Selbstheilungs-Amoklauf –
/// eine Software, die in einen fallenden Markt hinein Positionen schließt, weil eine Kennzahl
/// unterschritten ist – ist gefährlicher als der Zustand, den er beheben soll.</para>
/// </summary>
public static bool BuyingShouldHalt(AccountState account, decimal minCashPercent)
{
if (account.NetLiquidation <= 0m) return true;
var threshold = account.NetLiquidation * (minCashPercent / 100m);
return account.TotalCashValue < threshold;
}
}
@@ -0,0 +1,350 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Capital;
/// <summary>
/// Verwaltet Kapitalbindungen über den Order-Lebenszyklus (core_reservation).
///
/// <para><b>Wozu:</b> zu verhindern, dass zwei Module gleichzeitig dasselbe Geld ausgeben. Ohne
/// Reservierung sehen zwei Signale, die im selben Moment geprüft werden, beide denselben
/// Bargeldbestand als frei an – und beide werden freigegeben.</para>
///
/// <para><b>Warum in der Datenbank und nicht im Arbeitsspeicher:</b> eine Reservierung muss einen
/// Absturz überleben. Verschwindet sie beim Neustart, während die Order beim Broker weiterläuft,
/// wird dasselbe Geld ein zweites Mal freigegeben.</para>
///
/// <para><b>Der globale Order-Lock.</b> Reservieren ist Prüfen und Schreiben in einem Zug; passiert
/// das nebenläufig, prüfen zwei Aufrufe gegen denselben Stand und schreiben beide. Es gibt genau
/// eine TWS-Verbindung, deshalb genügt ein einzelnes Schloss – feingranulare Sperren wären hier
/// nur eine zusätzliche Fehlerquelle.</para>
/// </summary>
public sealed class ReservationService
{
private const string LogModule = "Kapital";
/// <summary>
/// Zustände, in denen eine Reservierung Kapital bindet.
///
/// <para>Der Feldtyp ist <see cref="IReadOnlyList{T}"/> und <b>nicht</b> das Array: bei einem
/// Array bindet <c>Contains</c> seit .NET 9/10 an die Span-Überladung
/// (<c>MemoryExtensions.Contains</c>), und die kann EF Core nicht in SQL übersetzen – die
/// Abfrage bricht erst zur Laufzeit ab, nicht beim Übersetzen. Über die Schnittstelle greift
/// wieder <c>Enumerable.Contains</c>, das EF kennt.</para>
/// </summary>
private static readonly IReadOnlyList<ReservationState> OpenStates =
[
ReservationState.Reserved,
ReservationState.Submitted,
ReservationState.PartiallyFilled,
ReservationState.Quarantined
];
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly SettingsService _settings;
private readonly LoggingService _logger;
private readonly SemaphoreSlim _orderLock = new(1, 1);
public ReservationService(
IDbContextFactory<CoreDbContext> dbf, SettingsService settings, LoggingService logger)
{
_dbf = dbf;
_settings = settings;
_logger = logger;
}
private CapitalSettings Config => _settings.Settings.Capital;
/// <summary>Ergebnis eines Reservierungsversuchs.</summary>
/// <param name="Reservation">Die Bindung, wenn sie zustande kam.</param>
/// <param name="Breach">Der Grund, wenn nicht.</param>
/// <param name="WasExisting">
/// true, wenn zu diesem Signal bereits eine Bindung bestand und diese zurückgegeben wurde,
/// statt eine zweite anzulegen.
/// </param>
public readonly record struct ReserveResult(
CoreReservation? Reservation, LimitBreach? Breach, bool WasExisting)
{
public bool Success => Reservation is not null;
}
/// <summary>
/// Bindet Kapital für einen geplanten Auftrag – prüfend und schreibend in einem Zug.
/// </summary>
/// <param name="check">
/// Die Prüfung, die unter dem Schloss gegen den aktuellen Reservierungsstand laufen muss.
/// Sie bekommt die bereits gebundenen Beträge und liefert entweder den zu bindenden Betrag
/// oder den Grund der Ablehnung.
/// </param>
public async Task<ReserveResult> ReserveAsync(
BookId book, InstrumentRef instrument, TradeSide side, int quantity,
string currency, string signalId,
Func<ReservationLoad, ReservationRequest> check,
CancellationToken ct = default)
{
await _orderLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var db = await _dbf.CreateDbContextAsync(ct);
// Idempotenz: dasselbe Signal, dasselbe Instrument, noch offen -> die bestehende
// Bindung. Ein Reconnect, der das Signal erneut schickt, darf keine zweite Order
// ausloesen.
var existing = await db.Reservations
.Where(r => r.SignalId == signalId && r.ContractKey == instrument.Key &&
OpenStates.Contains(r.State))
.FirstOrDefaultAsync(ct);
if (existing is not null)
{
_logger.Warn(LogModule,
$"Reservierung {existing.Id} zu Signal {signalId} besteht bereits " +
$"({existing.State}) – es wird keine zweite angelegt.");
return new ReserveResult(existing, null, WasExisting: true);
}
var open = await db.Reservations.Where(r => OpenStates.Contains(r.State)).ToListAsync(ct);
var load = new ReservationLoad(
TotalCash: open.Sum(r => r.Amount),
CashOfBook: open.Where(r => r.Book == book.Value).Sum(r => r.Amount),
CashInCurrency: open.Where(r => r.Currency == currency).Sum(r => r.Amount),
SharesOfBookForSymbol: open
.Where(r => r.Book == book.Value && r.Symbol == instrument.Symbol)
.Sum(r => r.Shares));
var request = check(load);
if (request.Breach is { } breach)
return new ReserveResult(null, breach, false);
var now = DateTime.UtcNow;
var row = new CoreReservation
{
Book = book.Value,
ContractKey = instrument.Key,
Symbol = instrument.Symbol,
Currency = currency,
Side = side == TradeSide.Buy ? "BUY" : "SELL",
Quantity = quantity,
Amount = request.Amount,
Shares = request.Shares,
State = ReservationState.Reserved,
SignalId = signalId,
CreatedAt = now,
UpdatedAt = now,
ExpiresAt = now.AddMinutes(Math.Max(1, Config.ReservationTtlMinutes))
};
db.Reservations.Add(row);
await db.SaveChangesAsync(ct);
_logger.Info(LogModule,
$"Reserviert: {book} {row.Side} {quantity}x {instrument.Key} – " +
$"{request.Amount:F2} {currency}" +
(request.Shares > 0 ? $" + {request.Shares} Stück" : "") + $" (#{row.Id}).");
return new ReserveResult(row, null, false);
}
finally
{
_orderLock.Release();
}
}
/// <summary>Was gerade gebunden ist – die Eingabe jeder Prüfung.</summary>
/// <param name="TotalCash">Bargeld über alle Bücher.</param>
/// <param name="CashOfBook">Bargeld dieses Buchs.</param>
/// <param name="CashInCurrency">Bargeld in dieser Währung.</param>
/// <param name="SharesOfBookForSymbol">Aktien dieses Buchs auf diesen Basiswert.</param>
public readonly record struct ReservationLoad(
decimal TotalCash, decimal CashOfBook, decimal CashInCurrency, int SharesOfBookForSymbol);
/// <summary>Ergebnis der Prüfung: was gebunden werden soll, oder warum nicht.</summary>
public readonly record struct ReservationRequest(decimal Amount, int Shares, LimitBreach? Breach)
{
public static ReservationRequest Bind(decimal amount, int shares = 0) => new(amount, shares, null);
public static ReservationRequest Reject(LimitBreach breach) => new(0m, 0, breach);
}
/// <summary>Vermerkt, dass die Order beim Broker liegt.</summary>
public async Task MarkSubmittedAsync(long id, string? brokerOrderId, CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var row = await db.Reservations.FindAsync([id], ct);
if (row is null) return;
row.State = ReservationState.Submitted;
row.BrokerOrderId = brokerOrderId;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Schließt eine Reservierung nach dem Endstatus der Order ab.
///
/// <para>Die Differenz zwischen reserviertem und tatsächlich benötigtem Betrag fließt in den
/// Pool zurück: reserviert wird mit Puffer, gefüllt wird zum echten Preis.</para>
/// </summary>
/// <param name="filledQuantity">Tatsächlich ausgeführte Menge; 0 = nichts ausgeführt.</param>
/// <param name="actualAmount">Tatsächlich benötigtes Kapital (bei Teilfüllung anteilig).</param>
public async Task SettleAsync(
long id, int filledQuantity, decimal actualAmount, CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var row = await db.Reservations.FindAsync([id], ct);
if (row is null) return;
row.UpdatedAt = DateTime.UtcNow;
if (filledQuantity <= 0)
{
row.State = ReservationState.Cancelled;
row.Note = "Nicht ausgeführt – Bindung vollständig freigegeben.";
}
else if (filledQuantity >= row.Quantity)
{
// Vollstaendig ausgefuehrt: die Bindung geht in eine Position ueber, die ihrerseits
// gebundenes Kapital ist. Die Reservierung selbst bindet ab hier nichts mehr - sonst
// zaehlte dasselbe Kapital doppelt.
row.State = ReservationState.Filled;
row.Amount = 0m;
row.Shares = 0;
}
else
{
row.State = ReservationState.PartiallyFilled;
var ratio = (decimal)(row.Quantity - filledQuantity) / row.Quantity;
row.Amount = Math.Max(0m, row.Amount * ratio);
row.Shares = (int)Math.Floor(row.Shares * ratio);
row.Quantity -= filledQuantity;
row.Note = $"Teilausführung: {filledQuantity} Stück; Rest bleibt gebunden.";
}
await db.SaveChangesAsync(ct);
}
/// <summary>Gibt eine Bindung frei, ohne dass ausgeführt wurde.</summary>
public async Task CancelAsync(long id, string reason, CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var row = await db.Reservations.FindAsync([id], ct);
if (row is null) return;
row.State = ReservationState.Cancelled;
row.Amount = 0m;
row.Shares = 0;
row.Note = reason;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
/// <summary>Summe der offen gebundenen Barmittel über alle Bücher.</summary>
public async Task<decimal> GetOpenAmountAsync(CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
return await db.Reservations.Where(r => OpenStates.Contains(r.State)).SumAsync(r => r.Amount, ct);
}
/// <summary>Offen gebundene Barmittel eines Buchs.</summary>
public async Task<decimal> GetOpenAmountAsync(BookId book, CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
return await db.Reservations
.Where(r => r.Book == book.Value && OpenStates.Contains(r.State))
.SumAsync(r => r.Amount, ct);
}
/// <summary>Offen gebundene Aktien eines Buchs auf einen Basiswert (geschriebene Calls).</summary>
public async Task<int> GetOpenSharesAsync(BookId book, string symbol, CancellationToken ct = default)
{
var ticker = symbol.Trim().ToUpperInvariant();
await using var db = await _dbf.CreateDbContextAsync(ct);
return await db.Reservations
.Where(r => r.Book == book.Value && r.Symbol == ticker && OpenStates.Contains(r.State))
.SumAsync(r => r.Shares, ct);
}
/// <summary>
/// Überfällige Bindungen: Frist erreicht, kein Endzustand. Sie werden <b>gemeldet</b> und auf
/// <see cref="ReservationState.Expired"/> gesetzt – aber erst, nachdem sie als Alarm im Log
/// stehen. Eine stille Freigabe wäre der gefährliche Weg: die zugehörige Order kann beim Broker
/// noch aktiv sein, und dann ist das Geld zweimal vergeben.
/// </summary>
/// <returns>Die überfälligen Bindungen.</returns>
public async Task<IReadOnlyList<CoreReservation>> ExpireOverdueAsync(CancellationToken ct = default)
{
var now = DateTime.UtcNow;
await using var db = await _dbf.CreateDbContextAsync(ct);
var overdue = await db.Reservations
.Where(r => r.ExpiresAt <= now &&
(r.State == ReservationState.Reserved || r.State == ReservationState.Submitted))
.ToListAsync(ct);
foreach (var row in overdue)
{
_logger.Error(LogModule,
$"Reservierung #{row.Id} ({row.Book} {row.Side} {row.Quantity}x {row.ContractKey}, " +
$"{row.Amount:F2} {row.Currency}) hat ihre Frist ohne Endzustand überschritten. " +
$"Broker-Order {row.BrokerOrderId ?? "unbekannt"} prüfen – die Bindung wird NICHT " +
$"stillschweigend freigegeben.");
row.State = ReservationState.Expired;
row.UpdatedAt = now;
row.Note = "Frist überschritten – gemeldet, Bindung aufgelöst. Order beim Broker prüfen.";
}
if (overdue.Count > 0) await db.SaveChangesAsync(ct);
return overdue;
}
/// <summary>
/// Abgleich beim Start: jede nicht abgeschlossene Bindung gegen die offenen Orders des Brokers.
///
/// <para>Eine Bindung, zu der es keine offene Order (mehr) gibt, ist eine Waise. Sie wird
/// <b>nicht</b> freigegeben, sondern in Quarantäne gestellt und gemeldet – denn es gibt zwei
/// Erklärungen dafür, und sie führen zu entgegengesetzten Handlungen: die Order wurde nie
/// abgeschickt (dann darf das Kapital zurück), oder sie wurde ausgeführt, während wir weg waren
/// (dann existiert eine Position, die wir nicht kennen, und das Kapital ist echt gebunden).
/// Welche der beiden zutrifft, entscheidet der Positionsabgleich oder ein Mensch, nicht diese
/// Methode.</para>
/// </summary>
/// <param name="openBrokerOrderIds">Order-IDs, die beim Broker noch offen sind.</param>
/// <returns>Die in Quarantäne gestellten Bindungen.</returns>
public async Task<IReadOnlyList<CoreReservation>> RecoverAsync(
IReadOnlyCollection<string> openBrokerOrderIds, CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var pending = await db.Reservations
.Where(r => r.State == ReservationState.Reserved ||
r.State == ReservationState.Submitted ||
r.State == ReservationState.PartiallyFilled)
.ToListAsync(ct);
if (pending.Count == 0) return Array.Empty<CoreReservation>();
var known = new HashSet<string>(openBrokerOrderIds, StringComparer.OrdinalIgnoreCase);
var orphans = new List<CoreReservation>();
foreach (var row in pending)
{
if (row.BrokerOrderId is { Length: > 0 } id && known.Contains(id))
continue; // Order laeuft weiter, Bindung bleibt zu Recht bestehen
row.State = ReservationState.Quarantined;
row.UpdatedAt = DateTime.UtcNow;
row.Note = "Beim Start keine passende offene Broker-Order gefunden. " +
"Kapital bleibt gebunden, bis der Fall geklärt ist.";
orphans.Add(row);
_logger.Error(LogModule,
$"Reservierung #{row.Id} ({row.Book} {row.Side} {row.Quantity}x {row.ContractKey}) " +
$"hat beim Start keine offene Broker-Order. Order-ID: {row.BrokerOrderId ?? "keine"}. " +
$"In Quarantäne – Positionsabgleich abwarten oder von Hand entscheiden.");
}
if (orphans.Count > 0) await db.SaveChangesAsync(ct);
else _logger.Info(LogModule, $"{pending.Count} offene Reservierung(en) beim Start bestätigt.");
return orphans;
}
}
@@ -0,0 +1,85 @@
using IBKRTrader.Core.Settings;
using MySqlConnector;
namespace IBKRTrader.Core.Configuration;
/// <summary>
/// Die eine maßgebliche Datenbankverbindung der Anwendung – aufgelöst aus den beiden Quellen,
/// die es historisch gab.
///
/// <para>Bisher lasen EF-Kontexte den Connection-String aus <c>appsettings(.Local).json</c>,
/// während BackupWorker und die Versionsdiagnose Host/Port/Datenbank/Benutzer aus
/// <c>settings.json</c> nahmen. Nichts erzwang, dass beide dasselbe meinen: eine Installation
/// konnte monatelang sauber laufen und dabei eine Datenbank sichern, die es gar nicht gibt.</para>
///
/// <para>Regel: Ist in der Konfiguration ein Connection-String hinterlegt, ist <b>er</b>
/// maßgeblich und alles Weitere wird aus ihm abgeleitet. Fehlt er, wird die Verbindung aus
/// <c>settings.json</c> gebaut – dann sind beide Quellen zwangsläufig deckungsgleich.</para>
/// </summary>
public sealed class DatabaseEndpoint
{
public string ConnectionString { get; }
public string Host { get; }
public int Port { get; }
public string Database { get; }
public string User { get; }
public string Password { get; }
/// <summary>Woher die maßgebliche Verbindung stammt – für die Startmeldung.</summary>
public string Source { get; }
private DatabaseEndpoint(string connectionString, string source)
{
ConnectionString = connectionString;
Source = source;
var b = new MySqlConnectionStringBuilder(connectionString);
Host = b.Server;
Port = (int)b.Port;
Database = b.Database;
User = b.UserID;
Password = b.Password;
}
/// <summary>
/// Löst die maßgebliche Verbindung auf. Ein unlesbarer Connection-String fällt auf
/// <c>settings.json</c> zurück, statt den Start zu verhindern – sichtbar wird das über
/// <see cref="Source"/> und die Startprüfung.
/// </summary>
public static DatabaseEndpoint Resolve(string? configuredConnectionString, DatabaseSettings settings)
{
if (!string.IsNullOrWhiteSpace(configuredConnectionString))
{
try { return new DatabaseEndpoint(configuredConnectionString, "appsettings"); }
catch (Exception ex)
{
return new DatabaseEndpoint(settings.BuildConnectionString(),
$"settings.json (Connection-String unlesbar: {ex.Message})");
}
}
return new DatabaseEndpoint(settings.BuildConnectionString(), "settings.json");
}
/// <summary>
/// Beschreibt eine Abweichung zwischen der maßgeblichen Verbindung und dem, was in
/// <c>settings.json</c> steht – sonst <c>null</c>. Beides darf existieren, aber wer sie
/// auseinanderlaufen lässt, sichert die falsche Datenbank.
/// </summary>
public string? DescribeDivergence(DatabaseSettings settings)
{
if (Source != "appsettings") return null;
var differs = !string.Equals(Host, settings.Host, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(Database, settings.Database, StringComparison.OrdinalIgnoreCase)
|| Port != settings.Port;
return differs
? $"Datenbank-Konfiguration weicht ab: maßgeblich ist {this} (appsettings), " +
$"settings.json nennt {settings}. Es gilt die maßgebliche Verbindung."
: null;
}
/// <summary>Benutzer@Host:Port/Datenbank – nie das Passwort.</summary>
public override string ToString() => $"{User}@{Host}:{Port}/{Database}";
}
@@ -18,6 +18,9 @@ public class CoreDbContext : DbContext
public DbSet<CoreWorkerLog> WorkerLog => Set<CoreWorkerLog>();
public DbSet<CoreSetting> Settings => Set<CoreSetting>();
// Kapital- und Buchmodell
public DbSet<CoreReservation> Reservations => Set<CoreReservation>();
// Datenfundament für Analyse/Forensik (Supervisor)
public DbSet<CoreDecisionRecord> DecisionJournal => Set<CoreDecisionRecord>();
public DbSet<CoreOrderEvent> OrderEvents => Set<CoreOrderEvent>();
@@ -32,10 +35,19 @@ public class CoreDbContext : DbContext
b.Entity<CorePosition>(e =>
{
e.ToTable("core_position");
e.HasKey(x => new { x.Module, x.Symbol });
// Schluessel ist der Instrumentenschluessel, nicht das Symbol: auf denselben Basiswert
// koennen die Aktie und mehrere Kontrakte gleichzeitig offen sein.
e.HasKey(x => new { x.Module, x.ContractKey });
e.Property(x => x.Module).HasMaxLength(50);
e.Property(x => x.ContractKey).HasMaxLength(64);
e.Property(x => x.Symbol).HasMaxLength(20);
e.Property(x => x.Kind).HasMaxLength(10);
e.Property(x => x.Right).HasMaxLength(1);
e.Property(x => x.Strike).HasPrecision(18, 4);
e.Property(x => x.AvgPrice).HasPrecision(18, 4);
// Der Abgleich gegen den Broker und die Deckungsrechnung fragen nach dem Basiswert
// ueber alle Buecher hinweg - ohne diesen Index ist das ein Tabellendurchlauf.
e.HasIndex(x => x.Symbol);
});
b.Entity<CoreTrade>(e =>
@@ -126,6 +138,30 @@ public class CoreDbContext : DbContext
e.Property(x => x.Ticker).HasMaxLength(50);
});
b.Entity<CoreReservation>(e =>
{
e.ToTable("core_reservation");
e.HasKey(x => x.Id);
e.Property(x => x.Book).HasMaxLength(50);
e.Property(x => x.ContractKey).HasMaxLength(64);
e.Property(x => x.Symbol).HasMaxLength(20);
e.Property(x => x.Currency).HasMaxLength(5);
e.Property(x => x.Side).HasMaxLength(10);
e.Property(x => x.SignalId).HasMaxLength(64);
e.Property(x => x.BrokerOrderId).HasMaxLength(100);
e.Property(x => x.Amount).HasPrecision(18, 4);
e.Property(x => x.State).HasConversion<string>().HasMaxLength(20);
e.Property(x => x.Note).HasColumnType("text");
// Idempotenz: dasselbe Signal darf fuer dasselbe Instrument nur einmal binden. Ohne
// diese Bedingung erzeugt ein Reconnect eine zweite Reservierung und damit eine
// zweite Order.
e.HasIndex(x => new { x.SignalId, x.ContractKey }).IsUnique();
// Die haeufigste Abfrage ueberhaupt: was ist gerade offen?
e.HasIndex(x => new { x.State, x.Book });
e.HasIndex(x => x.ExpiresAt);
e.HasIndex(x => x.BrokerOrderId);
});
b.Entity<CoreDecisionRecord>(e =>
{
e.ToTable("core_decision_journal");
@@ -0,0 +1,510 @@
// <auto-generated />
using System;
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
[DbContext(typeof(CoreDbContext))]
[Migration("20260903191842_AddOptionInstruments")]
partial class AddOptionInstruments
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Ticker")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.HasKey("Id");
b.HasIndex("Source", "Ticker");
b.HasIndex("InstrumentId", "Source", "Ticker")
.IsUnique();
b.ToTable("core_ibkr_external_identifiers", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("CompanyName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Exchange")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<long>("IbkrConid")
.HasColumnType("bigint");
b.Property<string>("Industry")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("varchar(12)");
b.Property<DateTime?>("LastFetched")
.HasColumnType("datetime(6)");
b.Property<string>("PrimaryExchange")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SecType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Sector")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Active");
b.HasIndex("IbkrConid")
.IsUnique();
b.HasIndex("Symbol");
b.ToTable("core_ibkr_instruments", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b =>
{
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("BarSize")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int?>("BarCount")
.HasColumnType("int");
b.Property<decimal>("Close")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("High")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Low")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Open")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<long>("Volume")
.HasColumnType("bigint");
b.Property<decimal?>("Wap")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.HasKey("InstrumentId", "BarSize", "Timestamp");
b.HasIndex("Timestamp");
b.ToTable("core_ibkr_market_data", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b =>
{
b.Property<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<decimal>("MaxPerTrade")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("UsedBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.HasKey("Module");
b.ToTable("core_budget", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContextJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Module", "Timestamp");
b.ToTable("core_decision_journal", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Response")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Symbol", "Timestamp");
b.ToTable("core_order_events", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b =>
{
b.Property<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("AvgPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateOnly?>("Expiry")
.HasColumnType("date");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<int>("Multiplier")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Right")
.HasMaxLength(1)
.HasColumnType("varchar(1)");
b.Property<decimal?>("Strike")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Module", "ContractKey");
b.HasIndex("Symbol");
b.ToTable("core_position", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("core_settings", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IbkrOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("SignalId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("TotalValue")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("TradedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Module");
b.HasIndex("SignalId");
b.HasIndex("Symbol");
b.ToTable("core_trade_history", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Message")
.HasColumnType("longtext");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("WorkerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("WorkerName", "StartedAt");
b.ToTable("core_worker_log", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddOptionInstruments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_core_position",
table: "core_position");
migrationBuilder.AddColumn<string>(
name: "ContractKey",
table: "core_position",
type: "varchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<DateOnly>(
name: "Expiry",
table: "core_position",
type: "date",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Kind",
table: "core_position",
type: "varchar(10)",
maxLength: 10,
nullable: false,
defaultValue: "Stock")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<int>(
name: "Multiplier",
table: "core_position",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<string>(
name: "Right",
table: "core_position",
type: "varchar(1)",
maxLength: 1,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<decimal>(
name: "Strike",
table: "core_position",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: true);
// Bestandsuebernahme VOR dem neuen Schluessel. Ohne sie traegt jede vorhandene Zeile
// den Vorgabewert "" als ContractKey - und AddPrimaryKey bricht mit einem doppelten
// Schluessel ab, sobald ein Modul mehr als eine Position haelt. Fuer eine Aktie ist der
// Instrumentenschluessel das Ticker-Symbol; es findet also kein Datenumzug statt,
// sondern nur das Fuellen einer Spalte, die es vorher nicht gab.
migrationBuilder.Sql(
"UPDATE core_position SET ContractKey = Symbol, Kind = 'Stock', Multiplier = 1;");
migrationBuilder.AddPrimaryKey(
name: "PK_core_position",
table: "core_position",
columns: new[] { "Module", "ContractKey" });
migrationBuilder.CreateIndex(
name: "IX_core_position_Symbol",
table: "core_position",
column: "Symbol");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Der alte Schluessel (Module, Symbol) kann Optionen nicht abbilden: mehrere Kontrakte
// auf denselben Basiswert waeren derselbe Schluessel. Sie werden deshalb entfernt statt
// zusammengefaltet - eine stillschweigend verschmolzene Position waere eine falsche
// Deckung, und das ist schlimmer als eine fehlende.
migrationBuilder.Sql("DELETE FROM core_position WHERE Kind = 'Option';");
migrationBuilder.DropPrimaryKey(
name: "PK_core_position",
table: "core_position");
migrationBuilder.DropIndex(
name: "IX_core_position_Symbol",
table: "core_position");
migrationBuilder.DropColumn(
name: "ContractKey",
table: "core_position");
migrationBuilder.DropColumn(
name: "Expiry",
table: "core_position");
migrationBuilder.DropColumn(
name: "Kind",
table: "core_position");
migrationBuilder.DropColumn(
name: "Multiplier",
table: "core_position");
migrationBuilder.DropColumn(
name: "Right",
table: "core_position");
migrationBuilder.DropColumn(
name: "Strike",
table: "core_position");
migrationBuilder.AddPrimaryKey(
name: "PK_core_position",
table: "core_position",
columns: new[] { "Module", "Symbol" });
}
}
}
@@ -0,0 +1,593 @@
// <auto-generated />
using System;
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
[DbContext(typeof(CoreDbContext))]
[Migration("20260904065336_AddCapitalReservations")]
partial class AddCapitalReservations
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Ticker")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.HasKey("Id");
b.HasIndex("Source", "Ticker");
b.HasIndex("InstrumentId", "Source", "Ticker")
.IsUnique();
b.ToTable("core_ibkr_external_identifiers", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("CompanyName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Exchange")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<long>("IbkrConid")
.HasColumnType("bigint");
b.Property<string>("Industry")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("varchar(12)");
b.Property<DateTime?>("LastFetched")
.HasColumnType("datetime(6)");
b.Property<string>("PrimaryExchange")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SecType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Sector")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Active");
b.HasIndex("IbkrConid")
.IsUnique();
b.HasIndex("Symbol");
b.ToTable("core_ibkr_instruments", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b =>
{
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("BarSize")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int?>("BarCount")
.HasColumnType("int");
b.Property<decimal>("Close")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("High")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Low")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Open")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<long>("Volume")
.HasColumnType("bigint");
b.Property<decimal?>("Wap")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.HasKey("InstrumentId", "BarSize", "Timestamp");
b.HasIndex("Timestamp");
b.ToTable("core_ibkr_market_data", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b =>
{
b.Property<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<decimal>("MaxPerTrade")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("UsedBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.HasKey("Module");
b.ToTable("core_budget", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContextJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Module", "Timestamp");
b.ToTable("core_decision_journal", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Response")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Symbol", "Timestamp");
b.ToTable("core_order_events", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b =>
{
b.Property<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("AvgPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateOnly?>("Expiry")
.HasColumnType("date");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<int>("Multiplier")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Right")
.HasMaxLength(1)
.HasColumnType("varchar(1)");
b.Property<decimal?>("Strike")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Module", "ContractKey");
b.HasIndex("Symbol");
b.ToTable("core_position", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreReservation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Book")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("BrokerOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("ContractKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("Shares")
.HasColumnType("int");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("BrokerOrderId");
b.HasIndex("ExpiresAt");
b.HasIndex("SignalId", "ContractKey")
.IsUnique();
b.HasIndex("State", "Book");
b.ToTable("core_reservation", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("core_settings", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IbkrOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("SignalId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("TotalValue")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("TradedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Module");
b.HasIndex("SignalId");
b.HasIndex("Symbol");
b.ToTable("core_trade_history", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Message")
.HasColumnType("longtext");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("WorkerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("WorkerName", "StartedAt");
b.ToTable("core_worker_log", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddCapitalReservations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "core_reservation",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Book = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContractKey = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Symbol = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Currency = table.Column<string>(type: "varchar(5)", maxLength: 5, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Quantity = table.Column<int>(type: "int", nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Shares = table.Column<int>(type: "int", nullable: false),
State = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SignalId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BrokerOrderId = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Note = table.Column<string>(type: "text", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_core_reservation", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_core_reservation_BrokerOrderId",
table: "core_reservation",
column: "BrokerOrderId");
migrationBuilder.CreateIndex(
name: "IX_core_reservation_ExpiresAt",
table: "core_reservation",
column: "ExpiresAt");
migrationBuilder.CreateIndex(
name: "IX_core_reservation_SignalId_ContractKey",
table: "core_reservation",
columns: new[] { "SignalId", "ContractKey" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_core_reservation_State_Book",
table: "core_reservation",
columns: new[] { "State", "Book" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "core_reservation");
}
}
}
@@ -334,25 +334,134 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("ContractKey")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("AvgPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateOnly?>("Expiry")
.HasColumnType("date");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<int>("Multiplier")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Right")
.HasMaxLength(1)
.HasColumnType("varchar(1)");
b.Property<decimal?>("Strike")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Module", "Symbol");
b.HasKey("Module", "ContractKey");
b.HasIndex("Symbol");
b.ToTable("core_position", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreReservation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Book")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("BrokerOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("ContractKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("Shares")
.HasColumnType("int");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("BrokerOrderId");
b.HasIndex("ExpiresAt");
b.HasIndex("SignalId", "ContractKey")
.IsUnique();
b.HasIndex("State", "Book");
b.ToTable("core_reservation", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b =>
{
b.Property<string>("Key")
@@ -0,0 +1,102 @@
namespace IBKRTrader.Core.Persistence.Entities;
/// <summary>
/// Zustand einer Reservierung im Order-Lebenszyklus.
///
/// <para>Das Kapitalmodell nennt als ersten Zustand <c>INTENT</c> vor <c>RESERVED</c>. Er fehlt
/// hier bewusst: Absicht und Reservierung entstehen in <b>derselben</b> Transaktion hinter dem
/// globalen Order-Lock – ein Zustand, den nie jemand beobachten kann, wäre eine Zeile, die immer
/// leer ist. Wird die Reservierung später zweistufig (Vorprüfung, dann Bindung), gehört er
/// nachgezogen.</para>
/// </summary>
public enum ReservationState
{
/// <summary>Kapital ist gebunden, die Order ist noch nicht beim Broker.</summary>
Reserved,
/// <summary>Die Order liegt beim Broker.</summary>
Submitted,
/// <summary>Teilweise ausgeführt – noch nicht endgültig, der Rest bleibt gebunden.</summary>
PartiallyFilled,
/// <summary>Vollständig ausgeführt. Endzustand.</summary>
Filled,
/// <summary>Storniert. Endzustand.</summary>
Cancelled,
/// <summary>Vom Broker abgelehnt. Endzustand.</summary>
Rejected,
/// <summary>
/// Frist abgelaufen, ohne dass ein Endzustand eintrat. Endzustand – aber ein <b>gemeldeter</b>:
/// eine hängende Order, deren Reservierung stillschweigend freigegeben wird, gibt dasselbe Geld
/// zweimal aus.
/// </summary>
Expired,
/// <summary>
/// Beim Start fand sich keine passende offene Order beim Broker. Bleibt gebunden und wird
/// gemeldet, bis ein Mensch entscheidet. Endzustand nur für die Automatik, nicht fachlich.
/// </summary>
Quarantined
}
/// <summary>
/// Eine Kapitalbindung über den Lebenszyklus einer Order (Tabelle core_reservation).
///
/// <para>Sie steht in der Datenbank und nicht im Arbeitsspeicher, weil sie einen Absturz überleben
/// muss: eine Reservierung, die beim Neustart verschwindet, während die Order beim Broker
/// weiterläuft, gibt dasselbe Geld ein zweites Mal frei.</para>
/// </summary>
public class CoreReservation
{
public long Id { get; set; }
/// <summary>Buch, dessen Kapital gebunden ist.</summary>
public string Book { get; set; } = "";
/// <summary>Instrumentenschlüssel des Auftrags.</summary>
public string ContractKey { get; set; } = "";
/// <summary>Ticker der Aktie bzw. des Basiswerts – für die Deckungsrechnung.</summary>
public string Symbol { get; set; } = "";
/// <summary>Währung, in der gebunden wird. Reservierungen werden je Währung geführt.</summary>
public string Currency { get; set; } = "USD";
/// <summary>"BUY" oder "SELL".</summary>
public string Side { get; set; } = "";
/// <summary>Menge in Stück bzw. Kontrakten, positiv.</summary>
public int Quantity { get; set; }
/// <summary>Gebundenes Bargeld einschließlich Puffer und geschätzter Kommission.</summary>
public decimal Amount { get; set; }
/// <summary>
/// Gebundene Aktien. Ein geschriebener Call bindet keine Barmittel, sondern Stücke – ohne diese
/// Spalte sähen zwei gleichzeitige Signale dieselben 100 Aktien als frei an.
/// </summary>
public int Shares { get; set; }
public ReservationState State { get; set; } = ReservationState.Reserved;
/// <summary>
/// Korrelations-ID des auslösenden Signals. Zusammen mit <see cref="ContractKey"/> der
/// Idempotenzschlüssel: ein Reconnect darf keine zweite Order erzeugen.
/// </summary>
public string SignalId { get; set; } = "";
/// <summary>Order-ID des Brokers, sobald bekannt – Anker des Abgleichs beim Neustart.</summary>
public string? BrokerOrderId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
/// <summary>Frist, nach der ohne Endzustand Alarm ausgelöst wird (UTC).</summary>
public DateTime ExpiresAt { get; set; }
public string? Note { get; set; }
}
@@ -1,13 +1,43 @@
namespace IBKRTrader.Core.Persistence.Entities;
/// <summary>Offene Position eines Moduls (Tabelle core_position, PK Module+Symbol).</summary>
/// <summary>
/// Offene Position eines Buchs (Tabelle core_position, PK Module+ContractKey).
///
/// <para>Der Schlüssel war bis zur Optionserweiterung Modul+Symbol. Das reicht nicht mehr: auf
/// denselben Basiswert können gleichzeitig die Aktie und mehrere Kontrakte offen sein, die sich nur
/// in Verfall, Strike und Art unterscheiden. <see cref="ContractKey"/> ist bei Aktien schlicht das
/// Ticker-Symbol – Bestandszeilen ändern sich dadurch inhaltlich nicht.</para>
/// </summary>
public class CorePosition
{
public string Module { get; set; } = "";
public string Symbol { get; set; } = "";
public int Quantity { get; set; }
public decimal AvgPrice { get; set; }
public DateTime UpdatedAt { get; set; }
public string Module { get; set; } = "";
/// <summary>Instrumentenschlüssel: "AAPL" oder "AAPL|20260918|C|150" (siehe <c>InstrumentRef</c>).</summary>
public string ContractKey { get; set; } = "";
/// <summary>Ticker der Aktie bzw. des Basiswerts der Option.</summary>
public string Symbol { get; set; } = "";
/// <summary>"Stock" oder "Option".</summary>
public string Kind { get; set; } = nameof(IBKRTrader.Core.Trading.InstrumentKind.Stock);
/// <summary>Verfallstag der Option; <c>null</c> bei Aktien.</summary>
public DateOnly? Expiry { get; set; }
/// <summary>Ausübungspreis; <c>null</c> bei Aktien.</summary>
public decimal? Strike { get; set; }
/// <summary>"C" oder "P"; <c>null</c> bei Aktien.</summary>
public string? Right { get; set; }
/// <summary>Kontraktgröße: 1 bei Aktien, bei US-Aktienoptionen 100.</summary>
public int Multiplier { get; set; } = 1;
/// <summary>Vorzeichenbehaftet: positiv = long, negativ = short (verkaufte Option).</summary>
public int Quantity { get; set; }
public decimal AvgPrice { get; set; }
public DateTime UpdatedAt { get; set; }
}
/// <summary>Trade-Historie (Tabelle core_trade_history).</summary>
@@ -252,6 +252,63 @@ public class TradingSettings
public override string ToString() => $"{Mode} – {(TradingEnabled ? "aktiv" : "inaktiv")}";
}
// ─── Kapital- und Buchmodell ─────────────────────────────────────────────────
[TypeConverter(typeof(ExpandableObjectConverter))]
public class CapitalSettings
{
[Category("Kapital")]
[DisplayName("Hausreserve (%)")]
[Description("Anteil des Kontowerts, der nicht auf die Bücher verteilt wird. " +
"Puffer für Gebühren, Währungsdifferenzen und Ausgleichsposten.")]
public double HouseReservePercent { get; set; } = 5.0;
[Category("Kapital")]
[DisplayName("Sicherheitsabschlag auf Bargeld (%)")]
[Description("Abschlag auf das Bargeld, bevor es als verteilbar gilt. 0 = kein Abschlag.")]
public double CashHaircutPercent { get; set; } = 0.0;
[Category("Kapital")]
[DisplayName("Max. je Einzelwert (%)")]
[Description("Konzentrationslimit je Basiswert in % des Kontowerts, gerechnet über ALLE Bücher " +
"einschließlich der von Hand angelegten Positionen.")]
public double MaxSymbolPercent { get; set; } = 15.0;
[Category("Kapital")]
[DisplayName("Kaufstopp unter Bargeld (%)")]
[Description("Watchdog-Schwelle: fällt das Bargeld unter diesen Anteil des Kontowerts, werden " +
"keine Käufe mehr freigegeben. Verkauft wird deswegen NIE automatisch.")]
public double WatchdogMinCashPercent { get; set; } = 2.0;
[Category("Kapital")]
[DisplayName("Reservierungsfrist (Minuten)")]
[Description("Nach dieser Zeit ohne Endzustand gilt eine Reservierung als überfällig und wird " +
"gemeldet. Sie wird dabei NICHT stillschweigend freigegeben.")]
public int ReservationTtlMinutes { get; set; } = 120;
[Category("Kapital")]
[DisplayName("Preispuffer Limit-Order (%)")]
[Description("Aufschlag auf den Referenzpreis bei der Reservierung einer Limit-Order. " +
"Klein, weil das Limit selbst schon die Obergrenze ist.")]
public double LimitOrderBufferPercent { get; set; } = 1.0;
[Category("Kapital")]
[DisplayName("Preispuffer Market-Order (%)")]
[Description("Aufschlag auf den Referenzpreis bei der Reservierung einer Market-Order. " +
"Größer, weil der Ausführungspreis offen ist (Slippage).")]
public double MarketOrderBufferPercent { get; set; } = 5.0;
[Category("Kapital")]
[DisplayName("Anteile je Buch")]
[Description("Anteil am verteilbaren Kapital je Buch, z. B. {\"CT\": 0.5, \"OW\": 0.5}. " +
"Leer = Gleichverteilung auf alle registrierten Module. " +
"Die Summe darf 100 % nicht überschreiten – sonst verdrängt das schnellste Buch die übrigen.")]
public Dictionary<string, double> BookShares { get; set; } = new();
public override string ToString() =>
$"Hausreserve {HouseReservePercent:0.#} %, max. {MaxSymbolPercent:0.#} % je Einzelwert";
}
// ─── Accounting ──────────────────────────────────────────────────────────────
[TypeConverter(typeof(ExpandableObjectConverter))]
@@ -398,6 +455,11 @@ public class AppSettings
[Description("Buchhaltung/Reporting (unabhängiger Kontoauszug-Ingest)")]
public AccountingSettings Accounting { get; set; } = new();
[Category("Kapital")]
[DisplayName("Kapital")]
[Description("Kapital- und Buchmodell: Pool, Obergrenzen je Buch, Margin-Sperre, Reservierungen")]
public CapitalSettings Capital { get; set; } = new();
[Category("Supervisor")]
[DisplayName("Supervisor")]
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
@@ -0,0 +1,42 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Die Ports von TWS und IB Gateway – und welcher davon echtes Geld bewegt.
///
/// <para>Bewusst öffentlich und außerhalb des Adapters: <c>IbkrMapping</c> ist <c>internal</c>,
/// weil es Adapter-Interna sind. <b>Welcher Port das Livekonto ist</b>, muss aber jeder wissen
/// dürfen, der eine Sicherung baut – etwa der Order-Nachweis, der sich weigert, außerhalb des
/// Papierkontos zu laufen. Die Alternative wäre, die Zahlen an zwei Stellen zu führen; die
/// zweite Stelle ist dann irgendwann die falsche.</para>
/// </summary>
public static class BrokerPorts
{
/// <summary>IB Gateway, Papierkonto.</summary>
public const int GatewayPaper = 4002;
/// <summary>IB Gateway, Livekonto.</summary>
public const int GatewayLive = 4001;
/// <summary>TWS Desktop, Papierkonto.</summary>
public const int TwsPaper = 7497;
/// <summary>TWS Desktop, Livekonto.</summary>
public const int TwsLive = 7496;
public static bool IsLive (int port) => port is GatewayLive or TwsLive;
public static bool IsPaper(int port) => port is GatewayPaper or TwsPaper;
/// <summary>
/// Beschreibt einen Widerspruch zwischen Handelsmodus und Port, sonst <c>null</c>. Ein
/// Paper-Modus auf einem Live-Port würde echtes Geld bewegen, obwohl der Betreiber einen Test
/// erwartet; nicht standardisierte Ports werden durchgelassen.
/// </summary>
public static string? ValidateMode(int port, TradingMode mode)
{
if (mode == TradingMode.Paper && IsLive(port))
return $"Handelsmodus ist Paper, Port {port} ist jedoch ein LIVE-Port.";
if (mode == TradingMode.Live && IsPaper(port))
return $"Handelsmodus ist Live, Port {port} ist jedoch ein Paper-Port.";
return null;
}
}
@@ -20,7 +20,9 @@ public sealed class DashboardService
var trades = await db.TradeHistory.CountAsync(ct);
return new DashboardSnapshot(
OpenPositions: positions.Count,
TotalExposure: positions.Sum(p => p.Quantity * p.AvgPrice),
// Mit Kontraktgroesse und Betrag: ohne den Multiplikator waere eine Option hier um
// Faktor 100 zu klein, ohne den Betrag wuerde eine verkaufte Option die Summe senken.
TotalExposure: positions.Sum(p => Math.Abs(p.Quantity) * p.AvgPrice * p.Multiplier),
TotalTrades: trades);
}
}
+68 -20
View File
@@ -1,4 +1,5 @@
using System.Text.Json;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Persistence.Entities;
@@ -23,6 +24,7 @@ public sealed class ExecutionService : IExecutionService
private readonly LoggingService _logger;
private readonly IDecisionJournal _journal;
private readonly IOrderEventLog _orderEvents;
private readonly ICapitalService _capital;
public ExecutionService(
IBrokerClient broker,
@@ -31,7 +33,8 @@ public sealed class ExecutionService : IExecutionService
SettingsService settings,
LoggingService logger,
IDecisionJournal journal,
IOrderEventLog orderEvents)
IOrderEventLog orderEvents,
ICapitalService capital)
{
_broker = broker;
_risk = risk;
@@ -40,6 +43,7 @@ public sealed class ExecutionService : IExecutionService
_logger = logger;
_journal = journal;
_orderEvents = orderEvents;
_capital = capital;
}
public async Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default)
@@ -63,19 +67,22 @@ public sealed class ExecutionService : IExecutionService
return Log(module, ExecutionResult.Skip($"Kein Kurs für {signal.Symbol} verfügbar."));
}
// 3. Konto + 4. bestehende Exposure/Position
var account = await _broker.GetAccountStateAsync(ct);
var exposure = await _portfolio.GetModuleExposureAsync(module, ct);
var existingQty = await _portfolio.GetPositionQuantityAsync(module, signal.Symbol, ct);
// 3. Buch bestimmen. NICHT aus dem Signal uebernommen: SourceModule kommt aus dem Modul
// und ist kein Ausweis. Ein unbekannter Name wuerde ein Buch ohne Cap erzeugen - also
// ein unbegrenztes.
var book = _capital.ResolveBook(module);
if (book is null)
{
Journal(signal, TradeDecision.Rejected, DecisionReason.RiskRejected,
$"Unbekanntes Buch '{module}' – Signal abgelehnt.");
return Log(module, ExecutionResult.Skip($"Unbekanntes Buch '{module}'."));
}
// 4. Konto und Bewertungskontext (Deckung, Spielraum, Konzentration, Watchdog)
var account = await _broker.GetAccountStateAsync(ct);
var context = await _capital.BuildContextAsync(book.Value, signal.Instrument, quote.Last, account, ct);
// 5. Risikoprüfung
var context = new RiskContext
{
Price = quote.Last,
NetLiquidation = account.NetLiquidation,
ModuleExposure = exposure,
ExistingQuantity = existingQty
};
var riskParams = new RiskParameters(
(decimal)trading.MaxTradePercent,
(decimal)trading.MaxPositionPercentPerModule,
@@ -84,23 +91,59 @@ public sealed class ExecutionService : IExecutionService
if (!decision.Approved)
{
Journal(signal, TradeDecision.Rejected, DecisionReason.RiskRejected, decision.Reason,
new { price = quote.Last, netLiq = account.NetLiquidation, exposure, existingQty });
new
{
price = quote.Last,
netLiq = account.NetLiquidation,
bound = context.ModuleExposure,
held = context.ExistingQuantity,
breach = decision.Breach?.Kind.ToString(),
maxQty = decision.Breach?.MaxQuantity
});
return Log(module, ExecutionResult.Skip(decision.Reason));
}
// 6. Order platzieren
var orderType = signal.LimitPrice.HasValue ? OrderType.Limit : OrderType.Market;
// 6. Kapital binden – VOR der Order. Andersherum entstünde ein Fenster, in dem die Order
// beim Broker liegt und niemand weiß, dass das Geld vergeben ist.
var reservation = await _capital.ReserveAsync(
book.Value, signal, signal.Instrument, signal.Side,
decision.Quantity, decision.ClosingQuantity, quote.Last, orderType, account, ct: ct);
if (!reservation.Success)
{
var reason = reservation.Breach?.ToString() ?? "Kapitalbindung abgelehnt.";
Journal(signal, TradeDecision.Rejected, DecisionReason.RiskRejected, reason);
return Log(module, ExecutionResult.Skip(reason));
}
if (reservation.WasExisting)
{
// Zu diesem Signal liegt bereits eine Bindung vor – also auch bereits eine Order.
// Ein zweites Mal zu senden waere eine Doppelorder.
const string doppelt = "Zu diesem Signal besteht bereits eine Kapitalbindung – keine zweite Order.";
Journal(signal, TradeDecision.Skipped, DecisionReason.RiskRejected, doppelt);
return Log(module, ExecutionResult.Skip(doppelt));
}
var reservationId = reservation.Reservation!.Id;
// 7. Order platzieren
var order = new OrderRequest
{
Symbol = signal.Symbol,
Side = signal.Side,
Quantity = decision.Quantity,
Type = signal.LimitPrice.HasValue ? OrderType.Limit : OrderType.Market,
LimitPrice = signal.LimitPrice
Type = orderType,
LimitPrice = signal.LimitPrice,
Option = signal.Option
};
var result = await _broker.PlaceOrderAsync(order, ct);
if (!result.Success)
{
await _capital.CancelReservationAsync(
reservationId, result.Error ?? "Order fehlgeschlagen.", ct);
OrderEvent(signal, OrderEventType.PlaceFailed, sideStr, order, quote.Last, result.Error ?? "Order fehlgeschlagen.");
Journal(signal, TradeDecision.Failed, DecisionReason.OrderFailed, result.Error ?? "Order fehlgeschlagen.");
return Log(module, ExecutionResult.Error(result.Error ?? "Order fehlgeschlagen.", result));
@@ -109,14 +152,19 @@ public sealed class ExecutionService : IExecutionService
OrderEvent(signal, OrderEventType.Filled, sideStr, order, result.AvgFillPrice, result.OrderId ?? "OK",
new { filled = result.FilledQuantity, avgPrice = result.AvgFillPrice });
// 7. Buchung
// 8. Buchung – die Bindung geht in eine Position über.
await _portfolio.RecordFillAsync(
module, signal.Symbol, signal.Side,
module, signal.Instrument, signal.Side,
result.FilledQuantity, result.AvgFillPrice, result.OrderId, signal.SignalId, ct);
// 9. Bindung abschließen. Reserviert wurde mit Puffer, ausgeführt zum echten Preis – die
// Differenz fließt zurück in den Pool.
var actualAmount = result.FilledQuantity * result.AvgFillPrice * signal.Instrument.Multiplier;
await _capital.SettleReservationAsync(reservationId, result.FilledQuantity, actualAmount, ct);
Journal(signal, TradeDecision.Executed, DecisionReason.OrderPlaced,
$"Ausgeführt: {sideStr} {result.FilledQuantity}x {signal.Symbol} @ {result.AvgFillPrice:F2}",
new { orderId = result.OrderId, filled = result.FilledQuantity, avgPrice = result.AvgFillPrice });
$"Ausgeführt: {sideStr} {result.FilledQuantity}x {signal.Instrument.Key} @ {result.AvgFillPrice:F2}",
new { orderId = result.OrderId, filled = result.FilledQuantity, avgPrice = result.AvgFillPrice, book = book.Value });
return Log(module, ExecutionResult.Execute(result));
}
@@ -3,21 +3,48 @@ namespace IBKRTrader.Core.Trading;
/// <summary>
/// Buchführung über offene Positionen und Ausführungen (core_position, core_trade_history).
/// Externer Seam gegenüber der Datenbank: in Tests gemockt.
///
/// <para>Alle Abfragen laufen über <see cref="InstrumentRef"/> statt über das Ticker-Symbol. Der
/// Unterschied ist nicht kosmetisch: auf denselben Basiswert können die Aktie und mehrere
/// Optionskontrakte gleichzeitig offen sein, und eine Abfrage nach "AAPL" könnte nicht sagen,
/// welche davon gemeint ist.</para>
/// </summary>
public interface IPortfolioService
{
/// <summary>Summe des offenen Nominalwerts eines Moduls.</summary>
/// <summary>
/// Summe des <b>gebundenen Kapitals</b> eines Buchs – nicht die Summe der Marktwerte. Bei
/// verkauften Puts zählt die Verpflichtung (Strike × Kontraktgröße), nicht die vereinnahmte
/// Prämie; siehe <see cref="Position.BoundCapital"/>.
/// </summary>
Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default);
/// <summary>Gehaltene Stückzahl eines Moduls für ein Symbol (0, wenn keine Position).</summary>
Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default);
/// <summary>
/// Gehaltene Menge eines Buchs für ein Instrument (0, wenn keine Position).
/// <b>Vorzeichenbehaftet:</b> negativ bei einer verkauften Option.
/// </summary>
Task<int> GetPositionQuantityAsync(string module, InstrumentRef instrument, CancellationToken ct = default);
/// <summary>Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.</summary>
Task RecordFillAsync(
string module, string symbol, TradeSide side,
string module, InstrumentRef instrument, TradeSide side,
int quantity, decimal price, string? orderId, string? signalId = null,
CancellationToken ct = default);
/// <summary>Alle offenen Positionen eines Moduls.</summary>
/// <summary>Alle offenen Positionen eines Buchs (long wie short).</summary>
Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default);
/// <summary>
/// Alle offenen Positionen eines Buchs auf einen Basiswert – die Aktie <b>und</b> jeden
/// Kontrakt darauf. Grundlage der Deckungsrechnung: welche Aktien sind noch frei, welche
/// bereits durch geschriebene Calls gebunden.
/// </summary>
Task<IReadOnlyList<Position>> GetPositionsForUnderlyingAsync(
string module, string symbol, CancellationToken ct = default);
/// <summary>
/// Alle offenen Positionen <b>über sämtliche Bücher</b>. Das Konzentrationslimit je Einzelwert
/// rechnet bewusst über alle Bücher einschließlich MANUAL – hält der Betreiber 50 % AAPL von
/// Hand, sind die Module bei AAPL gesperrt.
/// </summary>
Task<IReadOnlyList<Position>> GetAllPositionsAsync(CancellationToken ct = default);
}
@@ -57,7 +57,7 @@ public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, ID
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
var contract = await _connection!.ResolveContractAsync(symbol, _requestTimeout, ct).ConfigureAwait(false);
var contract = await _connection!.ResolveContractAsync(InstrumentRef.ForStock(symbol), _requestTimeout, ct).ConfigureAwait(false);
if (contract is null) return null;
return await _connection.RequestQuoteAsync(contract, symbol, _requestTimeout, ct).ConfigureAwait(false);
@@ -75,9 +75,9 @@ public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, ID
if (!await IsReadyAsync(ct).ConfigureAwait(false))
return OrderResult.Fail("Keine Verbindung zur TWS bzw. zum IB Gateway.");
var contract = await _connection!.ResolveContractAsync(request.Symbol, _requestTimeout, ct).ConfigureAwait(false);
var contract = await _connection!.ResolveContractAsync(request.Instrument, _requestTimeout, ct).ConfigureAwait(false);
if (contract is null)
return OrderResult.Fail($"Kontrakt für {request.Symbol} nicht auflösbar – Order nicht platziert.");
return OrderResult.Fail($"Kontrakt für {request.Instrument} nicht auflösbar – Order nicht platziert.");
var result = await _connection.PlaceOrderAsync(request, contract, _orderTimeout, ct).ConfigureAwait(false);
@@ -267,34 +267,45 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
/// Löst ein Symbol in einen vollständigen Kontrakt auf (inklusive ConId, damit Kurse und
/// Orders dasselbe Instrument treffen). Ergebnisse werden für die Laufzeit zwischengespeichert.
/// </summary>
public async Task<Contract?> ResolveContractAsync(string symbol, TimeSpan timeout, CancellationToken ct)
/// <summary>
/// Löst ein Instrument gegen TWS in einen vollständigen Kontrakt auf – Aktie wie Option.
///
/// <para>Der Zwischenspeicher liegt auf dem Instrumentenschlüssel, nicht auf dem Ticker: sonst
/// überschrieben sich alle Optionen desselben Basiswerts gegenseitig und ein Auftrag auf den
/// 150er-Call träfe den zuletzt aufgelösten Kontrakt.</para>
/// </summary>
public async Task<Contract?> ResolveContractAsync(InstrumentRef instrument, TimeSpan timeout, CancellationToken ct)
{
if (_contractCache.TryGetValue(symbol, out var cached)) return cached;
var key = instrument.Key;
if (_contractCache.TryGetValue(key, out var cached)) return cached;
var id = NextRequestId();
var slot = new ContractSlot();
_contracts[id] = slot;
try
{
_socket.reqContractDetails(id, IbkrMapping.Stock(symbol));
var query = instrument.Option is { } option
? IbkrMapping.Option(option)
: IbkrMapping.Stock(instrument.Symbol);
_socket.reqContractDetails(id, query);
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, $"Zeitüberschreitung bei der Kontraktsuche für {symbol}.");
_logger.Warn(LogModule, $"Zeitüberschreitung bei der Kontraktsuche für {key}.");
return null;
}
if (slot.Error is not null)
{
_logger.Warn(LogModule, $"Kontrakt {symbol} nicht auflösbar: {slot.Error}");
_logger.Warn(LogModule, $"Kontrakt {key} nicht auflösbar: {slot.Error}");
return null;
}
if (slot.First is null)
{
_logger.Warn(LogModule, $"Kein Kontrakt für {symbol} gefunden.");
_logger.Warn(LogModule, $"Kein Kontrakt für {key} gefunden.");
return null;
}
_contractCache[symbol] = slot.First;
_contractCache[key] = slot.First;
return slot.First;
}
finally
@@ -335,7 +346,11 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
_accounts[id] = slot;
try
{
_socket.reqAccountSummary(id, "All", "NetLiquidation,AvailableFunds");
// $LEDGER liefert zusaetzlich die Bargeldbestaende je Waehrung. Sie werden fuer die
// Waehrungsdeckung gebraucht (Kapitalmodell §4 Schicht C): ein Kauf in X darf nur aus
// Cash in X finanziert werden, sonst entsteht ein Sollsaldo in X - ein Margin-Kredit,
// auch wenn das Konto in Summe positiv aussieht.
_socket.reqAccountSummary(id, "All", "NetLiquidation,AvailableFunds,TotalCashValue,$LEDGER");
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
@@ -350,7 +365,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
return new AccountState(
ReadDecimal(slot.Values, "NetLiquidation"),
ReadDecimal(slot.Values, "AvailableFunds"));
ReadDecimal(slot.Values, "AvailableFunds"),
ReadDecimal(slot.Values, "TotalCashValue"),
slot.Cash.Count == 0 ? null : slot.Cash);
}
finally
{
@@ -523,7 +540,20 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
public override void accountSummary(int reqId, string account, string tag, string value, string currency)
{
if (_accounts.TryGetValue(reqId, out var slot)) slot.Values[tag] = value;
if (!_accounts.TryGetValue(reqId, out var slot)) return;
slot.Values[tag] = value;
// Aus dem Ledger interessiert genau ein Wert je Waehrung: der Bargeldbestand. "BASE" ist
// die Zusammenfassung in Kontobasiswaehrung und keine eigene Waehrung - wuerde sie mit
// aufgenommen, zaehlte dasselbe Geld zweimal.
if (tag.Equals("TotalCashBalance", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(currency) &&
!currency.Equals("BASE", StringComparison.OrdinalIgnoreCase) &&
decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var cash))
{
slot.Cash[currency.ToUpperInvariant()] = cash;
}
}
public override void accountSummaryEnd(int reqId)
@@ -722,7 +752,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private sealed class AccountSlot : Slot
{
public readonly Dictionary<string, string> Values = new(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary<string, string> Values = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Bargeld je Waehrung aus dem $LEDGER-Abschnitt.</summary>
public readonly Dictionary<string, decimal> Cash = new(StringComparer.OrdinalIgnoreCase);
}
private sealed class ContractSlot : Slot
+41 -22
View File
@@ -9,31 +9,21 @@ namespace IBKRTrader.Core.Trading.Ibkr;
/// </summary>
internal static class IbkrMapping
{
// IB Gateway
public const int GatewayPaperPort = 4002;
public const int GatewayLivePort = 4001;
// TWS Desktop
public const int TwsPaperPort = 7497;
public const int TwsLivePort = 7496;
// Die Portnummern selbst stehen in BrokerPorts: sie werden auch ausserhalb des Adapters
// gebraucht (der Order-Nachweis weigert sich, ausserhalb des Papierkontos zu laufen), und
// dieser Typ ist internal. Hier bleiben nur die Weiterleitungen, damit der bestehende
// Adaptercode und seine Tests unveraendert lesen.
public const int GatewayPaperPort = BrokerPorts.GatewayPaper;
public const int GatewayLivePort = BrokerPorts.GatewayLive;
public const int TwsPaperPort = BrokerPorts.TwsPaper;
public const int TwsLivePort = BrokerPorts.TwsLive;
public static bool IsLivePort (int port) => port is GatewayLivePort or TwsLivePort;
public static bool IsPaperPort(int port) => port is GatewayPaperPort or TwsPaperPort;
public static bool IsLivePort (int port) => BrokerPorts.IsLive(port);
public static bool IsPaperPort(int port) => BrokerPorts.IsPaper(port);
/// <summary>
/// Beschreibt einen Widerspruch zwischen Handelsmodus und Port, sonst null. Paper-Modus auf
/// einem Live-Port würde echtes Geld bewegen, obwohl der Betreiber einen Test erwartet;
/// nicht standardisierte Ports werden durchgelassen.
/// </summary>
public static string? ValidatePort(int port, TradingMode mode)
{
if (mode == TradingMode.Paper && IsLivePort(port))
return $"Handelsmodus ist Paper, Port {port} ist jedoch ein LIVE-Port.";
if (mode == TradingMode.Live && IsPaperPort(port))
return $"Handelsmodus ist Live, Port {port} ist jedoch ein Paper-Port.";
return null;
}
public static string? ValidatePort(int port, TradingMode mode) => BrokerPorts.ValidateMode(port, mode);
/// <summary>US-Aktie über die SMART-Route – der einzige Instrumententyp, den die Module handeln.</summary>
/// <summary>US-Aktie über die SMART-Route.</summary>
public static Contract Stock(string symbol) => new()
{
Symbol = symbol.Trim().ToUpperInvariant(),
@@ -42,6 +32,35 @@ internal static class IbkrMapping
Currency = "USD"
};
/// <summary>
/// US-Aktienoption über die SMART-Route.
///
/// <para><c>LastTradeDateOrContractMonth</c> muss der volle Tag "yyyyMMdd" sein. Gibt man nur
/// den Monat an, liefert TWS den Kontrakt des Standard-Verfallstermins – bei Basiswerten mit
/// wöchentlichen Verfällen also einen anderen als den gemeinten, ohne Fehlermeldung.</para>
///
/// <para><c>TradingClass</c> wird nur gesetzt, wenn der Aufrufer sie kennt. Sie ist nötig,
/// sobald ein Basiswert mehrere Klassen führt (Mini-Optionen, angepasste Kontrakte nach
/// Kapitalmaßnahmen); dann ist das Symbol allein mehrdeutig. Ist sie unbekannt, ist es besser,
/// sie wegzulassen und TWS entscheiden zu lassen, als sie zu raten.</para>
/// </summary>
public static Contract Option(OptionSpec spec) => new()
{
Symbol = spec.Underlying.Trim().ToUpperInvariant(),
SecType = "OPT",
Exchange = "SMART",
Currency = "USD",
LastTradeDateOrContractMonth = spec.ExpiryCode,
Strike = (double)spec.Strike,
Right = spec.RightCode,
Multiplier = spec.Multiplier.ToString(CultureInfo.InvariantCulture),
TradingClass = spec.TradingClass ?? ""
};
/// <summary>Baut den Kontrakt passend zum Instrument des Auftrags – Aktie oder Option.</summary>
public static Contract ForRequest(OrderRequest request) =>
request.Option is { } option ? Option(option) : Stock(request.Symbol);
public static Order BuildOrder(OrderRequest request, int orderId, string? account)
{
var isLimit = request.Type == OrderType.Limit;
+207
View File
@@ -0,0 +1,207 @@
using System.Globalization;
namespace IBKRTrader.Core.Trading;
/// <summary>Art des gehandelten Instruments. <see cref="Stock"/> ist die Vorgabe – der Aktienpfad
/// verhält sich damit unverändert, auch wenn nirgends etwas gesetzt wird.</summary>
public enum InstrumentKind { Stock, Option }
/// <summary>Optionsart.</summary>
public enum OptionRight { Call, Put }
/// <summary>
/// Ein Optionskontrakt. Der <see cref="Multiplier"/> gehört zwingend dazu: ohne ihn rechnet jede
/// Bewertung um Faktor 100 daneben, und zwar lautlos und in die gefährliche Richtung – ein
/// Risikolimit, das mit dem Hundertstel des echten Werts prüft, lehnt nie etwas ab.
/// </summary>
/// <param name="Underlying">Basiswert, z. B. "AAPL".</param>
/// <param name="Expiry">Verfallstag (Börsentag, kein Zeitpunkt – deshalb <see cref="DateOnly"/>).</param>
/// <param name="Strike">Ausübungspreis in der Währung des Kontrakts.</param>
/// <param name="Right">Call oder Put.</param>
/// <param name="Multiplier">Kontraktgröße; bei US-Aktienoptionen 100.</param>
/// <param name="TradingClass">
/// Optional. Nötig, sobald ein Basiswert mehrere Klassen führt (nach Splits, bei Mini-Optionen) –
/// dann ist das Symbol allein nicht mehr eindeutig und TWS liefert den falschen Kontrakt.
/// </param>
public sealed record OptionSpec(
string Underlying,
DateOnly Expiry,
decimal Strike,
OptionRight Right,
int Multiplier = 100,
string? TradingClass = null)
{
/// <summary>Kontraktbezeichner "C"/"P", wie ihn die TWS-API erwartet.</summary>
public string RightCode => Right == OptionRight.Call ? "C" : "P";
/// <summary>Verfallstag als "yyyyMMdd" – das Format von <c>LastTradeDateOrContractMonth</c>.</summary>
public string ExpiryCode => Expiry.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
/// <summary>
/// Deckungsbedarf eines <b>verkauften</b> Kontrakts in Geld: der Betrag, den eine Zuteilung
/// kostet. Beim Put ist das Strike × Multiplikator (Cash-Secured); beim Call wird nicht mit
/// Geld, sondern mit Aktien gedeckt – siehe <see cref="CoverageShares"/>.
/// </summary>
public decimal CashCoveragePerContract => Strike * Multiplier;
/// <summary>Aktien, die ein verkaufter Call je Kontrakt binden muss (Covered Call).</summary>
public int CoverageShares => Multiplier;
public override string ToString() => InstrumentRef.ForOption(this).Key;
}
/// <summary>
/// Eindeutige, druckbare Kennung eines handelbaren Instruments – der fachliche Schlüssel, unter dem
/// eine Position, eine Reservierung und ein Order-Ereignis dieselbe Sache meinen.
///
/// <para>Aktie: das Ticker-Symbol ("AAPL"). Option: "AAPL|20260918|C|150" – Pipe-getrennt, weil
/// keiner der Bestandteile eine Pipe enthalten kann, und in <see cref="CultureInfo.InvariantCulture"/>
/// formatiert. Der Strike wird auf drei Nachkommastellen normalisiert, damit 150, 150.0 und 150.000
/// denselben Schlüssel ergeben und nicht drei Positionen aus einer werden.</para>
///
/// <para><b>Warum ein Schlüssel und nicht der Sechser-Verbund (Modul, Symbol, Kind, Expiry, Strike,
/// Right):</b> MySQL verlangt für Primärschlüsselspalten NOT NULL. Expiry, Strike und Right sind bei
/// Aktien aber leer – man bräuchte Platzhalterwerte wie das Datum 1000-01-01, die man beim Lesen
/// wieder wegdenken muss. Eine Spalte, die bei Aktien schlicht das Ticker-Symbol enthält, ist im
/// Betrieb ehrlicher und in Log, Oberfläche und Fremdschlüssel direkt lesbar.</para>
/// </summary>
public readonly record struct InstrumentRef
{
private InstrumentRef(string key, InstrumentKind kind, string symbol, OptionSpec? option)
{
Key = key;
Kind = kind;
Symbol = symbol;
Option = option;
}
/// <summary>Der Schlüssel selbst – Primärschlüsselbestandteil und Fremdschlüssel.</summary>
public string Key { get; }
public InstrumentKind Kind { get; }
/// <summary>Ticker der Aktie bzw. des Basiswerts der Option. Nie leer.</summary>
public string Symbol { get; }
/// <summary>Kontraktangaben, wenn <see cref="Kind"/> = <see cref="InstrumentKind.Option"/>.</summary>
public OptionSpec? Option { get; }
/// <summary>Kontraktgröße: 1 bei Aktien, sonst der Multiplikator der Option.</summary>
public int Multiplier => Option?.Multiplier ?? 1;
public static InstrumentRef ForStock(string symbol)
{
var s = Normalize(symbol);
return new InstrumentRef(s, InstrumentKind.Stock, s, null);
}
public static InstrumentRef ForOption(OptionSpec spec)
{
var underlying = Normalize(spec.Underlying);
var key = string.Create(CultureInfo.InvariantCulture,
$"{underlying}|{spec.ExpiryCode}|{spec.RightCode}|{FormatStrike(spec.Strike)}");
return new InstrumentRef(key, InstrumentKind.Option, underlying, spec with { Underlying = underlying });
}
/// <summary>
/// Liest einen Schlüssel zurück. Liefert <c>false</c> bei allem, was nicht dem obigen Aufbau
/// entspricht – ein unlesbarer Schlüssel wird nie geraten, sondern gemeldet.
/// </summary>
public static bool TryParse(string? key, out InstrumentRef instrument)
{
instrument = default;
if (string.IsNullOrWhiteSpace(key)) return false;
var parts = key.Split('|');
if (parts.Length == 1)
{
instrument = ForStock(parts[0]);
return true;
}
if (parts.Length != 4) return false;
if (!DateTime.TryParseExact(parts[1], "yyyyMMdd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var expiry))
return false;
var right = parts[2].ToUpperInvariant() switch
{
"C" => (OptionRight?)OptionRight.Call,
"P" => OptionRight.Put,
_ => null
};
if (right is null) return false;
if (!decimal.TryParse(parts[3], NumberStyles.Number, CultureInfo.InvariantCulture, out var strike))
return false;
instrument = ForOption(new OptionSpec(parts[0], DateOnly.FromDateTime(expiry), strike, right.Value));
return true;
}
/// <summary>Der Schlüssel des Basiswerts – bei einer Option die Aktie, auf die sie lautet.</summary>
public InstrumentRef UnderlyingRef => Kind == InstrumentKind.Stock ? this : ForStock(Symbol);
public override string ToString() => Key;
private static string Normalize(string symbol)
{
var s = symbol.Trim().ToUpperInvariant();
if (s.Length == 0) throw new ArgumentException("Symbol darf nicht leer sein.", nameof(symbol));
if (s.Contains('|')) throw new ArgumentException($"Symbol enthält das Trennzeichen '|': {symbol}", nameof(symbol));
return s;
}
/// <summary>
/// Strike auf drei Nachkommastellen, ohne nachlaufende Nullen. Drei genügen: die feinste bei
/// IBKR gehandelte Staffelung ist ein halber Cent (0,005).
/// </summary>
private static string FormatStrike(decimal strike) =>
Math.Round(strike, 3).ToString("0.###", CultureInfo.InvariantCulture);
}
/// <summary>
/// Kurs und Griechen eines Optionskontrakts.
///
/// <para>Die Werte kommen bei einem Paper-Konto ohne OPRA-Abo über die <b>verzögerten</b>
/// Tick-Felder 80–83; maßgeblich für die Strike-Wahl ist Feld 83 (Modell). Am 2026-08-04 gegen das
/// laufende Gateway verifiziert: für <c>AAPL 20260821 C305</c> kamen IV 0,2730 / Delta 0,5443 /
/// Gamma 0,0220 / Vega 0,2626 / Theta −0,2304.</para>
///
/// <para>Der Abruf selbst ist noch nicht gebaut (#299). Die Datenform steht trotzdem hier im Core
/// und nicht im Modul: sie ist das Vokabular, in dem der Broker-Seam später antwortet, und ein
/// Modul, das seine eigene Fassung mitbrächte, müsste beim Anschluss umgebaut werden.</para>
/// </summary>
/// <param name="Spec">Der Kontrakt.</param>
/// <param name="Bid">Geldkurs; 0, wenn keiner vorliegt.</param>
/// <param name="Ask">Briefkurs; 0, wenn keiner vorliegt.</param>
/// <param name="Last">Letzter Preis; 0, wenn keiner vorliegt.</param>
/// <param name="Delta">Delta aus dem Modell. Bei einem Put negativ.</param>
/// <param name="ImpliedVolatility">Implizite Volatilität als Faktor (0,27 = 27 %).</param>
/// <param name="OpenInterest">Offene Kontrakte; -1, wenn unbekannt.</param>
public sealed record OptionQuote(
OptionSpec Spec,
decimal Bid,
decimal Ask,
decimal Last = 0m,
decimal? Delta = null,
decimal? ImpliedVolatility = null,
int OpenInterest = -1)
{
/// <summary>Mitte zwischen Geld und Brief – der Preis, zu dem limitiert wird.</summary>
public decimal Mid => Bid > 0m && Ask > 0m ? (Bid + Ask) / 2m : Last;
/// <summary>
/// Spanne zwischen Geld und Brief, bezogen auf die Mitte. <c>null</c>, wenn keine beidseitige
/// Notierung vorliegt – ein einseitiger Markt hat keine messbare Spanne, und ihn als "Spanne 0"
/// zu behandeln wäre die gefährliche Richtung.
/// </summary>
public decimal? SpreadPercent =>
Bid > 0m && Ask > 0m && Mid > 0m ? (Ask - Bid) / Mid * 100m : null;
/// <summary>
/// Delta als Betrag. Beim Put meldet TWS ein negatives Delta; das Zielband der Strike-Wahl
/// (0,15–0,30) ist für beide Seiten als Betrag gemeint.
/// </summary>
public decimal? AbsDelta => Delta is { } d ? Math.Abs(d) : null;
}
+102 -28
View File
@@ -31,15 +31,15 @@ public sealed class PortfolioService : IPortfolioService
public async Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var positions = await db.Positions.Where(p => p.Module == module).ToListAsync(ct);
return positions.Sum(p => p.Quantity * p.AvgPrice);
var positions = await GetPositionsAsync(module, ct).ConfigureAwait(false);
return positions.Sum(p => p.BoundCapital);
}
public async Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
public async Task<int> GetPositionQuantityAsync(string module, InstrumentRef instrument,
CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var pos = await db.Positions.FindAsync([module, symbol], ct);
var pos = await db.Positions.FindAsync([module, instrument.Key], ct);
return pos?.Quantity ?? 0;
}
@@ -47,51 +47,125 @@ public sealed class PortfolioService : IPortfolioService
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var rows = await db.Positions
.Where(p => p.Module == module && p.Quantity > 0)
.Where(p => p.Module == module && p.Quantity != 0)
.ToListAsync(ct);
return rows.Select(p => new Position(p.Module, p.Symbol, p.Quantity, p.AvgPrice)).ToList();
return Map(rows);
}
public async Task<IReadOnlyList<Position>> GetPositionsForUnderlyingAsync(
string module, string symbol, CancellationToken ct = default)
{
var ticker = symbol.Trim().ToUpperInvariant();
await using var db = await _dbf.CreateDbContextAsync(ct);
var rows = await db.Positions
.Where(p => p.Module == module && p.Symbol == ticker && p.Quantity != 0)
.ToListAsync(ct);
return Map(rows);
}
public async Task<IReadOnlyList<Position>> GetAllPositionsAsync(CancellationToken ct = default)
{
await using var db = await _dbf.CreateDbContextAsync(ct);
var rows = await db.Positions.Where(p => p.Quantity != 0).ToListAsync(ct);
return Map(rows);
}
public async Task RecordFillAsync(
string module, string symbol, TradeSide side,
string module, InstrumentRef instrument, TradeSide side,
int quantity, decimal price, string? orderId, string? signalId = null,
CancellationToken ct = default)
{
if (quantity <= 0) return;
var action = side == TradeSide.Buy ? "BUY" : "SELL";
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId, signalId: signalId);
await _history.RecordTradeAsync(module, instrument.Key, action, quantity, price, orderId, signalId: signalId);
PositionMath.Applied applied;
decimal boundBefore, boundAfter;
await using (var db = await _dbf.CreateDbContextAsync(ct))
{
var pos = await db.Positions.FindAsync([module, symbol], ct);
var oldQty = pos?.Quantity ?? 0;
var oldAvg = pos?.AvgPrice ?? 0m;
var pos = await db.Positions.FindAsync([module, instrument.Key], ct);
applied = PositionMath.Apply(pos?.Quantity ?? 0, pos?.AvgPrice ?? 0m, side, quantity, price);
if (side == TradeSide.Buy)
boundBefore = BoundCapital(module, instrument, pos?.Quantity ?? 0, pos?.AvgPrice ?? 0m);
boundAfter = BoundCapital(module, instrument, applied.Quantity, applied.AvgPrice);
if (applied.Quantity == 0)
{
var newQty = oldQty + quantity;
var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price;
if (pos is null)
db.Positions.Add(new CorePosition { Module = module, Symbol = symbol, Quantity = newQty, AvgPrice = newAvg, UpdatedAt = DateTime.UtcNow });
else
(pos.Quantity, pos.AvgPrice, pos.UpdatedAt) = (newQty, newAvg, DateTime.UtcNow);
// Geschlossen: die Zeile verschwindet. Eine Nullzeile stehen zu lassen waere
// Ballast, den jede spaetere Abfrage wieder herausfiltern muesste.
if (pos is not null) db.Positions.Remove(pos);
}
else if (pos is null)
{
db.Positions.Add(NewRow(module, instrument, applied));
}
else
{
var newQty = oldQty - quantity;
if (pos is not null)
{
if (newQty <= 0) db.Positions.Remove(pos);
else (pos.Quantity, pos.UpdatedAt) = (newQty, DateTime.UtcNow);
}
pos.Quantity = applied.Quantity;
pos.AvgPrice = applied.AvgPrice;
pos.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
}
if (side == TradeSide.Buy) await _budget.ReserveBudgetAsync(module, quantity * price);
else await _budget.ReleaseBudgetAsync(module, quantity * price);
// Budgetwirkung als Differenz des gebundenen Kapitals, nicht als Umsatz des Fills.
// Zwei Gruende: eine verkaufte Option bindet die Verpflichtung und nicht die vereinnahmte
// Praemie, und beim Durchqueren der Null (short 1 Put, dann 2 gekauft) schliesst derselbe
// Fill eine Position und eroeffnet die Gegenposition - "Kauf = reservieren" waere dort
// schlicht falsch. Die Differenz stimmt in jedem dieser Faelle.
var delta = boundAfter - boundBefore;
if (delta > 0) await _budget.ReserveBudgetAsync(module, delta);
else if (delta < 0) await _budget.ReleaseBudgetAsync(module, -delta);
_logger.Info(module, $"Position gebucht: {action} {quantity}x {symbol} @ {price:F2}");
_logger.Info(module,
$"Position gebucht: {action} {quantity}x {instrument.Key} @ {price:F2} " +
$"(Bestand {applied.Quantity:+#;-#;0}).");
}
/// <summary>
/// Gebundenes Kapital einer Menge – dieselbe Definition wie <see cref="Position.BoundCapital"/>,
/// bewusst ueber den Positionstyp gefuehrt, damit es davon nur eine Fassung gibt.
/// </summary>
private static decimal BoundCapital(string module, InstrumentRef instrument, int quantity, decimal avgPrice) =>
quantity == 0 ? 0m : new Position(module, instrument, quantity, avgPrice).BoundCapital;
private static CorePosition NewRow(string module, InstrumentRef instrument, PositionMath.Applied applied) =>
new()
{
Module = module,
ContractKey = instrument.Key,
Symbol = instrument.Symbol,
Kind = instrument.Kind.ToString(),
Expiry = instrument.Option?.Expiry,
Strike = instrument.Option?.Strike,
Right = instrument.Option?.RightCode,
Multiplier = instrument.Multiplier,
Quantity = applied.Quantity,
AvgPrice = applied.AvgPrice,
UpdatedAt = DateTime.UtcNow
};
/// <summary>
/// Bildet Zeilen auf Positionen ab. Ein unlesbarer Instrumentenschlüssel wird gemeldet und die
/// Zeile übersprungen – geraten wird nicht: eine falsch gedeutete Option wäre eine Deckung, die
/// es nicht gibt.
/// </summary>
private IReadOnlyList<Position> Map(IEnumerable<CorePosition> rows)
{
var result = new List<Position>();
foreach (var row in rows)
{
if (!InstrumentRef.TryParse(row.ContractKey, out var instrument))
{
_logger.Error(row.Module,
$"Positionszeile mit unlesbarem Instrumentenschlüssel übersprungen: '{row.ContractKey}'.");
continue;
}
result.Add(new Position(row.Module, instrument, row.Quantity, row.AvgPrice));
}
return result;
}
}
@@ -0,0 +1,73 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Fortschreibung einer Position durch eine Ausführung – reine Rechnung, ohne Datenbank und ohne
/// Broker, damit die Fälle einzeln prüfbar sind.
///
/// <para>Vor der Optionserweiterung genügten zwei Zeilen: kaufen erhöhte den gewichteten
/// Durchschnitt, verkaufen zog ab und löschte die Zeile bei null. Mit verkauften Optionen kommen
/// drei Fälle hinzu, die vorher nicht auftreten konnten – eine Position, die bei null <b>beginnt
/// und negativ wird</b>, eine, die im Minus <b>weiter aufgestockt</b> wird, und eine, die das
/// Vorzeichen <b>durchquert</b>.</para>
/// </summary>
public static class PositionMath
{
/// <summary>Ergebnis einer Fortschreibung.</summary>
/// <param name="Quantity">Neue vorzeichenbehaftete Menge (0 = Position geschlossen).</param>
/// <param name="AvgPrice">Neuer Durchschnittspreis je Stück bzw. Kontrakt.</param>
/// <param name="ClosedQuantity">
/// Wie viele Stück dieser Fill <i>geschlossen</i> hat (0, wenn er nur eröffnet/aufgestockt hat).
/// Die Abrechnung des realisierten Ergebnisses hängt daran.
/// </param>
public readonly record struct Applied(int Quantity, decimal AvgPrice, int ClosedQuantity);
/// <summary>
/// Wendet eine Ausführung auf eine bestehende Position an.
/// </summary>
/// <param name="currentQuantity">Bisherige vorzeichenbehaftete Menge.</param>
/// <param name="currentAvgPrice">Bisheriger Durchschnittspreis.</param>
/// <param name="side">Richtung der Ausführung.</param>
/// <param name="filledQuantity">Ausgeführte Menge, positiv.</param>
/// <param name="fillPrice">Ausführungspreis je Stück bzw. Kontrakt.</param>
public static Applied Apply(
int currentQuantity, decimal currentAvgPrice,
TradeSide side, int filledQuantity, decimal fillPrice)
{
if (filledQuantity <= 0)
return new Applied(currentQuantity, currentAvgPrice, 0);
var delta = side == TradeSide.Buy ? filledQuantity : -filledQuantity;
var newQty = currentQuantity + delta;
var opposing = currentQuantity != 0 && Math.Sign(delta) != Math.Sign(currentQuantity);
// Eröffnen aus der Flachlage: der Fill setzt den Einstand.
if (currentQuantity == 0)
return new Applied(newQty, fillPrice, 0);
// Aufstocken in dieselbe Richtung: gewichteter Durchschnitt über die Beträge. Long wie
// Short – bei einer Short-Option ist es der Durchschnitt der vereinnahmten Prämien.
if (!opposing)
{
var weighted = (Math.Abs(currentQuantity) * currentAvgPrice + filledQuantity * fillPrice)
/ Math.Abs(newQty);
return new Applied(newQty, weighted, 0);
}
// Gegenläufig: es wird geschlossen, höchstens bis zur vorhandenen Menge.
var closed = Math.Min(filledQuantity, Math.Abs(currentQuantity));
// Vollständig geschlossen – die Zeile verschwindet, ein Durchschnitt ist bedeutungslos.
if (newQty == 0)
return new Applied(0, 0m, closed);
// Teilweise geschlossen: der Einstand der verbleibenden Stücke ändert sich nicht. Das
// Ergebnis des geschlossenen Teils ist realisiert und gehört in die Trade-Historie, nicht
// in den Durchschnitt der Restposition.
if (Math.Sign(newQty) == Math.Sign(currentQuantity))
return new Applied(newQty, currentAvgPrice, closed);
// Vorzeichen durchquert: die Altposition ist weg, der Überhang ist eine neue Position in
// der Gegenrichtung und startet beim Ausführungspreis.
return new Applied(newQty, fillPrice, closed);
}
}
+153 -20
View File
@@ -1,14 +1,18 @@
using IBKRTrader.Core.Capital;
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Reine Risiko-/Sizing-Logik – keine externen Abhängigkeiten, vollständig unit-testbar.
///
/// Regeln:
/// - Kauf: Nominalwert = min(Wunsch, Kontowert × MaxTradePercent); Stückzahl = floor(Nominal / Kurs).
/// Ablehnung bei ungültigem Kurs, Stückzahl &lt; 1, Überschreitung des Modul-Limits
/// oder zu hoher Slippage (bei Limit-Order).
/// - Verkauf: schließt die vorhandene Position (Stückzahl = gehaltene Menge);
/// Ablehnung, wenn keine Position vorhanden ist.
/// <para><b>Aufbau.</b> Jeder Auftrag zerfällt in einen <i>schließenden</i> und einen
/// <i>eröffnenden</i> Teil. Der schließende Teil wird nie durch ein Limit verhindert – ein Limit,
/// das das Schließen einer Position blockiert, sperrt den Ausgang. Nur der eröffnende Teil
/// durchläuft Deckung, Buch-Cap und Konzentration.</para>
///
/// <para><b>Niemals nackt.</b> Ein verkaufter Kontrakt ohne Deckung wird hier abgelehnt – im Core,
/// nicht im Modul. Ein Modul, das sich selbst prüft, prüft sich irgendwann nicht mehr; und das
/// OptionsWheel wird das erste Modul sein, dessen Kernoperation ein Verkauf ist.</para>
/// </summary>
public sealed class RiskService : IRiskService
{
@@ -21,41 +25,170 @@ public sealed class RiskService : IRiskService
return RiskDecision.Reject(slipReason);
return signal.Side == TradeSide.Sell
? EvaluateSell(context)
: EvaluateBuy(context, signal, risk);
? EvaluateSell(signal, context, risk)
: EvaluateBuy(signal, context, risk);
}
private static RiskDecision EvaluateSell(RiskContext context)
{
if (context.ExistingQuantity <= 0)
return RiskDecision.Reject("Keine Position zum Verkauf vorhanden.");
// ─── Verkauf ─────────────────────────────────────────────────────────────────
return RiskDecision.Approve(context.ExistingQuantity, "Verkauf schließt Position.");
private static RiskDecision EvaluateSell(TradeSignal signal, RiskContext context, RiskParameters risk)
{
var instrument = signal.Instrument;
var held = context.ExistingQuantity;
if (instrument.Option is null)
{
// Aktienpfad, unverändert: ein Verkauf schließt die vorhandene Position, und ohne
// Bestand gibt es nichts zu verkaufen. Ein Aktien-Short bleibt gesperrt (§4 Schicht B) –
// er ist immer ein Kredit.
if (held <= 0)
return RiskDecision.Reject("Keine Position zum Verkauf vorhanden.");
return RiskDecision.Approve(held, "Verkauf schließt Position.", closingQuantity: held);
}
// Optionen: die Menge bestimmt das Modul, nicht der Kontowert. Eine Prämie lässt sich nicht
// in einen Nominalwert-Wunsch übersetzen; gedeckelt wird über die Deckung.
var want = Math.Max(1, signal.SuggestedQuantity ?? 1);
// Gekaufte Kontrakte glattstellen (sell-to-close): nie limitiert.
if (held > 0)
{
var closing = Math.Min(want, held);
return RiskDecision.Approve(closing, $"Schließt {closing} gekaufte(n) Kontrakt(e).",
closingQuantity: closing);
}
// Sell-to-open: die Kernoperation des Wheels – und die einzige Stelle, an der eine
// Verpflichtung ohne Position entsteht.
var breach = MarginGuard.CheckShortCoverage(
instrument, want, heldQuantity: 0, context.Coverage, context.AvailableCashForReservation);
if (breach is not null)
return RiskDecision.Reject(breach);
var required = RequiredCapital(instrument, want, context.Price, TradeSide.Sell);
if (CheckOpeningLimits(instrument, want, required, context, risk) is { } limitBreach)
return RiskDecision.Reject(limitBreach);
return RiskDecision.Approve(want,
$"{want} Kontrakt(e) gedeckt freigegeben ({DescribeCoverage(instrument, want)}).");
}
private static RiskDecision EvaluateBuy(RiskContext context, TradeSignal signal, RiskParameters risk)
// ─── Kauf ────────────────────────────────────────────────────────────────────
private static RiskDecision EvaluateBuy(TradeSignal signal, RiskContext context, RiskParameters risk)
{
var instrument = signal.Instrument;
var held = context.ExistingQuantity;
// Buy-to-close einer geschriebenen Option: das Gegenstück zum Verkauf oben. Es baut eine
// Verpflichtung ab und darf deshalb an keinem Limit scheitern – auch nicht am Kaufstopp
// des Watchdogs, der Neuengagements verhindern soll und nicht deren Auflösung.
if (held < 0)
{
var want = Math.Max(1, signal.SuggestedQuantity ?? Math.Abs(held));
var closing = Math.Min(want, Math.Abs(held));
return RiskDecision.Approve(closing, $"Schließt {closing} geschriebene(n) Kontrakt(e).",
closingQuantity: closing);
}
if (context.BuyingHalted)
return RiskDecision.Reject(new LimitBreach(
LimitKind.BuyingHalted, instrument.Symbol,
Current: 0m, Limit: 0m, MaxQuantity: 0, MaxNotional: 0m));
if (context.NetLiquidation <= 0)
return RiskDecision.Reject("Kontowert unbekannt oder 0.");
// Der Preis eines Kontrakts ist Kurs × Kontraktgröße. Ohne den Multiplikator käme hier das
// Hundertfache der gewollten Menge heraus.
var unitCost = context.Price * instrument.Multiplier;
var maxNotional = context.NetLiquidation * (risk.MaxTradePercent / 100m);
var notional = signal.SuggestedNotional is { } wish && wish > 0
? Math.Min(wish, maxNotional)
: maxNotional;
var quantity = (int)Math.Floor(notional / context.Price);
var quantity = signal.SuggestedQuantity is { } wantQty && wantQty > 0
? Math.Min(wantQty, (int)Math.Floor(notional / unitCost))
: (int)Math.Floor(notional / unitCost);
if (quantity < 1)
return RiskDecision.Reject("Positionsgröße < 1 Stück bei aktuellem Kurs/Budget.");
var projectedExposure = context.ModuleExposure + quantity * context.Price;
var moduleLimit = context.NetLiquidation * (risk.MaxPositionPercentPerModule / 100m);
if (projectedExposure > moduleLimit)
return RiskDecision.Reject(
$"Modul-Limit überschritten ({projectedExposure:F0} > {moduleLimit:F0}).");
var required = quantity * unitCost;
if (CheckOpeningLimits(instrument, quantity, required, context, risk) is { } breach)
return RiskDecision.Reject(breach);
return RiskDecision.Approve(quantity, $"{quantity} Stück freigegeben.");
}
// ─── Grenzen des eröffnenden Teils ───────────────────────────────────────────
/// <summary>
/// Modul-Limit, Buch-Cap und Konzentration je Einzelwert. Nur für den eröffnenden Teil eines
/// Auftrags – Schließen ist nie limitiert.
/// </summary>
private static LimitBreach? CheckOpeningLimits(
InstrumentRef instrument, int quantity, decimal required, RiskContext context, RiskParameters risk)
{
var unit = quantity > 0 ? required / quantity : 0m;
// Das bestehende Modul-Limit aus den Trading-Einstellungen.
var moduleLimit = context.NetLiquidation * (risk.MaxPositionPercentPerModule / 100m);
var projected = context.ModuleExposure + required;
if (projected > moduleLimit)
return new LimitBreach(
LimitKind.BookCap, instrument.Symbol,
Current: projected, Limit: moduleLimit,
MaxQuantity: MaxUnits(moduleLimit - context.ModuleExposure, unit),
MaxNotional: Math.Max(0m, moduleLimit - context.ModuleExposure));
// Der Cap des Buchs aus dem Kapitalmodell. 0 bedeutet "nicht gesetzt" und wird uebersprungen –
// sonst wuerde ein Aufrufer, der das Kapitalmodell gar nicht befuellt, alles abgelehnt sehen.
if (context.BookHeadroom > 0m && required > context.BookHeadroom)
return new LimitBreach(
LimitKind.BookCap, instrument.Symbol,
Current: required, Limit: context.BookHeadroom,
MaxQuantity: MaxUnits(context.BookHeadroom, unit),
MaxNotional: context.BookHeadroom);
// Konzentration auf den Einzelwert, ueber alle Buecher.
if (context.SymbolLimit > 0m)
{
var projectedSymbol = context.SymbolExposure + required;
if (projectedSymbol > context.SymbolLimit)
return new LimitBreach(
LimitKind.SymbolConcentration, instrument.Symbol,
Current: projectedSymbol, Limit: context.SymbolLimit,
MaxQuantity: MaxUnits(context.SymbolLimit - context.SymbolExposure, unit),
MaxNotional: Math.Max(0m, context.SymbolLimit - context.SymbolExposure));
}
return null;
}
/// <summary>Kapital, das ein eröffnender Auftrag bindet.</summary>
private static decimal RequiredCapital(InstrumentRef instrument, int quantity, decimal price, TradeSide side)
{
if (side == TradeSide.Buy || instrument.Option is not { } option)
return quantity * price * instrument.Multiplier;
// Geschriebene Option: gebunden wird die Verpflichtung, nicht die Prämie. Beim Call ist das
// kein Geld, sondern es sind Aktien – die stecken bereits als eigene Position im Buch.
return option.Right == OptionRight.Put
? quantity * option.CashCoveragePerContract
: 0m;
}
private static int MaxUnits(decimal budget, decimal unitCost) =>
unitCost <= 0m || budget <= 0m ? 0 : (int)Math.Floor(budget / unitCost);
private static string DescribeCoverage(InstrumentRef instrument, int contracts) =>
instrument.Option is { } o && o.Right == OptionRight.Call
? $"{contracts * o.CoverageShares} Aktien gebunden"
: $"{contracts * (instrument.Option?.CashCoveragePerContract ?? 0m):F0} Barmittel gebunden";
private static bool SlippageTooHigh(
TradeSignal signal, RiskContext context, RiskParameters risk, out string reason)
{
+149 -15
View File
@@ -1,3 +1,4 @@
using System.Globalization;
namespace IBKRTrader.Core.Trading;
/// <summary>Kauf oder Verkauf.</summary>
@@ -38,6 +39,23 @@ public sealed record TradeSignal
/// <summary>Optionaler Nominalwert-Wunsch; sonst greift das Risiko-Sizing.</summary>
public decimal? SuggestedNotional { get; init; }
/// <summary>
/// Optionaler Mengenwunsch in Stück bzw. Kontrakten. Beim Eröffnen einer Short-Option ist das
/// die maßgebliche Angabe: eine Prämie lässt sich nicht sinnvoll in einen Nominalwert-Wunsch
/// übersetzen, gedeckelt wird ohnehin über die Deckung.
/// </summary>
public int? SuggestedQuantity { get; init; }
/// <summary>
/// Optionskontrakt, falls das Signal eine Option meint. <c>null</c> = Aktie; dann verhält sich
/// der gesamte Pfad exakt wie vor der Optionserweiterung.
/// </summary>
public OptionSpec? Option { get; init; }
/// <summary>Das gemeinte Instrument – Aktie oder Kontrakt – als eindeutiger Schlüssel.</summary>
public InstrumentRef Instrument =>
Option is null ? InstrumentRef.ForStock(Symbol) : InstrumentRef.ForOption(Option);
}
/// <summary>Konkrete Order-Anforderung an den Broker.</summary>
@@ -45,9 +63,16 @@ public sealed record OrderRequest
{
public required string Symbol { get; init; }
public required TradeSide Side { get; init; }
/// <summary>Stück bei Aktien, Kontrakte bei Optionen – immer positiv; die Richtung steht in <see cref="Side"/>.</summary>
public required int Quantity { get; init; }
public required OrderType Type { get; init; }
public decimal? LimitPrice { get; init; }
/// <summary>Optionskontrakt, falls die Order eine Option meint. <c>null</c> = Aktie.</summary>
public OptionSpec? Option { get; init; }
public InstrumentRef Instrument =>
Option is null ? InstrumentRef.ForStock(Symbol) : InstrumentRef.ForOption(Option);
}
/// <summary>Ergebnis einer Order-Platzierung.</summary>
@@ -69,13 +94,77 @@ public sealed record OrderResult
/// <summary>Momentaufnahme eines Kurses.</summary>
public sealed record Quote(string Symbol, decimal Last, decimal Bid, decimal Ask);
/// <summary>Kontostand-Momentaufnahme des Brokers.</summary>
public sealed record AccountState(decimal NetLiquidation, decimal AvailableFunds);
/// <summary>Offene Position eines Moduls.</summary>
public sealed record Position(string Module, string Symbol, int Quantity, decimal AvgPrice)
/// <summary>
/// Kontostand-Momentaufnahme des Brokers.
///
/// <para><see cref="NetLiquidation"/> und <see cref="AvailableFunds"/> allein taugen <b>nicht</b>
/// als Bemessungsgrundlage für die Kaufkraft der Module: auf einem Konto mit Margin sind beide
/// bereits gehebelt gerechnet. Maßgeblich ist <see cref="TotalCashValue"/> – das echte Bargeld.
/// Eröffnet der Betreiber von Hand eine Margin-Position, sinkt es, und die Modul-Kaufkraft
/// schrumpft automatisch mit; bei 0 ist sie null, ganz ohne Sonderfall
/// (Kapitalmodell §4 Schicht A).</para>
/// </summary>
/// <param name="NetLiquidation">Netto-Liquidationswert in Kontobasiswährung.</param>
/// <param name="AvailableFunds">Verfügbare Mittel laut Broker (auf Margin-Konten gehebelt).</param>
/// <param name="TotalCashValue">Bargeldbestand in Kontobasiswährung.</param>
/// <param name="CashByCurrency">
/// Bargeld je Währung. Leer, wenn der Broker keine Aufschlüsselung lieferte – dann greift
/// Schicht C (Währungsdeckung) mangels Daten nicht und der Kauf wird abgelehnt, statt auf gut Glück
/// durchgelassen zu werden.
/// </param>
public sealed record AccountState(
decimal NetLiquidation,
decimal AvailableFunds,
decimal TotalCashValue = 0m,
IReadOnlyDictionary<string, decimal>? CashByCurrency = null)
{
public decimal Notional => Quantity * AvgPrice;
/// <summary>Bargeld in einer bestimmten Währung; <c>null</c>, wenn dazu nichts vorliegt.</summary>
public decimal? CashIn(string currency) =>
CashByCurrency is not null && CashByCurrency.TryGetValue(currency, out var v) ? v : null;
}
/// <summary>
/// Offene Position eines Buchs.
///
/// <para><b>Die Menge ist vorzeichenbehaftet:</b> positiv = long, negativ = short. Vor der
/// Optionserweiterung konnte sie nur positiv sein, weil Aktien-Shorts gesperrt sind und bis heute
/// gesperrt bleiben (Kapitalmodell §4 Schicht B). Ein verkaufter Optionskontrakt ist aber genau
/// das – eine negative Menge – und ohne Vorzeichen wäre eine Short-Position von einer Long-Position
/// nicht zu unterscheiden.</para>
/// </summary>
public sealed record Position(string Module, InstrumentRef Instrument, int Quantity, decimal AvgPrice)
{
/// <summary>Ticker der Aktie bzw. des Basiswerts.</summary>
public string Symbol => Instrument.Symbol;
public InstrumentKind Kind => Instrument.Kind;
/// <summary>true bei einer verkauften (geschriebenen) Position.</summary>
public bool IsShort => Quantity < 0;
/// <summary>
/// Marktwert der Position, <b>mit Kontraktgröße</b>. Genau hier lag der Fehler, den #298
/// behebt: ohne den Multiplikator ist der Wert einer Option um Faktor 100 zu klein, und jede
/// darauf gestützte Prüfung lässt alles durch. Immer positiv – die Richtung steht im Vorzeichen
/// von <see cref="Quantity"/>, nicht im Wert.
/// </summary>
public decimal Notional => Math.Abs(Quantity) * AvgPrice * Instrument.Multiplier;
/// <summary>
/// Kapital, das diese Position tatsächlich bindet – <b>nicht</b> ihr Marktwert.
///
/// <para>Bei einer verkauften Option ist die vereinnahmte Prämie ein Zufluss; gebunden wird die
/// <i>Verpflichtung</i>. Ein Short Put über Strike 150 bindet 15.000 USD, obwohl die Prämie
/// vielleicht 250 USD betrug. Würde man hier den Marktwert einsetzen, hielte das Kapitalmodell
/// eine Verpflichtung für ein Sechzigstel ihrer Größe.</para>
///
/// <para>Ein verkaufter Call bindet kein Geld, sondern Aktien – die stecken bereits als eigene
/// Position im Buch und werden über <c>CoverageCalculator</c> gesperrt, nicht hier doppelt
/// gezählt.</para>
/// </summary>
public decimal BoundCapital => Instrument.Option is { } opt && IsShort
? opt.Right == OptionRight.Put ? Math.Abs(Quantity) * opt.CashCoveragePerContract : 0m
: Notional;
}
/// <summary>
@@ -102,8 +191,8 @@ public sealed record BrokerPosition
public decimal MarketValue { get; init; }
public decimal UnrealizedPnl { get; init; }
public override string ToString() =>
$"{Symbol} ({SecType}) {Quantity:N0} @ {AvgCost:N2} {Currency}";
public override string ToString() => string.Create(CultureInfo.InvariantCulture,
$"{Symbol} ({SecType}) {Quantity:N0} @ {AvgCost:N2} {Currency}");
}
/// <summary>Einzelne Ausführung (Teil-Fill) laut Broker.</summary>
@@ -130,23 +219,52 @@ public sealed record BrokerExecution
public string? CommissionCurrency { get; init; }
public override string ToString() =>
$"{Time:yyyy-MM-dd HH:mm:ss} {Side} {Quantity:N0}x {Symbol} @ {Price:N2}";
string.Create(CultureInfo.InvariantCulture,
$"{Time:yyyy-MM-dd HH:mm:ss} {Side} {Quantity:N0}x {Symbol} @ {Price:N2}");
}
/// <summary>Kontext für die Risikobewertung eines Signals.</summary>
/// <summary>
/// Kontext für die Risikobewertung eines Signals. Alles, was der Core über Konto, Buch und
/// Deckung weiß – zusammengetragen vom <c>CapitalService</c>, bewertet vom <see cref="RiskService"/>.
///
/// <para>Die zusätzlichen Felder sind allesamt optional. Ein Aufruf, der nur
/// <see cref="Price"/> und <see cref="NetLiquidation"/> setzt, verhält sich wie vor der
/// Optionserweiterung – der Aktienpfad regressiert dadurch nicht.</para>
/// </summary>
public sealed record RiskContext
{
/// <summary>Aktueller Kurs des Symbols.</summary>
/// <summary>Aktueller Kurs je Stück bzw. je Kontrakt (nicht je Kontraktwert).</summary>
public required decimal Price { get; init; }
/// <summary>Netto-Liquidationswert des Kontos.</summary>
public required decimal NetLiquidation { get; init; }
/// <summary>Aktuell vom Modul gehaltener Nominalwert (Summe offener Positionen).</summary>
/// <summary>Bereits vom Buch gebundenes Kapital (Positionen und offene Reservierungen).</summary>
public decimal ModuleExposure { get; init; }
/// <summary>Bereits gehaltene Stückzahl für das Signal-Symbol.</summary>
/// <summary>
/// Bereits gehaltene Menge in genau diesem Instrument, <b>vorzeichenbehaftet</b>:
/// negativ bei einer verkauften Option.
/// </summary>
public int ExistingQuantity { get; init; }
/// <summary>Deckungslage des Buchs für den Basiswert (freie und gebundene Aktien).</summary>
public Capital.CoverageCalculator.Coverage Coverage { get; init; }
/// <summary>Bargeld, das für eine neue Bindung noch zur Verfügung steht.</summary>
public decimal AvailableCashForReservation { get; init; }
/// <summary>Was das Buch nach Cap und Pool-Bargeld noch einsetzen darf.</summary>
public decimal BookHeadroom { get; init; }
/// <summary>Bereits auf diesen Basiswert entfallendes Kapital über <b>alle</b> Bücher.</summary>
public decimal SymbolExposure { get; init; }
/// <summary>Obergrenze für die Konzentration auf diesen Basiswert. 0 = keine Prüfung.</summary>
public decimal SymbolLimit { get; init; }
/// <summary>true, wenn der Watchdog Käufe global gesperrt hat (§4 Schicht D).</summary>
public bool BuyingHalted { get; init; }
}
/// <summary>Aus den Settings abgeleitete Risiko-Parameter.</summary>
@@ -162,11 +280,27 @@ public sealed record RiskDecision
public int Quantity { get; init; }
public string Reason { get; init; } = "";
/// <summary>
/// Bei einer Ablehnung wegen einer Grenze: welche, wie weit, und was noch ginge. Das Gate ist
/// hart – der Core trimmt nicht selbst herunter –, aber ein Modul kann daraufhin seine Order
/// richtig dimensionieren, statt gegen dieselbe Wand zu laufen.
/// </summary>
public Capital.LimitBreach? Breach { get; init; }
/// <summary>
/// Menge, die diese Order von einer bestehenden Position <i>schließt</i>. Der Rest eröffnet.
/// Getrennt geführt, weil ein Schließen nie an einem Limit scheitern darf.
/// </summary>
public int ClosingQuantity { get; init; }
public static RiskDecision Reject(string reason) =>
new() { Approved = false, Quantity = 0, Reason = reason };
public static RiskDecision Approve(int quantity, string reason = "OK") =>
new() { Approved = true, Quantity = quantity, Reason = reason };
public static RiskDecision Reject(Capital.LimitBreach breach) =>
new() { Approved = false, Quantity = 0, Reason = breach.ToString(), Breach = breach };
public static RiskDecision Approve(int quantity, string reason = "OK", int closingQuantity = 0) =>
new() { Approved = true, Quantity = quantity, Reason = reason, ClosingQuantity = closingQuantity };
}
/// <summary>Ergebnis einer Signal-Ausführung durch den <see cref="IExecutionService"/>.</summary>
@@ -12,11 +12,15 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
/// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar).
/// 1. Ruft mariadb-dump bzw. mysqldump auf → SQL-Dump nach Backups/DB/
/// 2. Kopiert Logs/ → Backups/Logs/
/// Wird das Werkzeug nicht gefunden: Warnung und Überspringen, nie ein harter Fehler.
///
/// <para>Scheitert der Dump – oder fehlt das Werkzeug –, endet der Lauf als Fehler. Ein
/// stillschweigend übersprungener Dump sah in Workers-Ansicht und core_worker_log wie ein
/// erfolgreiches Backup aus; auffallen würde er erst, wenn man das Backup braucht.</para>
/// </summary>
public class BackupWorker : WorkerBase
{
private readonly SettingsService _settings;
private readonly SettingsService _settings;
private readonly DatabaseEndpoint _database;
public override string Name => "BackupWorker";
public override string Module => "Core";
@@ -25,16 +29,18 @@ public class BackupWorker : WorkerBase
protected override TimeSpan? Interval =>
TimeSpan.FromMinutes(_settings.Settings.WorkerSettings.BackupWorker.IntervalMinutes);
public BackupWorker(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
public BackupWorker(LoggingService logger, IDbContextFactory<CoreDbContext> db,
SettingsService settings, DatabaseEndpoint database)
: base(logger, db)
{
_settings = settings;
_settings = settings;
_database = database;
Info.Active = settings.Settings.WorkerSettings.BackupWorker.Enabled;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm");
var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm", CultureInfo.InvariantCulture);
var backupRoot = AppPaths.DataPath("Backups");
var dbDir = Path.Combine(backupRoot, "DB");
var logsDir = Path.Combine(backupRoot, "Logs");
@@ -55,14 +61,13 @@ public class BackupWorker : WorkerBase
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
{
var dump = FindDumpTool();
if (dump == null)
{
Logger.Warn(Module, $"{string.Join(" / ", ToolNames())} nicht gefunden – DB-Backup übersprungen.");
return;
}
var dump = MysqlDumpTool.Find()
?? throw new InvalidOperationException(
$"{string.Join(" / ", MysqlDumpTool.Names())} nicht gefunden – ohne Dump-Werkzeug gibt es kein DB-Backup.");
var db = _settings.Settings.Database;
// Die maßgebliche Verbindung, nicht die aus settings.json: sonst sichert der Worker eine
// andere Datenbank als die, mit der die Anwendung arbeitet.
var db = _database;
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
var psi = new System.Diagnostics.ProcessStartInfo
@@ -97,68 +102,23 @@ public class BackupWorker : WorkerBase
if (proc.ExitCode != 0)
{
var err = await proc.StandardError.ReadToEndAsync(ct);
Logger.Warn(Module, $"{Path.GetFileName(dump)} Exitcode {proc.ExitCode}: {err}");
}
else
{
Logger.Info(Module, $"DB-Dump erstellt: {Path.GetFileName(dumpFile)}");
var err = (await proc.StandardError.ReadToEndAsync(ct)).Trim();
// Eine unbrauchbare Datei zurücklassen wäre schlimmer als keine: im Backup-Verzeichnis
// sähe sie aus wie ein Dump.
TryDelete(dumpFile);
throw new InvalidOperationException(
$"{Path.GetFileName(dump)} Exitcode {proc.ExitCode} für {db}: {err}");
}
Logger.Info(Module, $"DB-Dump erstellt: {Path.GetFileName(dumpFile)} ({db})");
}
/// <summary>
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
/// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
/// </summary>
private static string[] ToolNames() =>
OperatingSystem.IsWindows()
? ["mariadb-dump.exe", "mysqldump.exe"]
: ["mariadb-dump", "mysqldump"];
/// <summary>
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
/// dann PATH. Findet es nichts, wird das Backup übersprungen (nie ein harter Fehler).
/// </summary>
private static string? FindDumpTool()
private static void TryDelete(string path)
{
var names = ToolNames();
// Übliche Installationspfade – plattformabhängig.
string[] dirs = OperatingSystem.IsWindows()
?
[
@"C:\Program Files\MariaDB\bin",
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
@"C:\xampp\mysql\bin",
]
:
[
"/usr/bin",
"/usr/local/bin",
"/opt/mariadb/bin",
"/opt/homebrew/bin",
];
foreach (var name in names)
foreach (var dir in dirs)
{
var full = Path.Combine(dir, name);
if (File.Exists(full)) return full;
}
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux –
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
foreach (var name in names)
{
var full = Path.Combine(dir.Trim(), name);
if (File.Exists(full)) return full;
}
return null;
try { if (File.Exists(path)) File.Delete(path); }
catch { /* best-effort – die Meldung über den Fehlschlag zählt, nicht die Aufräumarbeit */ }
}
// ─── Logs kopieren ────────────────────────────────────────────────────────
@@ -0,0 +1,64 @@
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// Findet das Dump-Werkzeug der Datenbank. Eigene Klasse, weil zwei Stellen sie brauchen: der
/// <see cref="BackupWorker"/> bei jedem Lauf und die Startprüfung, die ein fehlendes Werkzeug
/// einmal beim Hochfahren meldet statt es alle 30 Minuten stillschweigend zu übergehen.
/// </summary>
public static class MysqlDumpTool
{
/// <summary>
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
/// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
/// </summary>
public static string[] Names() =>
OperatingSystem.IsWindows()
? ["mariadb-dump.exe", "mysqldump.exe"]
: ["mariadb-dump", "mysqldump"];
/// <summary>
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
/// dann PATH. <c>null</c> = nicht gefunden.
/// </summary>
public static string? Find()
{
var names = Names();
// Übliche Installationspfade – plattformabhängig.
string[] dirs = OperatingSystem.IsWindows()
?
[
@"C:\Program Files\MariaDB\bin",
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
@"C:\xampp\mysql\bin",
]
:
[
"/usr/bin",
"/usr/local/bin",
"/opt/mariadb/bin",
"/opt/homebrew/bin",
];
foreach (var name in names)
foreach (var dir in dirs)
{
var full = Path.Combine(dir, name);
if (File.Exists(full)) return full;
}
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux –
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
foreach (var name in names)
{
var full = Path.Combine(dir.Trim(), name);
if (File.Exists(full)) return full;
}
return null;
}
}
@@ -0,0 +1,76 @@
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// Wacht über die Kapitalbindungen (Kapitalmodell §4 Schicht D und §5).
///
/// <para>Zwei Aufgaben, beide im selben Takt:</para>
/// <list type="number">
/// <item>Überfällige Reservierungen melden. Die Frist ist zwingend – eine Bindung, die ohne
/// Endzustand hängen bleibt, bedeutet, dass unser Bild vom Konto und das echte Konto
/// auseinanderlaufen. Sie wird gemeldet, nicht stillschweigend gelöst.</item>
/// <item>Den Bargeldstand gegen die Watchdog-Schwelle prüfen. Bei Unterschreitung gilt ein
/// globaler Kaufstopp – <b>und niemals ein automatischer Verkauf zur Heilung</b>. Eine Software,
/// die in einen fallenden Markt hinein Positionen schließt, weil eine Kennzahl unterschritten
/// ist, richtet mehr Schaden an als der Zustand, den sie beheben soll.</item>
/// </list>
/// </summary>
public class ReservationWatchdogWorker : WorkerBase
{
private readonly ReservationService _reservations;
private readonly IBrokerClient _broker;
private readonly Settings.SettingsService _settings;
public override string Name => "ReservationWatchdogWorker";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Worker;
protected override TimeSpan? Interval => TimeSpan.FromMinutes(5);
public ReservationWatchdogWorker(
LoggingService logger,
IDbContextFactory<CoreDbContext> db,
ReservationService reservations,
IBrokerClient broker,
Settings.SettingsService settings)
: base(logger, db)
{
_reservations = reservations;
_broker = broker;
_settings = settings;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var overdue = await _reservations.ExpireOverdueAsync(ct);
var account = await _broker.GetAccountStateAsync(ct);
var minCash = (decimal)_settings.Settings.Capital.WatchdogMinCashPercent;
// Bei fehlendem Kontowert meldet der Broker gerade nichts Brauchbares. Das ist kein Alarm
// ueber den Kontostand, sondern einer ueber die Verbindung - und die Kaufsperre greift
// ohnehin ueber dieselbe Pruefung im Risikopfad.
if (account.NetLiquidation <= 0m)
{
Logger.Warn(Module, "Kontostand nicht abrufbar – Bargeldschwelle diesmal nicht geprüft.");
return;
}
if (MarginGuard.BuyingShouldHalt(account, minCash))
{
var threshold = account.NetLiquidation * (minCash / 100m);
Logger.Error(Module,
$"Bargeld {account.TotalCashValue:F0} liegt unter der Schwelle {threshold:F0} " +
$"({minCash:F1} % von {account.NetLiquidation:F0}). Käufe sind gesperrt. " +
$"Es wird NICHT automatisch verkauft – das ist eine Entscheidung für einen Menschen.");
}
if (overdue.Count > 0)
Logger.Warn(Module, $"{overdue.Count} überfällige Reservierung(en) gemeldet.");
}
}
+69 -23
View File
@@ -86,6 +86,13 @@ public abstract class WorkerBase : IWorker, IHostedService
{
try { await _runLoop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
catch (Exception ex)
{
// Der Run-Loop fängt seine Fehler selbst ab; kommt hier doch einer an, darf er
// den Stopp nicht abbrechen. Sonst reißt ein einzelner Worker das geordnete
// Herunterfahren des gesamten Prozesses mit – Host.StopAsync wirft weiter.
Logger.Error(Module, $"Worker {Name}: Lauf endete mit Fehler: {ex.Message}", ex);
}
}
Info.Status = WorkerStatus.Stopped;
@@ -102,37 +109,70 @@ public abstract class WorkerBase : IWorker, IHostedService
// ─── Interner Run-Loop ────────────────────────────────────────────────────
/// <summary>
/// Obergrenze für eine einzelne Wartezeit. <c>SemaphoreSlim.WaitAsync(TimeSpan, …)</c> nimmt
/// höchstens int.MaxValue Millisekunden entgegen (rund 24,85 Tage) und wirft darüber hinaus.
/// Längere Intervalle – etwa der Jahresrhythmus des History-Imports – werden deshalb in
/// Scheiben abgewartet, bis der Zeitpunkt erreicht ist.
/// </summary>
private static readonly TimeSpan MaxWaitSlice = TimeSpan.FromHours(1);
private async Task RunLoopAsync(CancellationToken ct)
{
Logger.Info(Module, $"Worker gestartet: {Name}");
while (!ct.IsCancellationRequested)
try
{
await RunOnceAsync(ct);
if (Interval == null) break; // Service: nur einmal
// Anzeige-/Zeitplanwerte in der Betriebszeitzone, damit die UI dasselbe zeigt wie die Logs.
var next = AppTimeZone.Now.Add(Interval.Value);
Info.NextRuntime = next;
// Warte auf Interval ODER manuellen Trigger
var remaining = next - AppTimeZone.Now;
if (remaining > TimeSpan.Zero)
while (!ct.IsCancellationRequested)
{
try
{
await _triggerSemaphore
.WaitAsync(remaining, ct)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { break; }
await RunOnceAsync(ct);
if (Interval == null) break; // Service: nur einmal
// Anzeige-/Zeitplanwerte in der Betriebszeitzone, damit die UI dasselbe zeigt wie die Logs.
var next = AppTimeZone.Now.Add(Interval.Value);
Info.NextRuntime = next;
if (!await WaitForNextRunAsync(next, ct).ConfigureAwait(false)) break;
}
}
catch (OperationCanceledException) { /* erwartet */ }
catch (Exception ex)
{
// Alles, was außerhalb von RunOnceAsync schiefgeht, beendete den Worker bisher stumm:
// der Task faultete, geloggt wurde nichts, und beim Herunterfahren kam die Ausnahme
// erneut hoch und riss den Prozess mit. Hier bleibt sie stehen und wird sichtbar.
Logger.Error(Module, $"Worker {Name} abgebrochen: {ex.Message}", ex);
Info.Status = WorkerStatus.Error;
Info.Info = $"Abgebrochen: {ex.Message}";
}
Logger.Info(Module, $"Worker beendet: {Name}");
}
/// <summary>
/// Wartet, bis <paramref name="next"/> erreicht ist oder ein manueller Trigger kommt.
/// Rückgabe false = Abbruch angefordert, der Lauf-Loop endet.
/// </summary>
private async Task<bool> WaitForNextRunAsync(DateTime next, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var remaining = next - AppTimeZone.Now;
if (remaining <= TimeSpan.Zero) return true;
var slice = remaining > MaxWaitSlice ? MaxWaitSlice : remaining;
try
{
if (await _triggerSemaphore.WaitAsync(slice, ct).ConfigureAwait(false))
return true; // manuell ausgelöst
}
catch (OperationCanceledException) { return false; }
}
return false;
}
private async Task RunOnceAsync(CancellationToken ct)
{
Info.Status = WorkerStatus.Running;
@@ -153,16 +193,22 @@ public abstract class WorkerBase : IWorker, IHostedService
{
if (logId > 0)
await EndRunLogAsync(logId, false, "Abgebrochen");
Info.Status = WorkerStatus.Stopped;
Info.Info = "Abgebrochen";
Info.LastRuntime = AppTimeZone.Now;
Info.Status = WorkerStatus.Stopped;
Info.Info = "Abgebrochen";
}
catch (Exception ex)
{
if (logId > 0)
await EndRunLogAsync(logId, false, ex.Message);
Logger.Error(Module, $"Fehler in Worker {Name}: {ex.Message}", ex);
Info.Status = WorkerStatus.Error;
Info.Info = $"Fehler: {ex.Message}";
// Auch ein gescheiterter Lauf ist ein Lauf: bliebe "Letzter Lauf" leer, wäre ein
// Worker, der seit Tagen bei jedem Versuch scheitert, in der Tabelle nicht von
// einem zu unterscheiden, der noch nie gelaufen ist.
Info.LastRuntime = AppTimeZone.Now;
Info.Status = WorkerStatus.Error;
Info.Info = $"Fehler: {ex.Message}";
}
}