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
@@ -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();
}
+59
View File
@@ -0,0 +1,59 @@
using System.Text;
using System.Text.Json;
namespace IBKRTrader.Core.Logging;
/// <summary>
/// Pure, seiteneffektfreie (De-)Serialisierung einer JSONL-Log-Zeile. JSONL (eine JSON-Zeile je Event)
/// ist append-fähig, streambar und zeilenweise filterbar – KI-freundlich und effizient. Die eigentliche
/// Datei-I/O liegt im <see cref="LoggingService"/>; hier nur das Format, damit es unit-getestet werden
/// kann (Round-Trip). Feldnamen bewusst kurz: ts, level, source, cid, message.
/// </summary>
public static class LogJson
{
private static readonly JsonSerializerOptions Opts = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>Eine geparste JSONL-Zeile (Beiwerk – fehlende/kaputte Zeilen liefern null).</summary>
public sealed record ParsedLogLine(DateTime Ts, string Level, string Source, string? Cid, string Message);
/// <summary>Serialisiert ein Log-Event zu genau einer JSON-Zeile (ohne Zeilenumbruch).</summary>
public static string WriteLine(DateTime ts, AppLogLevel level, string source, string message, string? cid)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("ts", ts.ToUniversalTime().ToString("O"));
w.WriteString("level", level.ToString());
w.WriteString("source", source ?? "");
if (!string.IsNullOrEmpty(cid)) w.WriteString("cid", cid);
w.WriteString("message", message ?? "");
w.WriteEndObject();
}
return Encoding.UTF8.GetString(ms.ToArray());
}
/// <summary>Parst eine JSONL-Zeile. Gibt null zurück, wenn die Zeile leer oder kein gültiges JSON ist.</summary>
public static ParsedLogLine? ParseLine(string? line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
try
{
using var doc = JsonDocument.Parse(line);
var r = doc.RootElement;
DateTime ts = r.TryGetProperty("ts", out var tsp) && tsp.TryGetDateTime(out var d) ? d : default;
string level = r.TryGetProperty("level", out var lp) ? lp.GetString() ?? "" : "";
string source = r.TryGetProperty("source", out var sp) ? sp.GetString() ?? "" : "";
string? cid = r.TryGetProperty("cid", out var cp) ? cp.GetString() : null;
string msg = r.TryGetProperty("message", out var mp) ? mp.GetString() ?? "" : "";
return new ParsedLogLine(ts, level, source, cid, msg);
}
catch (JsonException)
{
return null;
}
}
}
+36 -2
View File
@@ -11,7 +11,8 @@ public class LoggingService
{
private RichTextBox? _rtb;
private AppLogLevel _minLevel = AppLogLevel.Info;
private readonly object _fileLock = new();
private readonly object _fileLock = new();
private readonly object _jsonlLock = new();
private static readonly string LogBaseDir =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
@@ -33,11 +34,23 @@ public class LoggingService
public void Error(string module, string message, Exception? ex = null)
=> Write(AppLogLevel.Error, module, message, ex);
public void Write(AppLogLevel level, string module, string message, Exception? ex = null)
// Überladungen mit CorrelationId (SignalId) – schreiben zusätzlich strukturiert ins JSONL,
// sodass der Supervisor die komplette Kette „alles zu diesem Signal" filtern kann.
public void Info (string module, string message, string? cid, Exception? ex = null)
=> Write(AppLogLevel.Info, module, message, ex, cid);
public void Warn (string module, string message, string? cid, Exception? ex = null)
=> Write(AppLogLevel.Warn, module, message, ex, cid);
public void Error(string module, string message, string? cid, Exception? ex = null)
=> Write(AppLogLevel.Error, module, message, ex, cid);
public void Write(AppLogLevel level, string module, string message, Exception? ex = null, string? cid = null)
{
if (level < _minLevel) return;
var entry = new LogEntry(DateTime.Now, level, module, message, ex);
WriteToFile(entry);
WriteToJsonl(entry, cid);
WriteToRtb(entry);
}
@@ -61,6 +74,27 @@ public class LoggingService
catch { /* Logging darf niemals abstürzen */ }
}
// ─── JSONL (KI-freundlicher Zweit-Sink) ─────────────────────────────────────
/// <summary>
/// Schreibt zusätzlich eine JSON-Zeile nach Logs\{yyyy-MM-dd}.jsonl (Dual-Sink). Zeilenweise
/// filter-/parsebar (Datum/Level/Quelle/Text/CorrelationId) – Grundlage für Log Viewer + Supervisor.
/// </summary>
private void WriteToJsonl(LogEntry e, string? cid)
{
try
{
Directory.CreateDirectory(LogBaseDir);
var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
var message = e.Exception != null ? $"{e.Message} | {e.Exception.Message}" : e.Message;
var json = LogJson.WriteLine(e.Timestamp, e.Level, e.Module, message, cid);
lock (_jsonlLock)
File.AppendAllText(file, json + "\n");
}
catch { /* Logging darf niemals abstürzen */ }
}
// ─── RichTextBox ──────────────────────────────────────────────────────────
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
@@ -18,6 +18,10 @@ public class CoreDbContext : DbContext
public DbSet<CoreWorkerLog> WorkerLog => Set<CoreWorkerLog>();
public DbSet<CoreSetting> Settings => Set<CoreSetting>();
// Datenfundament für Analyse/Forensik (Supervisor)
public DbSet<CoreDecisionRecord> DecisionJournal => Set<CoreDecisionRecord>();
public DbSet<CoreOrderEvent> OrderEvents => Set<CoreOrderEvent>();
// IBKR-Marktdaten
public DbSet<IBKRInstrument> Instruments => Set<IBKRInstrument>();
public DbSet<IBKRMarketBar> MarketBars => Set<IBKRMarketBar>();
@@ -41,6 +45,7 @@ public class CoreDbContext : DbContext
e.Property(x => x.Module).HasMaxLength(50);
e.Property(x => x.Symbol).HasMaxLength(20);
e.Property(x => x.Action).HasMaxLength(10);
e.Property(x => x.SignalId).HasMaxLength(64);
e.Property(x => x.IbkrOrderId).HasMaxLength(100);
e.Property(x => x.Status).HasMaxLength(50);
e.Property(x => x.Quantity).HasPrecision(18, 4);
@@ -48,6 +53,7 @@ public class CoreDbContext : DbContext
e.Property(x => x.TotalValue).HasPrecision(18, 4);
e.HasIndex(x => x.Symbol);
e.HasIndex(x => x.Module);
e.HasIndex(x => x.SignalId);
});
b.Entity<CoreBudget>(e =>
@@ -119,5 +125,39 @@ public class CoreDbContext : DbContext
e.Property(x => x.Source).HasMaxLength(50);
e.Property(x => x.Ticker).HasMaxLength(50);
});
b.Entity<CoreDecisionRecord>(e =>
{
e.ToTable("core_decision_journal");
e.HasKey(x => x.Id);
e.Property(x => x.SignalId).HasMaxLength(64);
e.Property(x => x.Module).HasMaxLength(50);
e.Property(x => x.Symbol).HasMaxLength(20);
e.Property(x => x.Side).HasMaxLength(10);
e.Property(x => x.SignalPrice).HasPrecision(18, 4);
e.Property(x => x.Decision).HasConversion<string>().HasMaxLength(20);
e.Property(x => x.Reason).HasConversion<string>().HasMaxLength(40);
e.Property(x => x.ContextJson).HasColumnType("text");
e.Property(x => x.Message).HasColumnType("text");
e.HasIndex(x => x.SignalId);
e.HasIndex(x => new { x.Module, x.Timestamp });
});
b.Entity<CoreOrderEvent>(e =>
{
e.ToTable("core_order_events");
e.HasKey(x => x.Id);
e.Property(x => x.SignalId).HasMaxLength(64);
e.Property(x => x.Module).HasMaxLength(50);
e.Property(x => x.Symbol).HasMaxLength(20);
e.Property(x => x.Side).HasMaxLength(10);
e.Property(x => x.OrderType).HasMaxLength(20);
e.Property(x => x.Price).HasPrecision(18, 4);
e.Property(x => x.EventType).HasConversion<string>().HasMaxLength(20);
e.Property(x => x.Response).HasColumnType("text");
e.Property(x => x.DetailsJson).HasColumnType("text");
e.HasIndex(x => x.SignalId);
e.HasIndex(x => new { x.Symbol, x.Timestamp });
});
}
}
@@ -0,0 +1,84 @@
using System.Linq.Expressions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Persistence.Ef;
/// <summary>
/// EF-Implementierung des Entscheidungsjournals. <see cref="Write"/> ist bewusst fehlertolerant:
/// ein Journal-/DB-Fehler darf den Geld-Pfad (ExecutionService) niemals brechen – er wird geloggt
/// und verworfen. Kurzlebiger DbContext je Operation (thread-safe über die Factory).
/// </summary>
public sealed class EfDecisionJournal : IDecisionJournal
{
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger;
public EfDecisionJournal(IDbContextFactory<CoreDbContext> dbf, LoggingService logger)
{
_dbf = dbf;
_logger = logger;
}
public void Write(CoreDecisionRecord record)
{
try
{
using var db = _dbf.CreateDbContext();
db.DecisionJournal.Add(record);
db.SaveChanges();
}
catch (Exception ex)
{
_logger.Warn("Core", $"DecisionJournal-Write fehlgeschlagen (ignoriert): {ex.Message}");
}
}
public List<CoreDecisionRecord> Query(Expression<Func<CoreDecisionRecord, bool>> predicate, int limit = 1000)
{
using var db = _dbf.CreateDbContext();
return db.DecisionJournal.AsNoTracking()
.Where(predicate)
.OrderByDescending(r => r.Timestamp)
.Take(limit)
.ToList();
}
}
/// <summary>EF-Implementierung des Order-Lifecycle-Logs (gleiche Robustheits-Garantie).</summary>
public sealed class EfOrderEventLog : IOrderEventLog
{
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger;
public EfOrderEventLog(IDbContextFactory<CoreDbContext> dbf, LoggingService logger)
{
_dbf = dbf;
_logger = logger;
}
public void Write(CoreOrderEvent record)
{
try
{
using var db = _dbf.CreateDbContext();
db.OrderEvents.Add(record);
db.SaveChanges();
}
catch (Exception ex)
{
_logger.Warn("Core", $"OrderEvent-Write fehlgeschlagen (ignoriert): {ex.Message}");
}
}
public List<CoreOrderEvent> Query(Expression<Func<CoreOrderEvent, bool>> predicate, int limit = 1000)
{
using var db = _dbf.CreateDbContext();
return db.OrderEvents.AsNoTracking()
.Where(predicate)
.OrderByDescending(r => r.Timestamp)
.Take(limit)
.ToList();
}
}
@@ -0,0 +1,484 @@
// <auto-generated />
using System;
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
[DbContext(typeof(CoreDbContext))]
[Migration("20260730164359_AddAnalysisFoundation")]
partial class AddAnalysisFoundation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Ticker")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.HasKey("Id");
b.HasIndex("Source", "Ticker");
b.HasIndex("InstrumentId", "Source", "Ticker")
.IsUnique();
b.ToTable("core_ibkr_external_identifiers", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("CompanyName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Exchange")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<long>("IbkrConid")
.HasColumnType("bigint");
b.Property<string>("Industry")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("varchar(12)");
b.Property<DateTime?>("LastFetched")
.HasColumnType("datetime(6)");
b.Property<string>("PrimaryExchange")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SecType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Sector")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Active");
b.HasIndex("IbkrConid")
.IsUnique();
b.HasIndex("Symbol");
b.ToTable("core_ibkr_instruments", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b =>
{
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("BarSize")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int?>("BarCount")
.HasColumnType("int");
b.Property<decimal>("Close")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("High")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Low")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Open")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<long>("Volume")
.HasColumnType("bigint");
b.Property<decimal?>("Wap")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.HasKey("InstrumentId", "BarSize", "Timestamp");
b.HasIndex("Timestamp");
b.ToTable("core_ibkr_market_data", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b =>
{
b.Property<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<decimal>("MaxPerTrade")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("UsedBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.HasKey("Module");
b.ToTable("core_budget", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContextJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Module", "Timestamp");
b.ToTable("core_decision_journal", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Response")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Symbol", "Timestamp");
b.ToTable("core_order_events", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b =>
{
b.Property<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("AvgPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Module", "Symbol");
b.ToTable("core_position", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("core_settings", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IbkrOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("SignalId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("TotalValue")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("TradedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Module");
b.HasIndex("SignalId");
b.HasIndex("Symbol");
b.ToTable("core_trade_history", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Message")
.HasColumnType("longtext");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("WorkerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("WorkerName", "StartedAt");
b.ToTable("core_worker_log", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,130 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddAnalysisFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SignalId",
table: "core_trade_history",
type: "varchar(64)",
maxLength: 64,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "core_decision_journal",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SignalId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Module = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Symbol = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SignalPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Decision = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContextJson = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Message = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_core_decision_journal", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "core_order_events",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SignalId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Module = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Symbol = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EventType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Price = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
OrderType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Response = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DetailsJson = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_core_order_events", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_core_trade_history_SignalId",
table: "core_trade_history",
column: "SignalId");
migrationBuilder.CreateIndex(
name: "IX_core_decision_journal_Module_Timestamp",
table: "core_decision_journal",
columns: new[] { "Module", "Timestamp" });
migrationBuilder.CreateIndex(
name: "IX_core_decision_journal_SignalId",
table: "core_decision_journal",
column: "SignalId");
migrationBuilder.CreateIndex(
name: "IX_core_order_events_SignalId",
table: "core_order_events",
column: "SignalId");
migrationBuilder.CreateIndex(
name: "IX_core_order_events_Symbol_Timestamp",
table: "core_order_events",
columns: new[] { "Symbol", "Timestamp" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "core_decision_journal");
migrationBuilder.DropTable(
name: "core_order_events");
migrationBuilder.DropIndex(
name: "IX_core_trade_history_SignalId",
table: "core_trade_history");
migrationBuilder.DropColumn(
name: "SignalId",
table: "core_trade_history");
}
}
}
@@ -201,6 +201,133 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_budget", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContextJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Module", "Timestamp");
b.ToTable("core_decision_journal", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Response")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Symbol", "Timestamp");
b.ToTable("core_order_events", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b =>
{
b.Property<string>("Module")
@@ -279,6 +406,10 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("SignalId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
@@ -299,6 +430,8 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.HasIndex("Module");
b.HasIndex("SignalId");
b.HasIndex("Symbol");
b.ToTable("core_trade_history", (string)null);
@@ -0,0 +1,100 @@
namespace IBKRTrader.Core.Persistence.Entities;
/// <summary>Ausgang einer Handelsentscheidung im Entscheidungsjournal.</summary>
public enum TradeDecision
{
Executed, // Aktion ausgeführt (Order platziert)
Rejected, // aktiv abgelehnt (Risiko-/Plausibilitätsregel)
Skipped, // bewusst übersprungen (z. B. Trading global aus, kein Kurs)
Failed // versucht, aber fehlgeschlagen (z. B. Order-Fehler)
}
/// <summary>
/// Strukturierter Grund einer Entscheidung (statt Freitext). Als STRING persistiert – neue Werte können
/// gefahrlos ergänzt werden. Deckt die heutigen Verzweigungen im <c>ExecutionService</c> ab und lässt
/// Raum für künftige Modul-/Risiko-Regeln. Vorbild: PolytraderSharp <c>DecisionReason</c>.
/// </summary>
public enum DecisionReason
{
None = 0,
// ----- Modus / Zustand -----
TradingDisabled, // globaler Hauptschalter aus
NoQuote, // kein Kurs für das Symbol verfügbar
// ----- Risiko / Sizing -----
RiskRejected, // Risikoprüfung abgelehnt (Grund im Message-/ContextJson)
PositionLimitReached, // Modul-/Positions-Limit erreicht
MaxSlippageExceeded, // Abweichung Limit ↔ Kurs zu groß
InsufficientFunds, // verfügbares Guthaben reicht nicht
DuplicateOrder, // bereits offene/gleiche Order
// ----- Ausführung -----
OrderPlaced, // Order erfolgreich platziert
OrderFailed // Broker/Order-Fehler beim Platzieren
}
/// <summary>Art eines Order-Lifecycle-Ereignisses (als String persistiert – erweiterbar).</summary>
public enum OrderEventType
{
Placed, // Order an den Broker gesendet, Ergebnis in Response
PlaceFailed, // Platzierung fehlgeschlagen
Filled, // vollständig ausgeführt
PartiallyFilled, // teilweise ausgeführt
Cancelled // storniert (Grund in DetailsJson)
}
/// <summary>
/// Eine Zeile im Entscheidungsjournal (core_decision_journal): JEDE Handelsentscheidung –
/// ausgeführt, abgelehnt oder übersprungen – strukturiert und abfragbar. Grundlage für
/// Supervisor-Analysen („warum (nicht) gehandelt?"). Siehe docs/konzepte/KONZEPT-Modul-Supervisor.md.
/// </summary>
public class CoreDecisionRecord
{
public long Id { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>Korrelation: verbindet Signal → Entscheidung(en) → Order(s) → Trade.</summary>
public string SignalId { get; set; } = "";
public string Module { get; set; } = "";
public string Symbol { get; set; } = "";
public string Side { get; set; } = ""; // BUY / SELL
public decimal SignalPrice { get; set; }
public TradeDecision Decision { get; set; }
public DecisionReason Reason { get; set; }
/// <summary>Kompakte Kontext-Zahlen als JSON (Limitwerte, Budgets, berechnete Größen …).</summary>
public string ContextJson { get; set; } = "";
/// <summary>Menschlicher Begründungstext (wie bisher im Log).</summary>
public string Message { get; set; } = "";
}
/// <summary>
/// Ein Order-Lifecycle-Ereignis (core_order_events): Platzierungen, Broker-Antworten, Cancels – als
/// Daten statt nur als Log. Zusammen mit dem Entscheidungsjournal ergibt das die rekonstruierbare Kette
/// je Signal (Dossier).
/// </summary>
public class CoreOrderEvent
{
public long Id { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string SignalId { get; set; } = "";
public string Module { get; set; } = "";
public string Symbol { get; set; } = "";
public OrderEventType EventType { get; set; }
public string Side { get; set; } = ""; // BUY / SELL
public decimal Price { get; set; }
public int Quantity { get; set; }
public string OrderType { get; set; } = ""; // Market / Limit
/// <summary>Broker-Antwort ("OK" oder Fehlertext) bzw. Ergebnis der Aktion.</summary>
public string Response { get; set; } = "";
/// <summary>Zusatzkontext als kompaktes JSON (z. B. Fill-Preis, Teilmenge, Grund).</summary>
public string DetailsJson { get; set; } = "";
}
@@ -21,6 +21,8 @@ public class CoreTrade
public decimal Price { get; set; }
public decimal TotalValue { get; set; }
public DateTime TradedAt { get; set; }
/// <summary>Korrelation zu Entscheidungsjournal/Order-Events (leer für Trades ohne Signal-Kette).</summary>
public string? SignalId { get; set; }
public string? IbkrOrderId { get; set; }
public string? Status { get; set; }
public string? Notes { get; set; }
@@ -0,0 +1,21 @@
using System.Linq.Expressions;
using IBKRTrader.Core.Persistence.Entities;
namespace IBKRTrader.Core.Persistence;
/// <summary>
/// Entscheidungsjournal (core_decision_journal). <see cref="Write"/> darf den Trading-Pfad NIEMALS
/// brechen – Implementierungen fangen Persistenzfehler ab (Log statt Exception).
/// </summary>
public interface IDecisionJournal
{
void Write(CoreDecisionRecord record);
List<CoreDecisionRecord> Query(Expression<Func<CoreDecisionRecord, bool>> predicate, int limit = 1000);
}
/// <summary>Order-Lifecycle-Log (core_order_events). Gleiche Robustheits-Garantie wie das Journal.</summary>
public interface IOrderEventLog
{
void Write(CoreOrderEvent record);
List<CoreOrderEvent> Query(Expression<Func<CoreOrderEvent, bool>> predicate, int limit = 1000);
}
@@ -214,6 +214,53 @@ public class TradingSettings
public override string ToString() => $"{Mode} – {(TradingEnabled ? "aktiv" : "inaktiv")}";
}
// ─── Accounting ──────────────────────────────────────────────────────────────
[TypeConverter(typeof(ExpandableObjectConverter))]
public class AccountingSettings
{
[Category("Accounting")]
[DisplayName("Basiswährung")]
[Description("Kontobasiswährung für die Abrechnung (Standard: USD)")]
public string BaseCurrency { get; set; } = "USD";
[Category("Accounting")]
[DisplayName("Ingest-Intervall (Stunden)")]
[Description("Abstand der automatischen Kontoauszug-Abrufe (Live-Quelle; offline ungenutzt)")]
public int IngestIntervalHours { get; set; } = 6;
[Category("Accounting")]
[DisplayName("Flex Query-Id")]
[Description("IBKR Activity Flex Query-Id (Zielland – Live-Abruf; leer = offline)")]
public string FlexQueryId { get; set; } = "";
[Category("Accounting")]
[DisplayName("Flex-Token")]
[Description("IBKR Flex Web Service Token (Zielland; verschlüsselt ablegen, nicht im Klartext)")]
[PasswordPropertyText(true)]
public string FlexToken { get; set; } = "";
public override string ToString() => $"Basis {BaseCurrency}, alle {IngestIntervalHours} h";
}
// ─── Supervisor ──────────────────────────────────────────────────────────────
[TypeConverter(typeof(ExpandableObjectConverter))]
public class SupervisorSettings
{
[Category("Supervisor")]
[DisplayName("Modell")]
[Description("OpenRouter-Modell für die Analyse (Standard: openrouter/auto)")]
public string Model { get; set; } = "openrouter/auto";
[Category("Supervisor")]
[DisplayName("Token-Budget/Analyse")]
[Description("Weiches Token-Budget je Analyse-Lauf (Hinweis; harte Grenze = max. Tool-Iterationen)")]
public int TokenBudgetPerRun { get; set; } = 60000;
public override string ToString() => Model;
}
// ─── Root ────────────────────────────────────────────────────────────────────
public class AppSettings
@@ -252,4 +299,14 @@ public class AppSettings
[DisplayName("Trading")]
[Description("Handelsmodus und Risiko-Parameter")]
public TradingSettings Trading { get; set; } = new();
[Category("Accounting")]
[DisplayName("Accounting")]
[Description("Buchhaltung/Reporting (unabhängiger Kontoauszug-Ingest)")]
public AccountingSettings Accounting { get; set; } = new();
[Category("Supervisor")]
[DisplayName("Supervisor")]
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
public SupervisorSettings Supervisor { get; set; } = new();
}
+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; }