R10: Lesender Bestandsabgleich (IBrokerPortfolioReader) + Datenlage-Konzepte

Eigener Seam neben IBrokerClient: Wer handelt, braucht ihn nicht; wer die
eigene Buchfuehrung gegen den Broker abstimmt, braucht nur ihn. Zuteilung
und Verfall aendern Positionen ohne Order von uns - ohne Abgleich laeuft
das Managementbuch zwangslaeufig auseinander.

- IBrokerPortfolioReader mit GetPositionsAsync/GetExecutionsAsync;
  implementiert von IbkrBrokerClient und NullBrokerClient (DI registriert
  beide Rollen auf derselben Instanz).
- IbkrConnection: reqAccountUpdates statt reqPositions (nur dieser Weg
  liefert Marktwert und unrealisierten G/V), reqExecutions inkl. Zuordnung
  der verspaetet eintreffenden commissionReport-Callbacks ueber die ExecId.
- BrokerPosition/BrokerExecution als Broker-Wahrheit neben Position;
  IbkrMapping: ParseSide, ParseExecutionTime, FormatExecutionFilterTime
  (UTC wegen TWS-Warnung 2174) - mit Unit-Tests.
- Verifiziert gegen Paper-Konto DUR371528: 2 Positionen, 2 Ausfuehrungen
  inkl. Kommissionen.

Doku: Kapital- und Buchmodell (drei Wahrheiten, Kapitalzuteilung),
KONZEPT-Datenlage-und-Strategien (gemessen, was die API auf diesem Konto
liefert). Options-Wheel: Greeks bei verzoegerten Daten funktionieren
(Feld 83); Earnings-Termine sind ueber die TWS API nicht erreichbar
(Fehler 10358) - Behelf ueber IV-Filter statt Fremddatenquelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 10:51:51 +02:00
co-authored by Claude Opus 5
parent 80afcd49c1
commit b96a20788c
13 changed files with 1039 additions and 20 deletions
@@ -32,8 +32,12 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private readonly ConcurrentDictionary<int, QuoteSlot> _quotes = new();
private readonly ConcurrentDictionary<int, AccountSlot> _accounts = new();
private readonly ConcurrentDictionary<int, ContractSlot> _contracts = new();
private readonly ConcurrentDictionary<int, OrderSlot> _orders = new();
private readonly ConcurrentDictionary<string, Contract> _contractCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<int, OrderSlot> _orders = new();
private readonly ConcurrentDictionary<int, ExecutionSlot> _executions = new();
private readonly ConcurrentDictionary<string, Contract> _contractCache = new(StringComparer.OrdinalIgnoreCase);
// Portfolio-Abruf ist ein kontoweites Abonnement, keine reqId-Anfrage daher nur ein Slot.
private PortfolioSlot? _portfolio;
private TaskCompletionSource<bool> _handshake = NewTcs();
private volatile bool _ready;
@@ -256,6 +260,85 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
}
}
/// <summary>
/// Bestand samt Bewertung. Nutzt <c>reqAccountUpdates</c> statt <c>reqPositions</c>, weil nur
/// dieser Weg Marktwert und unrealisierten G/V mitliefert. Es ist ein Abonnement wir melden
/// uns nach dem ersten vollständigen Stand wieder ab.
/// </summary>
public async Task<IReadOnlyList<BrokerPosition>> RequestPositionsAsync(TimeSpan timeout, CancellationToken ct)
{
var account = _account;
if (string.IsNullOrWhiteSpace(account))
{
_logger.Warn(LogModule, "Kontonummer unbekannt Positionen nicht abrufbar.");
return Array.Empty<BrokerPosition>();
}
var slot = new PortfolioSlot();
if (Interlocked.CompareExchange(ref _portfolio, slot, null) is not null)
{
_logger.Warn(LogModule, "Es läuft bereits eine Positionsabfrage.");
return Array.Empty<BrokerPosition>();
}
try
{
_socket.reqAccountUpdates(true, account);
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, "Zeitüberschreitung bei der Positionsabfrage.");
return Array.Empty<BrokerPosition>();
}
return slot.Positions;
}
finally
{
try { _socket.reqAccountUpdates(false, account); }
catch { /* Verbindung bereits weg. */ }
Interlocked.Exchange(ref _portfolio, null);
}
}
/// <summary>
/// Ausführungen. Kommissionen kommen über einen eigenen Callback und treffen oft erst nach
/// <c>execDetailsEnd</c> ein deshalb die kurze Nachlauffrist, bevor zusammengeführt wird.
/// </summary>
public async Task<IReadOnlyList<BrokerExecution>> RequestExecutionsAsync(DateTime? since,
TimeSpan timeout, CancellationToken ct)
{
var id = NextRequestId();
var slot = new ExecutionSlot();
_executions[id] = slot;
try
{
var filter = new ExecutionFilter();
if (since is { } from) filter.Time = IbkrMapping.FormatExecutionFilterTime(from);
_socket.reqExecutions(id, filter);
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, "Zeitüberschreitung bei der Abfrage der Ausführungen.");
return Array.Empty<BrokerExecution>();
}
await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
return slot.Items
.Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c)
? e with { Commission = c.Amount, CommissionCurrency = c.Currency }
: e)
.OrderByDescending(e => e.Time)
.ToList();
}
finally
{
_executions.TryRemove(id, out _);
}
}
public async Task<OrderResult> PlaceOrderAsync(OrderRequest request, Contract contract,
TimeSpan timeout, CancellationToken ct)
{
@@ -346,6 +429,60 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
if (_accounts.TryGetValue(reqId, out var slot)) slot.Complete();
}
public override void updatePortfolio(Contract contract, double position, double marketPrice,
double marketValue, double averageCost, double unrealizedPNL, double realizedPNL, string accountName)
{
// Glattgestellte Positionen meldet TWS mit Menge 0 weiter die gehören nicht in den Bestand.
if (_portfolio is not { } slot || position == 0) return;
slot.Positions.Add(new BrokerPosition
{
Symbol = contract.Symbol,
SecType = contract.SecType,
Currency = contract.Currency,
ConId = contract.ConId,
Quantity = (decimal)position,
AvgCost = (decimal)averageCost,
MarketPrice = (decimal)marketPrice,
MarketValue = (decimal)marketValue,
UnrealizedPnl = (decimal)unrealizedPNL
});
}
public override void accountDownloadEnd(string account) => _portfolio?.Complete();
public override void execDetails(int reqId, Contract contract, Execution execution)
{
if (!_executions.TryGetValue(reqId, out var slot)) return;
slot.Items.Add(new BrokerExecution
{
ExecId = execution.ExecId,
Time = IbkrMapping.ParseExecutionTime(execution.Time) ?? DateTime.MinValue,
Symbol = contract.Symbol,
SecType = contract.SecType,
Side = IbkrMapping.ParseSide(execution.Side),
Quantity = (decimal)execution.Shares,
Price = (decimal)execution.Price,
Exchange = execution.Exchange ?? "",
OrderId = execution.OrderId
});
}
public override void execDetailsEnd(int reqId)
{
if (_executions.TryGetValue(reqId, out var slot)) slot.Complete();
}
public override void commissionReport(CommissionReport report)
{
// Der Callback trägt keine reqId; die ExecId ordnet ihn der Ausführung zu.
if (report.Commission is <= 0 or >= 1e100) return;
foreach (var slot in _executions.Values)
slot.Commissions[report.ExecId] = ((decimal)report.Commission, report.Currency ?? "");
}
public override void orderStatus(int orderId, string status, double filled, double remaining,
double avgFillPrice, int permId, int parentId, double lastFillPrice, int clientId,
string whyHeld, double mktCapPrice)
@@ -407,19 +544,22 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private bool FailPending(int id, string error)
{
if (_quotes .TryGetValue(id, out var q)) { q.Fail(error); return true; }
if (_accounts .TryGetValue(id, out var a)) { a.Fail(error); return true; }
if (_contracts.TryGetValue(id, out var c)) { c.Fail(error); return true; }
if (_orders .TryGetValue(id, out var o)) { o.Fail(error); return true; }
if (_quotes .TryGetValue(id, out var q)) { q.Fail(error); return true; }
if (_accounts .TryGetValue(id, out var a)) { a.Fail(error); return true; }
if (_contracts .TryGetValue(id, out var c)) { c.Fail(error); return true; }
if (_orders .TryGetValue(id, out var o)) { o.Fail(error); return true; }
if (_executions.TryGetValue(id, out var e)) { e.Fail(error); return true; }
return false;
}
private void FailAllPending(string error)
{
foreach (var slot in _quotes.Values) slot.Fail(error);
foreach (var slot in _accounts.Values) slot.Fail(error);
foreach (var slot in _contracts.Values) slot.Fail(error);
foreach (var slot in _orders.Values) slot.Fail(error);
foreach (var slot in _quotes.Values) slot.Fail(error);
foreach (var slot in _accounts.Values) slot.Fail(error);
foreach (var slot in _contracts.Values) slot.Fail(error);
foreach (var slot in _orders.Values) slot.Fail(error);
foreach (var slot in _executions.Values) slot.Fail(error);
_portfolio?.Fail(error);
}
private static decimal ReadDecimal(IReadOnlyDictionary<string, string> values, string tag) =>
@@ -486,4 +626,15 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
public int Filled;
public double AvgFillPrice;
}
private sealed class PortfolioSlot : Slot
{
public readonly List<BrokerPosition> Positions = new();
}
private sealed class ExecutionSlot : Slot
{
public readonly List<BrokerExecution> Items = new();
public readonly ConcurrentDictionary<string, (decimal Amount, string Currency)> Commissions = new();
}
}