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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user