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