Bestandsabgleich gegen den Broker: Zuteilung, Verfall, Fremdbestand
Zuteilung und Verfall aendern Positionen ohne eine Order von uns - es gibt kein Ereignis, an das sich ein Abgleich haengen koennte. Nach einem Verfallstag fuehren wir Kontrakte, die es nicht mehr gibt; nach einer Zuteilung liegen Aktien im Konto, die in keinem Buch stehen. Beides verschiebt still die Deckungslage: Ein Covered Call, dessen Aktien in Wahrheit abgerufen wurden, ist ab dann ein nackter Call. PositionReconciler ist reine Rechnung und deutet die Differenz, statt nur Mengen anzugleichen. Verschwindet ein geschriebener Put und tauchen zugleich Aktien des Basiswerts auf, gehoeren diese Aktien dem Buch, das den Put geschrieben hat - landeten sie in UNASSIGNED, verloere ein Wheel-Modul genau den Bestand, aus dem es den naechsten Call schreiben muss. Der Einstand ist dabei der Strike, denn zu genau dem Preis wurde geliefert. Passt die Aktienbewegung nicht zur Groesse der Option, gilt sie NICHT als Zuteilung. Verfall wird nur angenommen, wenn der Verfallstag erreicht ist. Ein Kontrakt, der vorher verschwindet, ist eine Abweichung und wird als solche gemeldet - von Hand geschlossen ist kein Verfall. Die gefaehrlichste Stelle ist die leere Antwort: Die Leseschnittstelle liefert bei gestoerter Verbindung eine leere Liste, die von einem leeren Konto nicht zu unterscheiden ist. Solange wir selbst Positionen fuehren, gilt sie deshalb als Stoerung. Ohne diese Sperre setzte ein Verbindungsabbruch jede Position auf null. Gehandelt wird nie. Der Abgleich bucht ausschliesslich nach. BrokerPosition traegt jetzt das Instrument (IbkrMapping.ToInstrument); ein Kontrakt, den unser Modell nicht kennt - Future, Devise, Fonds -, liefert null und wird uebersprungen statt als Aktie gedeutet. Gemessen auf dem Testserver gegen das Paper-Konto: vier Altlasten erkannt (GOOG, MIGA:EUR, die SAP-ADR aus dem Fehllauf und zwei ungebuchte SAP:EUR), alle an UNASSIGNED gebucht, das Buch DIAG unangetastet. Gegenprobe danach: "Bestand stimmt ueberein (5 Positionen)". 402 Tests gruen. Erledigt den offenen Teil von #300, Kern von #306. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
using IBKRTrader.Core.Capital;
|
||||
using IBKRTrader.Core.Trading;
|
||||
|
||||
namespace IBKRTrader.Core.Reconciliation;
|
||||
|
||||
/// <summary>Was der Abgleich an einer Position festgestellt hat.</summary>
|
||||
public enum ReconciliationKind
|
||||
{
|
||||
/// <summary>Ein geschriebener Kontrakt wurde zugeteilt bzw. ein gekaufter ausgeübt.</summary>
|
||||
Assignment,
|
||||
|
||||
/// <summary>Ein Kontrakt ist wertlos verfallen.</summary>
|
||||
Expiry,
|
||||
|
||||
/// <summary>Der Broker führt mehr, als wir gebucht haben – Herkunft unbekannt.</summary>
|
||||
Excess,
|
||||
|
||||
/// <summary>Wir führen mehr, als der Broker hat. Unsere Buchung ist zu hoch.</summary>
|
||||
Shortfall,
|
||||
|
||||
/// <summary>Ein Instrument, das wir überhaupt nicht führen.</summary>
|
||||
Foreign
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine festgestellte Abweichung samt der Buchung, die sie ausgleicht.
|
||||
/// </summary>
|
||||
/// <param name="Book">Buch, dessen Bestand zu ändern ist.</param>
|
||||
/// <param name="Instrument">Betroffenes Instrument.</param>
|
||||
/// <param name="NewQuantity">Menge, die danach im Buch steht – vorzeichenbehaftet, 0 = geschlossen.</param>
|
||||
/// <param name="AvgPrice">Einstand, der dabei anzusetzen ist.</param>
|
||||
/// <param name="Explanation">Begründung, so wie sie ins Log gehört.</param>
|
||||
public sealed record ReconciliationAction(
|
||||
ReconciliationKind Kind,
|
||||
string Book,
|
||||
InstrumentRef Instrument,
|
||||
int NewQuantity,
|
||||
decimal AvgPrice,
|
||||
string Explanation);
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht die eigene Buchführung mit dem Bestand des Brokers und leitet daraus die nötigen
|
||||
/// Korrekturen ab. <b>Reine Rechnung</b> – kein Broker, keine Datenbank, kein Zeitgeber.
|
||||
///
|
||||
/// <para><b>Warum es das braucht.</b> Zuteilung und Verfall ändern Positionen ohne eine Order von
|
||||
/// uns. Nach einer Zuteilung besitzt das Konto 100 Aktien, die in keiner unserer Zeilen stehen;
|
||||
/// nach einem Verfall führen wir einen Kontrakt, den es nicht mehr gibt. Beides verschiebt still
|
||||
/// die Deckungslage: Ein Covered Call, dessen Aktien in Wahrheit fehlen, ist ein nackter Call.</para>
|
||||
///
|
||||
/// <para><b>Der Broker ist die Wahrheit.</b> Bei jeder Abweichung gewinnt sein Bestand. Wir
|
||||
/// buchen nach – gehandelt wird hier nichts, unter keinen Umständen.</para>
|
||||
///
|
||||
/// <para><b>Zuteilungen werden dem richtigen Buch zugeordnet.</b> Das ist der Grund, warum dieser
|
||||
/// Vergleich mehr ist als „Mengen angleichen": Verschwindet ein geschriebener Put und tauchen
|
||||
/// zugleich Aktien des Basiswerts auf, gehören diese Aktien dem Buch, das den Put geschrieben hat.
|
||||
/// Landeten sie stattdessen in UNASSIGNED, verlöre ein Wheel-Modul genau den Bestand, aus dem es
|
||||
/// den nächsten Call schreiben muss.</para>
|
||||
/// </summary>
|
||||
public static class PositionReconciler
|
||||
{
|
||||
/// <summary>
|
||||
/// Vergleicht und liefert die nötigen Buchungen. Eine leere Liste heißt: alles stimmt überein.
|
||||
/// </summary>
|
||||
/// <param name="own">Eigene Positionen, je Buch und Instrument.</param>
|
||||
/// <param name="broker">Bestand laut Broker – ohne Bücher, er kennt keine.</param>
|
||||
/// <param name="today">Heutiger Tag in der Betriebszeitzone; entscheidet über „verfallen".</param>
|
||||
/// <param name="unassignedBook">Buch für Bestände ohne erkennbare Herkunft.</param>
|
||||
public static IReadOnlyList<ReconciliationAction> Compare(
|
||||
IReadOnlyList<Position> own,
|
||||
IReadOnlyList<BrokerPosition> broker,
|
||||
DateOnly today,
|
||||
string? unassignedBook = null)
|
||||
{
|
||||
var unassigned = unassignedBook ?? BookId.Unassigned.Value;
|
||||
var actions = new List<ReconciliationAction>();
|
||||
|
||||
// Der Broker führt je Instrument eine Menge; unsere Zeilen können dasselbe Instrument in
|
||||
// mehreren Büchern halten.
|
||||
var brokerByKey = broker
|
||||
.Where(p => p.Instrument is not null && p.Quantity != 0)
|
||||
.GroupBy(p => p.Instrument!.Value.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => new BrokerLot(
|
||||
g.First().Instrument!.Value,
|
||||
(int)g.Sum(p => p.Quantity),
|
||||
g.First().AvgCost), StringComparer.Ordinal);
|
||||
|
||||
var ownByKey = own
|
||||
.Where(p => p.Quantity != 0)
|
||||
.GroupBy(p => p.Instrument.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
|
||||
|
||||
// Was an Aktienmenge je Basiswert noch unerklärt ist. Zuteilungen zehren davon; was übrig
|
||||
// bleibt, ist ein echter Über- oder Fehlbestand.
|
||||
var openDelta = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var key in ownByKey.Keys.Union(brokerByKey.Keys, StringComparer.Ordinal))
|
||||
{
|
||||
var ownQty = ownByKey.TryGetValue(key, out var rows) ? rows.Sum(r => r.Quantity) : 0;
|
||||
var brokerQty = brokerByKey.TryGetValue(key, out var lot) ? lot.Quantity : 0;
|
||||
if (ownQty != brokerQty) openDelta[key] = brokerQty - ownQty;
|
||||
}
|
||||
|
||||
// ── 1. Verschwundene Kontrakte deuten: Zuteilung oder Verfall ────────────
|
||||
foreach (var (key, rows) in ownByKey)
|
||||
{
|
||||
if (brokerByKey.ContainsKey(key)) continue; // steht noch, kein Sonderfall
|
||||
var instrument = rows[0].Instrument;
|
||||
if (instrument.Option is not { } option) continue; // Aktien: unten als Fehlbestand
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var contracts = Math.Abs(row.Quantity);
|
||||
var expected = ExpectedShareChange(row.Quantity, option);
|
||||
var underlying = instrument.UnderlyingRef;
|
||||
|
||||
if (expected != 0 && Consume(openDelta, underlying.Key, expected))
|
||||
{
|
||||
// Die Aktienbewegung passt zur verschwundenen Option: zugeteilt bzw. ausgeübt.
|
||||
// Der Einstand ist der Strike – zu genau diesem Preis wurde geliefert.
|
||||
var ownShares = ownByKey.TryGetValue(underlying.Key, out var shareRows)
|
||||
? shareRows.Where(r => r.Module == row.Module).Sum(r => r.Quantity)
|
||||
: 0;
|
||||
|
||||
actions.Add(new ReconciliationAction(
|
||||
ReconciliationKind.Assignment, row.Module, underlying,
|
||||
ownShares + expected, option.Strike,
|
||||
$"{contracts} {option.RightCode}-Kontrakt(e) {instrument.Key} " +
|
||||
$"{(row.Quantity < 0 ? "zugeteilt" : "ausgeübt")}: " +
|
||||
$"{expected:+#;-#;0} Aktien {underlying.Key} zu {option.Strike} " +
|
||||
$"gehen an {row.Module}."));
|
||||
|
||||
actions.Add(new ReconciliationAction(
|
||||
ReconciliationKind.Assignment, row.Module, instrument, 0, row.AvgPrice,
|
||||
$"Kontrakt {instrument.Key} ist durch Zuteilung erloschen."));
|
||||
}
|
||||
else if (option.Expiry <= today)
|
||||
{
|
||||
actions.Add(new ReconciliationAction(
|
||||
ReconciliationKind.Expiry, row.Module, instrument, 0, row.AvgPrice,
|
||||
$"{contracts} Kontrakt(e) {instrument.Key} sind am {option.Expiry:yyyy-MM-dd} " +
|
||||
"wertlos verfallen."));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Weder Zuteilung noch Verfall: der Kontrakt ist vor dem Verfallstag weg.
|
||||
// Das ist meldenswert – etwa ein Schließen von Hand in TWS.
|
||||
actions.Add(new ReconciliationAction(
|
||||
ReconciliationKind.Shortfall, row.Module, instrument, 0, row.AvgPrice,
|
||||
$"{contracts} Kontrakt(e) {instrument.Key} führt der Broker nicht mehr, " +
|
||||
$"obwohl der Verfall erst am {option.Expiry:yyyy-MM-dd} ist. " +
|
||||
"Von Hand geschlossen?"));
|
||||
}
|
||||
|
||||
openDelta.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Was danach an Abweichung bleibt ───────────────────────────────────
|
||||
foreach (var (key, delta) in openDelta.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (delta == 0) continue;
|
||||
|
||||
var hasOwn = ownByKey.TryGetValue(key, out var rows) && rows.Count > 0;
|
||||
var lot = brokerByKey.TryGetValue(key, out var l) ? l : null;
|
||||
|
||||
if (!hasOwn)
|
||||
{
|
||||
// Nur beim Broker: ein Bestand, den niemand von uns aufgebaut hat.
|
||||
actions.Add(new ReconciliationAction(
|
||||
ReconciliationKind.Foreign, unassigned, lot!.Instrument, lot.Quantity, lot.AvgCost,
|
||||
$"{lot.Quantity} {lot.Instrument.Key} beim Broker, bei uns nicht gebucht – " +
|
||||
$"wird {unassigned} zugeschrieben."));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wir führen das Instrument bereits. Die Differenz kann nicht sinnvoll auf mehrere
|
||||
// Bücher verteilt werden – wer sie verursacht hat, weiß nur der Broker, und der kennt
|
||||
// unsere Bücher nicht. Sie geht deshalb ungeteilt an UNASSIGNED bzw. wird dort
|
||||
// abgebaut; die Bücher der Module bleiben unangetastet.
|
||||
var instrument = rows![0].Instrument;
|
||||
var ownInUnassigned = rows.Where(r => r.Module == unassigned).Sum(r => r.Quantity);
|
||||
|
||||
actions.Add(new ReconciliationAction(
|
||||
delta > 0 ? ReconciliationKind.Excess : ReconciliationKind.Shortfall,
|
||||
unassigned, instrument, ownInUnassigned + delta,
|
||||
lot?.AvgCost ?? rows[0].AvgPrice,
|
||||
delta > 0
|
||||
? $"Broker führt {delta} mehr {instrument.Key} als wir – Überschuss geht an {unassigned}."
|
||||
: $"Broker führt {-delta} weniger {instrument.Key} als wir – Fehlbestand wird " +
|
||||
$"bei {unassigned} abgezogen."));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wie sich der Aktienbestand ändert, wenn dieser Kontrakt zugeteilt bzw. ausgeübt wird.
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Geschriebener Put: wir müssen kaufen → Bestand steigt.</item>
|
||||
/// <item>Geschriebener Call: wir müssen liefern → Bestand fällt.</item>
|
||||
/// <item>Gekaufter Call: Bezug → Bestand steigt. Gekaufter Put: Andienung → Bestand fällt.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static int ExpectedShareChange(int contracts, OptionSpec option)
|
||||
{
|
||||
var shares = Math.Abs(contracts) * option.Multiplier;
|
||||
|
||||
return (option.Right, IsShort: contracts < 0) switch
|
||||
{
|
||||
(OptionRight.Put, true) => shares, // zugeteilt bekommen
|
||||
(OptionRight.Call, true) => -shares, // abgerufen worden
|
||||
(OptionRight.Call, false) => shares, // selbst bezogen
|
||||
(OptionRight.Put, false) => -shares, // selbst angedient
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verrechnet eine erwartete Aktienbewegung mit dem noch unerklärten Delta des Basiswerts.
|
||||
/// Nur wenn das Delta in dieselbe Richtung zeigt und groß genug ist, gilt die Bewegung als
|
||||
/// erklärt – sonst wäre jede zufällige Änderung am Basiswert eine „Zuteilung".
|
||||
/// </summary>
|
||||
private static bool Consume(Dictionary<string, int> openDelta, string underlyingKey, int expected)
|
||||
{
|
||||
if (!openDelta.TryGetValue(underlyingKey, out var delta)) return false;
|
||||
if (Math.Sign(delta) != Math.Sign(expected)) return false;
|
||||
if (Math.Abs(delta) < Math.Abs(expected)) return false;
|
||||
|
||||
var rest = delta - expected;
|
||||
if (rest == 0) openDelta.Remove(underlyingKey);
|
||||
else openDelta[underlyingKey] = rest;
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record BrokerLot(InstrumentRef Instrument, int Quantity, decimal AvgCost);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Trading;
|
||||
|
||||
namespace IBKRTrader.Core.Reconciliation;
|
||||
|
||||
/// <summary>Ergebnis eines Abgleichs.</summary>
|
||||
/// <param name="Skipped">
|
||||
/// Der Abgleich wurde ausgesetzt – der Broker meldete nichts, während wir Positionen führen.
|
||||
/// </param>
|
||||
public sealed record ReconciliationResult(
|
||||
IReadOnlyList<ReconciliationAction> Actions,
|
||||
int OwnPositions,
|
||||
int BrokerPositions,
|
||||
bool Applied,
|
||||
bool Skipped = false)
|
||||
{
|
||||
public int Assignments => Actions.Count(a => a.Kind == ReconciliationKind.Assignment);
|
||||
public int Expiries => Actions.Count(a => a.Kind == ReconciliationKind.Expiry);
|
||||
public int Differences => Actions.Count - Assignments - Expiries;
|
||||
|
||||
public string Summary => Skipped
|
||||
? "Ausgesetzt – der Broker meldete keine Positionen."
|
||||
: Actions.Count == 0
|
||||
? $"Bestand stimmt überein ({OwnPositions} Position(en))."
|
||||
: $"{Actions.Count} Korrektur(en): {Assignments} aus Zuteilung/Ausübung, " +
|
||||
$"{Expiries} durch Verfall, {Differences} sonstige Abweichung(en).";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt den Bestandsabgleich aus: Zahlen holen, <see cref="PositionReconciler"/> entscheiden
|
||||
/// lassen, Ergebnis buchen.
|
||||
///
|
||||
/// <para>Bewusst getrennt vom Worker: derselbe Ablauf wird zweimal gebraucht – periodisch im
|
||||
/// Betrieb und einmalig über die Kommandozeile, dort auch als Trockenlauf. Ein Abgleich, den man
|
||||
/// nicht erst ansehen kann, bevor er bucht, wäre im Störungsfall unbrauchbar.</para>
|
||||
/// </summary>
|
||||
public sealed class ReconciliationService
|
||||
{
|
||||
private const string LogModule = "Core";
|
||||
|
||||
private readonly IPortfolioService _portfolio;
|
||||
private readonly IBrokerPortfolioReader _reader;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public ReconciliationService(
|
||||
IPortfolioService portfolio, IBrokerPortfolioReader reader, LoggingService logger)
|
||||
{
|
||||
_portfolio = portfolio;
|
||||
_reader = reader;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <param name="apply">
|
||||
/// <c>true</c> bucht die Korrekturen, <c>false</c> stellt sie nur fest. Gehandelt wird in
|
||||
/// keinem der beiden Fälle.
|
||||
/// </param>
|
||||
public async Task<ReconciliationResult> RunAsync(bool apply, CancellationToken ct = default)
|
||||
{
|
||||
var own = await _portfolio.GetAllPositionsAsync(ct).ConfigureAwait(false);
|
||||
var broker = await _reader.GetPositionsAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Die Leseschnittstelle liefert bei gestörter Verbindung eine leere Liste – von einem
|
||||
// tatsächlich leeren Konto ist sie nicht zu unterscheiden. Diesen Fall nicht zu behandeln
|
||||
// wäre der gefährlichste Fehler des ganzen Abgleichs: ein Verbindungsabbruch setzte jede
|
||||
// Position auf null und löste damit die gesamte Deckungslage auf. Solange wir selbst
|
||||
// etwas führen, gilt eine leere Antwort deshalb als Störung, nicht als Wahrheit.
|
||||
if (broker.Count == 0 && own.Count > 0)
|
||||
{
|
||||
_logger.Warn(LogModule,
|
||||
$"Broker meldet keine Positionen, wir führen {own.Count}. Abgleich ausgesetzt – " +
|
||||
"eine leere Antwort ist von einer gestörten Verbindung nicht zu unterscheiden.");
|
||||
return new ReconciliationResult([], own.Count, 0, Applied: false, Skipped: true);
|
||||
}
|
||||
|
||||
var actions = PositionReconciler.Compare(
|
||||
own, broker, DateOnly.FromDateTime(AppTimeZone.Now));
|
||||
|
||||
if (apply)
|
||||
{
|
||||
foreach (var action in actions)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await _portfolio.ApplyAdjustmentAsync(
|
||||
action.Book, action.Instrument, action.NewQuantity, action.AvgPrice,
|
||||
action.Explanation, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new ReconciliationResult(actions, own.Count, broker.Count, apply);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user