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:
@@ -0,0 +1,23 @@
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Lesender Zugriff auf Bestand und Ausführungen beim Broker.
|
||||
///
|
||||
/// Bewusst getrennt von <see cref="IBrokerClient"/>: dort geht es um das Auslösen von Handel,
|
||||
/// hier um den Abgleich der eigenen Buchführung mit dem, was der Broker tatsächlich führt.
|
||||
/// Wer nur handelt, braucht das nicht; wer abstimmt, braucht nur das.
|
||||
/// </summary>
|
||||
public interface IBrokerPortfolioReader
|
||||
{
|
||||
/// <summary>Alle offenen Positionen des Kontos, inklusive Bewertung. Leer, wenn nicht erreichbar.</summary>
|
||||
Task<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ausführungen ab <paramref name="since"/> (null = alles, was der Broker liefert).
|
||||
///
|
||||
/// <b>Grenze der TWS-API:</b> sie liefert nur den **aktuellen Handelstag**, unabhängig vom
|
||||
/// gewünschten Zeitraum. Für ältere Trades bleibt der Flex-Query-Ingest des Accounting-Moduls
|
||||
/// die richtige Quelle.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace IBKRTrader.Core.Trading.Ibkr;
|
||||
/// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen –
|
||||
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
|
||||
/// </summary>
|
||||
public sealed class IbkrBrokerClient : IBrokerClient, IDisposable
|
||||
public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IDisposable
|
||||
{
|
||||
private const string LogModule = "IBKR";
|
||||
|
||||
@@ -91,6 +91,21 @@ public sealed class IbkrBrokerClient : IBrokerClient, IDisposable
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerPosition>();
|
||||
|
||||
return await _connection!.RequestPositionsAsync(_requestTimeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerExecution>();
|
||||
|
||||
return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AccountState Empty => new(0m, 0m);
|
||||
|
||||
private async Task<bool> IsReadyAsync(CancellationToken ct)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using IBApi;
|
||||
|
||||
namespace IBKRTrader.Core.Trading.Ibkr;
|
||||
@@ -88,6 +89,35 @@ internal static class IbkrMapping
|
||||
(decimal)(ask > 0 ? ask : price));
|
||||
}
|
||||
|
||||
/// <summary>Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft.</summary>
|
||||
public static TradeSide ParseSide(string side) =>
|
||||
side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy;
|
||||
|
||||
/// <summary>
|
||||
/// Zeitstempel einer Ausführung. TWS liefert je nach Aufruf "yyyyMMdd HH:mm:ss" (mit doppeltem
|
||||
/// Leerzeichen) oder zusätzlich eine Zeitzone ("20260804 17:52:56 Europe/Berlin"). Die Zeitzone
|
||||
/// wird verworfen – der Wert bleibt Ortszeit der Börse, wie ihn TWS meldet.
|
||||
/// </summary>
|
||||
public static DateTime? ParseExecutionTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2) return null;
|
||||
|
||||
return DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeitfilter für <c>reqExecutions</c>. TWS warnt (2174) bei Zeitangaben ohne Zeitzone und
|
||||
/// kündigt an, das Format zu entfernen – deshalb ausdrücklich UTC.
|
||||
/// </summary>
|
||||
public static string FormatExecutionFilterTime(DateTime since) =>
|
||||
since.ToUniversalTime().ToString("yyyyMMdd-HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
|
||||
public static bool IsLastTick (int field) => field is TickType.LAST or TickType.DELAYED_LAST;
|
||||
public static bool IsBidTick (int field) => field is TickType.BID or TickType.DELAYED_BID;
|
||||
public static bool IsAskTick (int field) => field is TickType.ASK or TickType.DELAYED_ASK;
|
||||
|
||||
@@ -7,12 +7,18 @@ namespace IBKRTrader.Core.Trading;
|
||||
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
|
||||
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
||||
/// </summary>
|
||||
public sealed class NullBrokerClient : IBrokerClient
|
||||
public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader
|
||||
{
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public NullBrokerClient(LoggingService logger) => _logger = logger;
|
||||
|
||||
public Task<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<BrokerPosition>>(Array.Empty<BrokerPosition>());
|
||||
|
||||
public Task<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<BrokerExecution>>(Array.Empty<BrokerExecution>());
|
||||
|
||||
public Task<Quote?> GetQuoteAsync(string symbol, CancellationToken ct = default)
|
||||
=> Task.FromResult<Quote?>(null);
|
||||
|
||||
|
||||
@@ -78,6 +78,56 @@ public sealed record Position(string Module, string Symbol, int Quantity, decima
|
||||
public decimal Notional => Quantity * AvgPrice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position, wie der Broker sie führt – die maßgebliche Wahrheit. Weicht bewusst von
|
||||
/// <see cref="Position"/> ab: der Broker kennt kein Modul, dafür Bewertung und Instrumententyp.
|
||||
/// Zuteilung und Verfall (Optionen) ändern Positionen ohne Order von uns; ohne Abgleich läuft
|
||||
/// die eigene Buchführung deshalb zwangsläufig auseinander.
|
||||
/// </summary>
|
||||
public sealed record BrokerPosition
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
/// <summary>"STK", "OPT", "FUT" …</summary>
|
||||
public required string SecType { get; init; }
|
||||
public required string Currency { get; init; }
|
||||
/// <summary>IBKR-Kontrakt-ID – eindeutiger als das Symbol.</summary>
|
||||
public required int ConId { get; init; }
|
||||
/// <summary>Negativ bei Short-Positionen.</summary>
|
||||
public required decimal Quantity { get; init; }
|
||||
public required decimal AvgCost { get; init; }
|
||||
// Achtung: Kurs, Wert und G/V stehen in der Währung der Position (<see cref="Currency"/>),
|
||||
// nicht in der Kontobasiswährung. Positionen verschiedener Währungen dürfen deshalb nicht
|
||||
// einfach aufsummiert werden – dafür ist NetLiquidation aus AccountState zuständig.
|
||||
public decimal MarketPrice { get; init; }
|
||||
public decimal MarketValue { get; init; }
|
||||
public decimal UnrealizedPnl { get; init; }
|
||||
|
||||
public override string ToString() =>
|
||||
$"{Symbol} ({SecType}) {Quantity:N0} @ {AvgCost:N2} {Currency}";
|
||||
}
|
||||
|
||||
/// <summary>Einzelne Ausführung (Teil-Fill) laut Broker.</summary>
|
||||
public sealed record BrokerExecution
|
||||
{
|
||||
/// <summary>Eindeutige IBKR-Ausführungs-ID – geeignet als Idempotenzschlüssel beim Import.</summary>
|
||||
public required string ExecId { get; init; }
|
||||
public required DateTime Time { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required string SecType { get; init; }
|
||||
public required TradeSide Side { get; init; }
|
||||
public required decimal Quantity { get; init; }
|
||||
public required decimal Price { get; init; }
|
||||
public string Exchange { get; init; } = "";
|
||||
/// <summary>0 bei Orders, die nicht über diese API platziert wurden (z. B. manuell in TWS).</summary>
|
||||
public int OrderId { get; init; }
|
||||
/// <summary>Kommt über einen eigenen Callback und kann fehlen, wenn er verspätet eintrifft.</summary>
|
||||
public decimal? Commission { get; init; }
|
||||
public string? CommissionCurrency { get; init; }
|
||||
|
||||
public override string ToString() =>
|
||||
$"{Time:yyyy-MM-dd HH:mm:ss} {Side} {Quantity:N0}x {Symbol} @ {Price:N2}";
|
||||
}
|
||||
|
||||
/// <summary>Kontext für die Risikobewertung eines Signals.</summary>
|
||||
public sealed record RiskContext
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user