R8: Accounting- + Supervisor-Modul + Core-Datenfundament (S-0)

Portierung der beiden fehlenden Grundbausteine aus PolytraderSharp (voller Ausbau).

Core S-0 (Datenfundament fuer Analyse/Forensik):
- core_decision_journal + core_order_events (+ ReasonCode/Decision/OrderEvent-Enums),
  IDecisionJournal/IOrderEventLog mit fehlertoleranten EF-Impls (Handel bricht nie).
- SignalId-Durchreichung TradeSignal -> ExecutionService -> core_trade_history;
  ExecutionService schreibt an jeder Verzweigung Journal/Order-Events.
- JSONL-Log-Sink (LogJson + Dual-Sink), pure Analytik: RealizedPnlEngine (FIFO),
  TradeAnalytics, DossierBuilder. Migration AddAnalysisFoundation.

Accounting-Modul (acc_): unabhaengiger IBKR-Kontoauszug (Activity Flex Query) hinter
Interfaces mit Offline-Null-Stubs -> append-only Ledger + Periodenabrechnung/BWA + FX
(USD/EUR) + CSV/PDF (PDFsharp/MigraDoc). Steuerschicht bewusst offen (Platzhalter-Tab).
Kein Handel. Migration InitialAccounting.

Supervisor-Modul (sup_): read-only OpenRouter-Agent (Function-Calling-Loop) + read-only
Tool-Registry (8 Tools) + Profile + Dossier-Browser + Counterfactual-Job (Stub) +
Tagesbericht/MCP-Light (opt-in). Migration InitialSupervisor.

Verdrahtung: Program.cs (beide Module + Icons), slnx/App/Tests-Referenzen,
provision-db.ps1, AppSettings-Sektionen, docs/konzepte, README.

Tests: 79 -> 117 gruen (FIFO/KPIs/Dossier/JSONL, Classifier/Engine/FX/Idempotenz,
OpenRouter/Registry/Agent/MCP, STA-Konstruktion beider neuen Fenster).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-31 09:25:18 +02:00
co-authored by Claude Opus 4.8
parent cbbedb2e0e
commit 2a312ca035
86 changed files with 6880 additions and 22 deletions
+81 -11
View File
@@ -1,4 +1,7 @@
using System.Text.Json;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Trading;
@@ -6,42 +9,59 @@ namespace IBKRTrader.Core.Trading;
/// <summary>
/// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung.
/// Kennt kein Modul – Module rufen nur <see cref="ExecuteAsync"/> mit ihrem Signal auf.
///
/// Schreibt an jeder Verzweigung strukturiert ins Entscheidungsjournal (core_decision_journal) und bei
/// Order-Aktionen ins Order-Lifecycle-Log (core_order_events) und reicht die <c>SignalId</c> bis in die
/// Trade-Historie durch – Grundlage für die Supervisor-Forensik. Journal-Fehler brechen den Handel nie.
/// </summary>
public sealed class ExecutionService : IExecutionService
{
private readonly IBrokerClient _broker;
private readonly IRiskService _risk;
private readonly IBrokerClient _broker;
private readonly IRiskService _risk;
private readonly IPortfolioService _portfolio;
private readonly SettingsService _settings;
private readonly LoggingService _logger;
private readonly SettingsService _settings;
private readonly LoggingService _logger;
private readonly IDecisionJournal _journal;
private readonly IOrderEventLog _orderEvents;
public ExecutionService(
IBrokerClient broker,
IRiskService risk,
IPortfolioService portfolio,
SettingsService settings,
LoggingService logger)
LoggingService logger,
IDecisionJournal journal,
IOrderEventLog orderEvents)
{
_broker = broker;
_risk = risk;
_portfolio = portfolio;
_settings = settings;
_logger = logger;
_broker = broker;
_risk = risk;
_portfolio = portfolio;
_settings = settings;
_logger = logger;
_journal = journal;
_orderEvents = orderEvents;
}
public async Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default)
{
var trading = _settings.Settings.Trading;
var module = signal.SourceModule;
var sideStr = signal.Side == TradeSide.Buy ? "BUY" : "SELL";
// 1. Globaler Hauptschalter
if (!trading.TradingEnabled)
{
Journal(signal, TradeDecision.Skipped, DecisionReason.TradingDisabled, "Trading global deaktiviert.");
return Log(module, ExecutionResult.Skip("Trading global deaktiviert."));
}
// 2. Kurs
var quote = await _broker.GetQuoteAsync(signal.Symbol, ct);
if (quote is null || quote.Last <= 0)
{
Journal(signal, TradeDecision.Skipped, DecisionReason.NoQuote, $"Kein Kurs für {signal.Symbol} verfügbar.");
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);
@@ -62,7 +82,11 @@ public sealed class ExecutionService : IExecutionService
(decimal)trading.MaxSlippagePercent);
var decision = _risk.Evaluate(signal, context, riskParams);
if (!decision.Approved)
{
Journal(signal, TradeDecision.Rejected, DecisionReason.RiskRejected, decision.Reason,
new { price = quote.Last, netLiq = account.NetLiquidation, exposure, existingQty });
return Log(module, ExecutionResult.Skip(decision.Reason));
}
// 6. Order platzieren
var order = new OrderRequest
@@ -76,16 +100,62 @@ public sealed class ExecutionService : IExecutionService
var result = await _broker.PlaceOrderAsync(order, ct);
if (!result.Success)
{
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));
}
OrderEvent(signal, OrderEventType.Filled, sideStr, order, result.AvgFillPrice, result.OrderId ?? "OK",
new { filled = result.FilledQuantity, avgPrice = result.AvgFillPrice });
// 7. Buchung
await _portfolio.RecordFillAsync(
module, signal.Symbol, signal.Side,
result.FilledQuantity, result.AvgFillPrice, result.OrderId, ct);
result.FilledQuantity, result.AvgFillPrice, result.OrderId, signal.SignalId, 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 });
return Log(module, ExecutionResult.Execute(result));
}
// ─── Journal-/Order-Event-Helfer (fehlertolerant über die EF-Implementierung) ────────────────
private void Journal(TradeSignal signal, TradeDecision decision, DecisionReason reason,
string message, object? context = null) =>
_journal.Write(new CoreDecisionRecord
{
Timestamp = DateTime.UtcNow,
SignalId = signal.SignalId,
Module = signal.SourceModule,
Symbol = signal.Symbol,
Side = signal.Side == TradeSide.Buy ? "BUY" : "SELL",
SignalPrice = signal.LimitPrice ?? 0m,
Decision = decision,
Reason = reason,
ContextJson = context is null ? "" : JsonSerializer.Serialize(context),
Message = message
});
private void OrderEvent(TradeSignal signal, OrderEventType type, string side, OrderRequest order,
decimal price, string response, object? details = null) =>
_orderEvents.Write(new CoreOrderEvent
{
Timestamp = DateTime.UtcNow,
SignalId = signal.SignalId,
Module = signal.SourceModule,
Symbol = signal.Symbol,
EventType = type,
Side = side,
Price = price,
Quantity = order.Quantity,
OrderType = order.Type.ToString(),
Response = response,
DetailsJson = details is null ? "" : JsonSerializer.Serialize(details)
});
private ExecutionResult Log(string module, ExecutionResult result)
{
var text = $"[{result.Action}] {result.Reason}";
@@ -15,7 +15,8 @@ public interface IPortfolioService
/// <summary>Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.</summary>
Task RecordFillAsync(
string module, string symbol, TradeSide side,
int quantity, decimal price, string? orderId, CancellationToken ct = default);
int quantity, decimal price, string? orderId, string? signalId = null,
CancellationToken ct = default);
/// <summary>Alle offenen Positionen eines Moduls.</summary>
Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default);
@@ -54,12 +54,13 @@ public sealed class PortfolioService : IPortfolioService
public async Task RecordFillAsync(
string module, string symbol, TradeSide side,
int quantity, decimal price, string? orderId, CancellationToken ct = default)
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);
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId, signalId: signalId);
await using (var db = await _dbf.CreateDbContextAsync(ct))
{
@@ -19,7 +19,8 @@ public class TradeHistoryService
public async Task RecordTradeAsync(
string module, string symbol, string action,
decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null)
decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null,
string? signalId = null)
{
await using var db = await _dbf.CreateDbContextAsync();
db.TradeHistory.Add(new CoreTrade
@@ -31,6 +32,7 @@ public class TradeHistoryService
Price = price,
TotalValue = quantity * price,
TradedAt = DateTime.UtcNow,
SignalId = signalId,
IbkrOrderId = ibkrOrderId,
Status = "Executed",
Notes = notes,
@@ -15,6 +15,12 @@ public enum TradingMode { Paper, Live }
/// </summary>
public sealed record TradeSignal
{
/// <summary>
/// Korrelations-ID der Signal-Kette (Signal → Entscheidung(en) → Order(s) → Trade). Standardmäßig
/// neu erzeugt; ein Modul kann eine eigene ID setzen, um mehrere Signale zu verknüpfen.
/// </summary>
public string SignalId { get; init; } = Guid.NewGuid().ToString("N");
/// <summary>Ticker-Symbol (z. B. "AAPL").</summary>
public required string Symbol { get; init; }