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:
co-authored by
Claude Opus 4.8
parent
cbbedb2e0e
commit
2a312ca035
@@ -0,0 +1,95 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Persistence.Entities;
|
||||
|
||||
namespace IBKRTrader.Core.Analytics;
|
||||
|
||||
/// <summary>
|
||||
/// Das komplette, rekonstruierte Bild zu einer <c>SignalId</c>: Entscheidungskette, Order-Events,
|
||||
/// gebuchte Trades und der zugehörige JSONL-Log-Auszug. Read-only zusammengesetzt.
|
||||
/// </summary>
|
||||
public sealed record TradeDossier(
|
||||
string SignalId,
|
||||
IReadOnlyList<CoreDecisionRecord> Decisions,
|
||||
IReadOnlyList<CoreOrderEvent> OrderEvents,
|
||||
IReadOnlyList<CoreTrade> Trades,
|
||||
IReadOnlyList<LogJson.ParsedLogLine> LogLines);
|
||||
|
||||
/// <summary>
|
||||
/// Reiner Zusammenbau + Rendering eines Dossiers (JSON fürs Modell, Markdown für Menschen). Keine I/O –
|
||||
/// die Beschaffung (DB-Queries, JSONL-Lesen) liegt im <c>DossierService</c> des Supervisor-Moduls.
|
||||
/// </summary>
|
||||
public static class DossierBuilder
|
||||
{
|
||||
public static TradeDossier Build(
|
||||
string signalId,
|
||||
IEnumerable<CoreDecisionRecord> decisions,
|
||||
IEnumerable<CoreOrderEvent> orderEvents,
|
||||
IEnumerable<CoreTrade> trades,
|
||||
IEnumerable<LogJson.ParsedLogLine> logLines) =>
|
||||
new(signalId,
|
||||
decisions.OrderBy(d => d.Timestamp).ToList(),
|
||||
orderEvents.OrderBy(e => e.Timestamp).ToList(),
|
||||
trades.OrderBy(t => t.TradedAt).ToList(),
|
||||
logLines.OrderBy(l => l.Ts).ToList());
|
||||
|
||||
public static string ToMarkdown(TradeDossier d)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# Dossier – Signal `{d.SignalId}`");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Entscheidungen");
|
||||
if (d.Decisions.Count == 0) sb.AppendLine("_(keine)_");
|
||||
foreach (var r in d.Decisions)
|
||||
sb.AppendLine($"- {r.Timestamp:u} · **{r.Decision}** ({r.Reason}) · {r.Module} · {r.Side} {r.Symbol} @ {r.SignalPrice:F2} · {r.Message}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Order-Events");
|
||||
if (d.OrderEvents.Count == 0) sb.AppendLine("_(keine)_");
|
||||
foreach (var e in d.OrderEvents)
|
||||
sb.AppendLine($"- {e.Timestamp:u} · **{e.EventType}** · {e.Side} {e.Quantity}x {e.Symbol} @ {e.Price:F2} ({e.OrderType}) · {e.Response}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Trades");
|
||||
if (d.Trades.Count == 0) sb.AppendLine("_(keine)_");
|
||||
foreach (var t in d.Trades)
|
||||
sb.AppendLine($"- {t.TradedAt:u} · {t.Action} {t.Quantity}x {t.Symbol} @ {t.Price:F2} = {t.TotalValue:F2} · {t.Status}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Log-Auszug");
|
||||
if (d.LogLines.Count == 0) sb.AppendLine("_(keine passenden JSONL-Zeilen)_");
|
||||
foreach (var l in d.LogLines)
|
||||
sb.AppendLine($"- {l.Ts:u} [{l.Level}] {l.Source}: {l.Message}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static string ToJson(TradeDossier d)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
signalId = d.SignalId,
|
||||
decisions = d.Decisions.Select(r => new
|
||||
{
|
||||
ts = r.Timestamp, r.Module, r.Symbol, r.Side, price = r.SignalPrice,
|
||||
decision = r.Decision.ToString(), reason = r.Reason.ToString(), r.Message, ctx = r.ContextJson
|
||||
}),
|
||||
orderEvents = d.OrderEvents.Select(e => new
|
||||
{
|
||||
ts = e.Timestamp, e.Module, e.Symbol, eventType = e.EventType.ToString(),
|
||||
e.Side, e.Price, e.Quantity, e.OrderType, e.Response, details = e.DetailsJson
|
||||
}),
|
||||
trades = d.Trades.Select(t => new
|
||||
{
|
||||
ts = t.TradedAt, t.Module, t.Symbol, t.Action, t.Quantity, t.Price, t.TotalValue, t.Status
|
||||
}),
|
||||
logLines = d.LogLines.Select(l => new { ts = l.Ts, l.Level, l.Source, l.Message })
|
||||
};
|
||||
return JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using IBKRTrader.Core.Persistence.Entities;
|
||||
|
||||
namespace IBKRTrader.Core.Analytics;
|
||||
|
||||
/// <summary>
|
||||
/// Ein geschlossener (realisierter) Teil-Trade: eine SELL-Menge, gegen ihre FIFO-gematchten BUY-Lots
|
||||
/// abgerechnet. Aus den Fills der <c>core_trade_history</c> rein rechnerisch abgeleitet.
|
||||
/// </summary>
|
||||
public sealed record RealizedTrade(
|
||||
string Module,
|
||||
string Symbol,
|
||||
decimal Quantity,
|
||||
decimal BuyPrice,
|
||||
decimal SellPrice,
|
||||
DateTime OpenedAt,
|
||||
DateTime ClosedAt)
|
||||
{
|
||||
/// <summary>Realisierte GuV dieser Menge (Erlös − Einstand); Fees sind hier nicht berücksichtigt.</summary>
|
||||
public decimal RealizedPnl => (SellPrice - BuyPrice) * Quantity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reines, seiteneffektfreies FIFO-Lot-Matching über Fills (BUY öffnet Lots, SELL realisiert gegen die
|
||||
/// ältesten offenen Lots) — je (Modul, Symbol). Grundlage sowohl für die Supervisor-KPIs
|
||||
/// (<see cref="TradeAnalytics"/>) als auch für die realisierte GuV im Accounting-Modul (kein Duplikat).
|
||||
/// Geldkritisch → unit-getestet. Long-only-Sicht: SELL-Mengen ohne passendes offenes Lot werden
|
||||
/// ignoriert (kein Leerverkauf modelliert).
|
||||
/// </summary>
|
||||
public static class RealizedPnlEngine
|
||||
{
|
||||
private sealed class Lot
|
||||
{
|
||||
public decimal Quantity;
|
||||
public decimal Price;
|
||||
public DateTime OpenedAt;
|
||||
}
|
||||
|
||||
/// <summary>Matcht alle Fills zu realisierten Teil-Trades (chronologisch, FIFO je Modul+Symbol).</summary>
|
||||
public static IReadOnlyList<RealizedTrade> Match(IEnumerable<CoreTrade> fills)
|
||||
{
|
||||
var result = new List<RealizedTrade>();
|
||||
|
||||
var groups = fills
|
||||
.GroupBy(f => (f.Module, f.Symbol));
|
||||
|
||||
foreach (var g in groups)
|
||||
{
|
||||
var open = new Queue<Lot>();
|
||||
foreach (var f in g.OrderBy(x => x.TradedAt).ThenBy(x => x.Id))
|
||||
{
|
||||
bool isBuy = string.Equals(f.Action, "BUY", StringComparison.OrdinalIgnoreCase);
|
||||
if (isBuy)
|
||||
{
|
||||
open.Enqueue(new Lot { Quantity = f.Quantity, Price = f.Price, OpenedAt = f.TradedAt });
|
||||
continue;
|
||||
}
|
||||
|
||||
// SELL: gegen die ältesten offenen Lots abrechnen.
|
||||
decimal remaining = f.Quantity;
|
||||
while (remaining > 0 && open.Count > 0)
|
||||
{
|
||||
var lot = open.Peek();
|
||||
decimal matched = Math.Min(remaining, lot.Quantity);
|
||||
result.Add(new RealizedTrade(
|
||||
g.Key.Module, g.Key.Symbol, matched, lot.Price, f.Price, lot.OpenedAt, f.TradedAt));
|
||||
|
||||
lot.Quantity -= matched;
|
||||
remaining -= matched;
|
||||
if (lot.Quantity <= 0) open.Dequeue();
|
||||
}
|
||||
// Überschüssige SELL-Menge ohne offenes Lot: ignoriert (kein Short modelliert).
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Summe der realisierten GuV über alle gematchten Teil-Trades.</summary>
|
||||
public static decimal TotalRealized(IEnumerable<CoreTrade> fills) =>
|
||||
Match(fills).Sum(t => t.RealizedPnl);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using IBKRTrader.Core.Persistence.Entities;
|
||||
|
||||
namespace IBKRTrader.Core.Analytics;
|
||||
|
||||
/// <summary>Kennzahlen über realisierte Trades.</summary>
|
||||
public sealed record Kpis(
|
||||
int TradeCount,
|
||||
decimal NetPnl,
|
||||
double WinRatePct,
|
||||
decimal AvgPnlPerTrade,
|
||||
double ProfitFactor);
|
||||
|
||||
/// <summary>GuV-Aufteilung nach einem Schlüssel (z. B. Modul).</summary>
|
||||
public sealed record PnlBucket(string Key, decimal Pnl, int Count);
|
||||
|
||||
/// <summary>
|
||||
/// Reine KPI-Berechnung über die Fills der <c>core_trade_history</c>: erst FIFO-Realisierung
|
||||
/// (<see cref="RealizedPnlEngine"/>), dann Aggregat. Genutzt von den Supervisor-Tools (get_kpis).
|
||||
/// </summary>
|
||||
public static class TradeAnalytics
|
||||
{
|
||||
public static Kpis ComputeKpis(IEnumerable<CoreTrade> fills)
|
||||
{
|
||||
var realized = RealizedPnlEngine.Match(fills);
|
||||
int count = realized.Count;
|
||||
if (count == 0) return new Kpis(0, 0m, 0d, 0m, 0d);
|
||||
|
||||
decimal net = realized.Sum(t => t.RealizedPnl);
|
||||
int winners = realized.Count(t => t.RealizedPnl > 0);
|
||||
decimal grossProfit = realized.Where(t => t.RealizedPnl > 0).Sum(t => t.RealizedPnl);
|
||||
decimal grossLoss = Math.Abs(realized.Where(t => t.RealizedPnl < 0).Sum(t => t.RealizedPnl));
|
||||
|
||||
double winRate = 100d * winners / count;
|
||||
double profitFactor = grossLoss == 0m ? (grossProfit > 0m ? double.PositiveInfinity : 0d)
|
||||
: (double)(grossProfit / grossLoss);
|
||||
|
||||
return new Kpis(count, net, winRate, net / count, profitFactor);
|
||||
}
|
||||
|
||||
/// <summary>Realisierte GuV je Modul (absteigend nach GuV).</summary>
|
||||
public static IReadOnlyList<PnlBucket> PnlByModule(IEnumerable<CoreTrade> fills) =>
|
||||
RealizedPnlEngine.Match(fills)
|
||||
.GroupBy(t => t.Module)
|
||||
.Select(g => new PnlBucket(g.Key, g.Sum(t => t.RealizedPnl), g.Count()))
|
||||
.OrderByDescending(b => b.Pnl)
|
||||
.ToList();
|
||||
}
|
||||
Reference in New Issue
Block a user