Supervisor S-0a/b: Entscheidungsjournal, Order-Events, SignalId, JSONL-Logs

Datenfundament fuer die KI-Analyse (docs/konzepte/KONZEPT-Modul-Supervisor.md, Phase S-0).
Sofortnutzen auch ohne KI: abfragbare Rejects, rekonstruierbare Order-Ketten.

- core_decision_journal (DecisionRecord): JEDE Handelsentscheidung strukturiert -
  Decision (Executed/Rejected/Skipped/Failed) + ReasonCode-Enum (als String, erweiterbar)
  + Kontext-JSON (kulturinvariant) + MarketSlug/EndDate (counterfactual-ready).
- core_order_events (OrderEventRecord): Order-Lifecycle als Daten (Placed/PlaceFailed/
  LadderStart/LadderStep/FloorReplaced/DustAbort ...) inkl. CLOB-Response.
- SignalId (GUID) auf CopySignal, durchgereicht bis ClosedTrade + Core-TradeRecord
  (Dual-Write) und in die Leiter (ExitLadderState.SignalId) -> Dossier-Korrelation.
- CopyTradingEngine: ~20 Entscheidungs-Sites journalisieren zusaetzlich zum Freitext-Log
  (Mode/SellOnly/H3/MaxBuyPrice/PerMaster/TimeLimit/Budget/Balance/Minimum/SpamBlock/
  Leiter/Ownership/NotFound/Teilverkauf/Inkonsistenz/Grace/Demo-Fill/-Close/Live-BUY).
- SellLadderService: Order-Events fuer Start/Step/Floor-Replace/Dust; IOrderEventLog injiziert.
- TerminalLogger: JSONL-Sink (eine Datei/Tag, LogJson pur) + CorrelationId-Support. Dual-Sink.
- Journal-Writes fehlertolerant (brechen den Geld-Pfad nie).
- Migrationen offline generiert UND angewendet (rein additiv): Core (Widen F1 + Journal),
  CopyTrading (SignalId), RF (Initial) - DB war bis AddMarketTakerFeeBps migriert, verifiziert.
- Launcher-Designer: Account-Spalten-Instanziierung wiederhergestellt (war bei Designer-
  Reserialisierung verloren gegangen -> NullReference im Smoke).

Tests: +5 (LogJson, Journal-Roundtrips, SignalId) + Journal-Assertions in Engine-Tests.
Build 0 Fehler, 336 Tests gruen, --smoke-ui komplett gruen (alle Views inkl. RF gegen echte Tabellen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-16 18:48:25 +02:00
co-authored by Claude Opus 4.8
parent eac47369f0
commit bf3e012f8b
28 changed files with 1796 additions and 39 deletions
@@ -24,6 +24,10 @@ namespace PolyTrader.Core.DependencyInjection
services.AddSingleton<IPositionRepository, EfPositionRepository>();
services.AddSingleton<ITradeLogRepository, EfTradeLogRepository>();
// S-0 (Supervisor-Konzept): Entscheidungsjournal + Order-Lifecycle-Events.
services.AddSingleton<IDecisionJournal, EfDecisionJournal>();
services.AddSingleton<IOrderEventLog, EfOrderEventLog>();
return services;
}
}
+7
View File
@@ -2,6 +2,13 @@ namespace PolyTraderSharp.Models
{
public class CopySignal
{
/// <summary>
/// Korrelations-ID (S-0, Supervisor-Konzept): verbindet dieses Signal mit allen daraus
/// entstehenden Entscheidungen (core_decision_journal), Orders (core_order_events) und
/// dem ClosedTrade. Wird bei Erzeugung automatisch vergeben.
/// </summary>
public string SignalId { get; set; } = Guid.NewGuid().ToString("N");
public int SourceTradeId { get; set; }
public int TraderId { get; set; }
public string MarketSlug { get; set; } = string.Empty;
@@ -0,0 +1,96 @@
using System;
namespace PolyTraderSharp.Models
{
/// <summary>Ausgang einer Handelsentscheidung im Entscheidungsjournal.</summary>
public enum TradeDecision
{
Executed, // Aktion ausgeführt (Order platziert / Demo-Fill / Leiter gestartet)
Rejected, // aktiv abgelehnt (Risk-/Plausibilitätsregel)
Skipped, // bewusst übersprungen (z. B. ExitPending, Spam-Blockade, Modus)
Failed // versucht, aber fehlgeschlagen (z. B. Order-Fehler)
}
/// <summary>
/// Strukturierter Grund einer Entscheidung (statt Freitext). Wird als STRING persistiert
/// neue Werte können gefahrlos ergänzt werden. Die Codes decken die heutigen
/// TradeReasoning-/Reject-Stellen von Engine, Leiter und Monitor ab.
/// </summary>
public enum DecisionReason
{
None = 0,
// ----- Modus / Zustand -----
ModeInactive, // Live-/Demo-Trading deaktiviert
SellOnlyModeBuyBlocked, // SellOnly-Modus blockiert BUY
TraderInactive, // Master nicht gefunden / inaktiv
AccountInactive, // Account nicht gefunden / inaktiv
// ----- BUY-Pfad -----
MaxBuyPriceExceeded, // Signalpreis über MaxBuyPrice
ExitPendingBuySkip, // H3: SELL-Leiter aktiv kein Zukauf
TimeWindowLimitReached, // Zeitfenster-Budget (6h/24h/72h/None) erschöpft
MarketBudgetExhausted, // PerMarket-Budget erschöpft
PerMasterLimitReached, // PerMaster-Budget erschöpft
InsufficientBalance, // verfügbares Guthaben reicht nicht
BelowPolymarketMinimum, // Order unter Minimum (Shares/USDC)
MarketExpiredOrTooClose, // EndDate-Filter
DuplicateOrPendingOrder, // bereits offene/pending Order
// ----- SELL-Pfad -----
SellSpamBlock, // SELL <20s nach letztem SELL
LadderAlreadyActive, // Eskalationsleiter läuft bereits
PositionNotFound, // keine passende Position im Portfolio
OwnershipMismatch, // Position gehört anderem Trader (Safety)
PartialSellBelowThreshold, // Teilverkauf unter Signifikanz-Schwelle
MasterPositionInconsistent, // Master hält laut Tracking 0 Shares
SyncGracePeriod, // kein Tracking + Haltezeit < Schonfrist
// ----- Ausführung -----
OrderPlaced, // Order erfolgreich platziert
OrderFailed, // CLOB-Fehler beim Platzieren
DemoFilled, // Demo-Fill gebucht
DemoClosed, // Demo-Position geschlossen
LadderStarted, // SELL-Leiter gestartet
LadderStartFailed, // Leiter-Startorder fehlgeschlagen
LadderDustAbort, // H4: Dust-Rest unter Minimum Leiter beendet
ProfitTargetTriggered, // Take-Profit hat Exit ausgelöst
SystemResolutionClose // System-Close bei Marktauflösung (TraderId==0)
}
/// <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?") und Counterfactual-Auswertungen
/// (MarketSlug/EndDate sind dafür bewusst enthalten). Siehe docs/konzepte/KONZEPT-Modul-Supervisor.md.
/// </summary>
public class DecisionRecord
{
public long Id { get; set; } // DB-Autoincrement
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>Korrelation: verbindet Signal → Entscheidungen → Orders → ClosedTrade.</summary>
public string SignalId { get; set; } = string.Empty;
public string ModuleName { get; set; } = string.Empty;
public int AccountId { get; set; }
public bool IsDemo { get; set; }
public int SourceTraderId { get; set; }
public string TokenId { get; set; } = string.Empty;
public string MarketSlug { get; set; } = string.Empty; // counterfactual-ready
public string MarketQuestion { get; set; } = string.Empty;
public string Side { get; set; } = string.Empty; // BUY/SELL
public decimal SignalPrice { get; set; }
public DateTime? MarketEndDate { get; set; } // counterfactual-ready
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; } = string.Empty;
/// <summary>Menschlicher Begründungstext (wie bisher im Log).</summary>
public string Message { get; set; } = string.Empty;
}
}
@@ -0,0 +1,47 @@
using System;
namespace PolyTraderSharp.Models
{
/// <summary>Art eines Order-Lifecycle-Ereignisses (als String persistiert erweiterbar).</summary>
public enum OrderEventType
{
Placed, // Order ans CLOB gesendet, Ergebnis in Response
PlaceFailed, // Platzierung fehlgeschlagen
Cancelled, // Order storniert (inkl. Grund in DetailsJson)
LadderStart, // erste GTC-Order der SELL-Leiter
LadderStep, // Nachpreisen eine Stufe tiefer
FloorHold, // Floor erreicht, Order ruht
FloorReplaced, // K1b: fehlende Floor-Order neu platziert
DustAbort, // H4: Leiter wegen Dust-Rest beendet
StartupReconcileCancel, // K2: verwaiste Order beim Start storniert
StaleCleanupCancel // Stale-Order-Cleanup hat storniert
}
/// <summary>
/// Ein Order-Lifecycle-Ereignis (core_order_events): Platzierungen, CLOB-Antworten, Cancels,
/// Leiter-Stufen als Daten statt nur als Log. Zusammen mit dem Entscheidungsjournal ergibt das
/// die vollständige, rekonstruierbare Kette je Signal (Dossier).
/// </summary>
public class OrderEventRecord
{
public long Id { get; set; } // DB-Autoincrement
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string SignalId { get; set; } = string.Empty;
public string ModuleName { get; set; } = string.Empty;
public int AccountId { get; set; }
public string TokenId { get; set; } = string.Empty;
public OrderEventType EventType { get; set; }
public string Side { get; set; } = string.Empty; // BUY/SELL
public decimal Price { get; set; }
public decimal AmountUsd { get; set; }
public string OrderType { get; set; } = string.Empty; // GTC/GTD/FOK/MARKET
/// <summary>CLOB-Antwort ("OK" oder Fehlertext) bzw. Ergebnis der Aktion.</summary>
public string Response { get; set; } = string.Empty;
/// <summary>Zusatzkontext als kompaktes JSON (z. B. Leiter-Stufe, Floor, Timeout-Grund).</summary>
public string DetailsJson { get; set; } = string.Empty;
}
}
@@ -34,5 +34,8 @@ namespace PolyTraderSharp.Models
public DateTime OpenedAt { get; set; }
public DateTime ClosedAt { get; set; }
public string ExitReason { get; set; } = string.Empty;
/// <summary>Korrelation zum Entscheidungsjournal/Order-Events (leer bei Alt-Daten).</summary>
public string SignalId { get; set; } = string.Empty;
}
}
@@ -17,6 +17,8 @@ namespace PolyTrader.Core.Persistence.Ef
public DbSet<Position> Positions => Set<Position>();
public DbSet<MarketData> Markets => Set<MarketData>();
public DbSet<TradeRecord> TradeLog => Set<TradeRecord>();
public DbSet<DecisionRecord> DecisionJournal => Set<DecisionRecord>();
public DbSet<OrderEventRecord> OrderEvents => Set<OrderEventRecord>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -93,6 +95,52 @@ namespace PolyTrader.Core.Persistence.Ef
e.HasIndex(x => x.ClosedAt);
e.HasIndex(x => x.ModuleName);
e.HasIndex(x => x.AccountId);
e.Property(x => x.SignalId).HasMaxLength(40); // Korrelation zum Entscheidungsjournal
});
// Entscheidungsjournal (S-0, Supervisor-Konzept): jede Handelsentscheidung strukturiert.
b.Entity<DecisionRecord>(e =>
{
e.ToTable("core_decision_journal");
e.HasKey(x => x.Id);
e.Property(x => x.Id).ValueGeneratedOnAdd();
e.Property(x => x.SignalId).HasMaxLength(40);
e.Property(x => x.ModuleName).HasMaxLength(64);
e.Property(x => x.TokenId).HasMaxLength(120);
e.Property(x => x.MarketSlug).HasMaxLength(300);
e.Property(x => x.MarketQuestion).HasMaxLength(1000);
e.Property(x => x.Side).HasMaxLength(10);
e.Property(x => x.SignalPrice).HasPrecision(18, 6);
// Enums als String: lesbar in der DB, gefahrlos erweiterbar.
e.Property(x => x.Decision).HasConversion<string>().HasMaxLength(20);
e.Property(x => x.Reason).HasConversion<string>().HasMaxLength(50);
e.Property(x => x.ContextJson).HasMaxLength(2000);
e.Property(x => x.Message).HasMaxLength(2000);
e.HasIndex(x => x.Timestamp);
e.HasIndex(x => x.SignalId);
e.HasIndex(x => new { x.AccountId, x.TokenId });
e.HasIndex(x => x.Reason);
});
// Order-Lifecycle-Events (S-0): Platzierungen/Antworten/Cancels/Leiter-Stufen als Daten.
b.Entity<OrderEventRecord>(e =>
{
e.ToTable("core_order_events");
e.HasKey(x => x.Id);
e.Property(x => x.Id).ValueGeneratedOnAdd();
e.Property(x => x.SignalId).HasMaxLength(40);
e.Property(x => x.ModuleName).HasMaxLength(64);
e.Property(x => x.TokenId).HasMaxLength(120);
e.Property(x => x.EventType).HasConversion<string>().HasMaxLength(30);
e.Property(x => x.Side).HasMaxLength(10);
e.Property(x => x.Price).HasPrecision(18, 6);
e.Property(x => x.AmountUsd).HasPrecision(18, 6);
e.Property(x => x.OrderType).HasMaxLength(10);
e.Property(x => x.Response).HasMaxLength(1000);
e.Property(x => x.DetailsJson).HasMaxLength(2000);
e.HasIndex(x => x.Timestamp);
e.HasIndex(x => x.SignalId);
e.HasIndex(x => new { x.AccountId, x.TokenId });
});
}
}
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using PolyTraderSharp.Models;
namespace PolyTrader.Core.Persistence.Ef
{
/// <summary>
/// EF-Implementierung des Entscheidungsjournals. Write ist bewusst fehlertolerant:
/// ein Journal-/DB-Fehler darf den Geld-Pfad (Engine/Leiter) niemals brechen
/// er wird auf der Konsole gemeldet und verworfen.
/// </summary>
public class EfDecisionJournal : IDecisionJournal
{
private readonly IDbContextFactory<CoreDbContext> _factory;
public EfDecisionJournal(IDbContextFactory<CoreDbContext> factory) => _factory = factory;
public void Write(DecisionRecord record)
{
try
{
using var ctx = _factory.CreateDbContext();
ctx.DecisionJournal.Add(record);
ctx.SaveChanges();
}
catch (Exception ex)
{
Console.WriteLine($"[DecisionJournal] Write fehlgeschlagen (ignoriert): {ex.Message}");
}
}
public List<DecisionRecord> Query(Expression<Func<DecisionRecord, bool>> predicate, int limit = 1000)
{
using var ctx = _factory.CreateDbContext();
return ctx.DecisionJournal.AsNoTracking()
.Where(predicate)
.OrderByDescending(r => r.Timestamp)
.Take(limit)
.ToList();
}
}
/// <summary>EF-Implementierung des Order-Lifecycle-Logs (gleiche Robustheits-Garantie).</summary>
public class EfOrderEventLog : IOrderEventLog
{
private readonly IDbContextFactory<CoreDbContext> _factory;
public EfOrderEventLog(IDbContextFactory<CoreDbContext> factory) => _factory = factory;
public void Write(OrderEventRecord record)
{
try
{
using var ctx = _factory.CreateDbContext();
ctx.OrderEvents.Add(record);
ctx.SaveChanges();
}
catch (Exception ex)
{
Console.WriteLine($"[OrderEventLog] Write fehlgeschlagen (ignoriert): {ex.Message}");
}
}
public List<OrderEventRecord> Query(Expression<Func<OrderEventRecord, bool>> predicate, int limit = 1000)
{
using var ctx = _factory.CreateDbContext();
return ctx.OrderEvents.AsNoTracking()
.Where(predicate)
.OrderByDescending(r => r.Timestamp)
.Take(limit)
.ToList();
}
}
}
@@ -0,0 +1,481 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PolyTrader.Core.Persistence.Ef;
#nullable disable
namespace PolyTrader.Core.Persistence.Ef.Migrations
{
[DbContext(typeof(CoreDbContext))]
[Migration("20260716101647_AddDecisionJournalAndOrderEvents")]
partial class AddDecisionJournalAndOrderEvents
{
/// <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("PolyTraderSharp.Models.AccountState", b =>
{
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("ApiPassphrase")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ApiSecret")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<decimal>("AvailableBalance")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<bool>("CloseOnlyMode")
.HasColumnType("tinyint(1)");
b.Property<bool>("HasOpenLimitOrders")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("PayoutAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<decimal>("PayoutLimitUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<decimal>("TotalBalance")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("WalletAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.HasKey("AccountId");
b.ToTable("core_accounts", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.DecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<string>("ContextJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("MarketEndDate")
.HasColumnType("datetime(6)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("SourceTraderId")
.HasColumnType("int");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("Reason");
b.HasIndex("SignalId");
b.HasIndex("Timestamp");
b.HasIndex("AccountId", "TokenId");
b.ToTable("core_decision_journal", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b =>
{
b.Property<string>("Id")
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("ClobTokenIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Closed")
.HasColumnType("tinyint(1)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<bool>("NegRisk")
.HasColumnType("tinyint(1)");
b.Property<string>("Outcomes")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<int>("TakerFeeBps")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("core_markets", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.OrderEventRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("AmountUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("DetailsJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Price")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Response")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Timestamp");
b.HasIndex("AccountId", "TokenId");
b.ToTable("core_order_events", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.Position", b =>
{
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("TokenId")
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<decimal>("AmountUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("CurrentValueUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("EntryPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<DateTime?>("ExpiryDate")
.HasColumnType("datetime(6)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<DateTime>("OpenedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Size")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("SourceTraderAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("SourceTraderId")
.HasColumnType("int");
b.Property<string>("SourceTraderName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.HasKey("AccountId", "IsDemo", "TokenId");
b.ToTable("core_positions", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.TradeRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("EntryPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("ExitPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("ExitReason")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("OpenedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("PnlPercent")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("Size")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("ClosedAt");
b.HasIndex("ModuleName");
b.ToTable("core_trade_log", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,146 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddDecisionJournalAndOrderEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SignalId",
table: "core_trade_log",
type: "varchar(40)",
maxLength: 40,
nullable: false,
defaultValue: "")
.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(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ModuleName = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AccountId = table.Column<int>(type: "int", nullable: false),
IsDemo = table.Column<bool>(type: "tinyint(1)", nullable: false),
SourceTraderId = table.Column<int>(type: "int", nullable: false),
TokenId = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketSlug = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketQuestion = table.Column<string>(type: "varchar(1000)", maxLength: 1000, 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,6)", precision: 18, scale: 6, nullable: false),
MarketEndDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
Decision = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContextJson = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Message = table.Column<string>(type: "varchar(2000)", maxLength: 2000, 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(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ModuleName = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AccountId = table.Column<int>(type: "int", nullable: false),
TokenId = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EventType = table.Column<string>(type: "varchar(30)", maxLength: 30, 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,6)", precision: 18, scale: 6, nullable: false),
AmountUsd = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
OrderType = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Response = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DetailsJson = table.Column<string>(type: "varchar(2000)", maxLength: 2000, 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_decision_journal_AccountId_TokenId",
table: "core_decision_journal",
columns: new[] { "AccountId", "TokenId" });
migrationBuilder.CreateIndex(
name: "IX_core_decision_journal_Reason",
table: "core_decision_journal",
column: "Reason");
migrationBuilder.CreateIndex(
name: "IX_core_decision_journal_SignalId",
table: "core_decision_journal",
column: "SignalId");
migrationBuilder.CreateIndex(
name: "IX_core_decision_journal_Timestamp",
table: "core_decision_journal",
column: "Timestamp");
migrationBuilder.CreateIndex(
name: "IX_core_order_events_AccountId_TokenId",
table: "core_order_events",
columns: new[] { "AccountId", "TokenId" });
migrationBuilder.CreateIndex(
name: "IX_core_order_events_SignalId",
table: "core_order_events",
column: "SignalId");
migrationBuilder.CreateIndex(
name: "IX_core_order_events_Timestamp",
table: "core_order_events",
column: "Timestamp");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "core_decision_journal");
migrationBuilder.DropTable(
name: "core_order_events");
migrationBuilder.DropColumn(
name: "SignalId",
table: "core_trade_log");
}
}
}
@@ -91,6 +91,96 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_accounts", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.DecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<string>("ContextJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("MarketEndDate")
.HasColumnType("datetime(6)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("SourceTraderId")
.HasColumnType("int");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("Reason");
b.HasIndex("SignalId");
b.HasIndex("Timestamp");
b.HasIndex("AccountId", "TokenId");
b.ToTable("core_decision_journal", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b =>
{
b.Property<string>("Id")
@@ -145,6 +235,79 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_markets", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.OrderEventRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("AmountUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("DetailsJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Price")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Response")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Timestamp");
b.HasIndex("AccountId", "TokenId");
b.ToTable("core_order_events", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.Position", b =>
{
b.Property<int>("AccountId")
@@ -285,6 +448,11 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("Size")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using PolyTraderSharp.Models;
namespace PolyTrader.Core.Persistence
{
/// <summary>
/// Entscheidungsjournal (core_decision_journal). Write darf den Trading-Pfad NIEMALS brechen
/// Implementierungen fangen Persistenzfehler ab (Log statt Exception).
/// </summary>
public interface IDecisionJournal
{
void Write(DecisionRecord record);
List<DecisionRecord> Query(Expression<Func<DecisionRecord, bool>> predicate, int limit = 1000);
}
/// <summary>Order-Lifecycle-Log (core_order_events). Gleiche Robustheits-Garantie wie das Journal.</summary>
public interface IOrderEventLog
{
void Write(OrderEventRecord record);
List<OrderEventRecord> Query(Expression<Func<OrderEventRecord, bool>> predicate, int limit = 1000);
}
}
+36 -3
View File
@@ -13,11 +13,39 @@ namespace PolyTraderSharp.Services
public LogLevel Level { get; }
public DateTime Timestamp { get; }
public LogMessageEventArgs(string message, LogLevel level)
/// <summary>Korrelations-ID (z. B. SignalId) für die Log-Forensik; leer wenn ohne Kontext.</summary>
public string CorrelationId { get; }
public LogMessageEventArgs(string message, LogLevel level, string correlationId = "")
{
Message = message;
Level = level;
Timestamp = DateTime.Now;
CorrelationId = correlationId ?? "";
}
}
/// <summary>
/// Reiner JSONL-Formatter für Log-Events (S-0, Supervisor-Konzept): eine JSON-Zeile je Event
/// append-fähig, streambar, maschinen-/KI-lesbar. Statisch und seiteneffektfrei → unit-getestet.
/// </summary>
public static class LogJson
{
public static string Format(LogMessageEventArgs e)
{
var obj = new
{
ts = e.Timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"),
level = e.Level.ToString(),
cid = string.IsNullOrEmpty(e.CorrelationId) ? null : e.CorrelationId,
msg = e.Message
};
return System.Text.Json.JsonSerializer.Serialize(obj,
new System.Text.Json.JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
}
@@ -62,6 +90,11 @@ namespace PolyTraderSharp.Services
string logLine = $"[{e.Timestamp:HH:mm:ss}] {safeMsg}{Environment.NewLine}";
await File.AppendAllTextAsync(fullPath, logLine);
// S-0: zusätzlich JSONL (eine Datei je Tag, alle Level) maschinen-/KI-lesbar,
// Grundlage für den Log Viewer. Dual-Sink; Text-Sink später abschaltbar.
string jsonlPath = Path.Combine(_logsDirectory, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
await File.AppendAllTextAsync(jsonlPath, LogJson.Format(e) + Environment.NewLine);
}
catch
{
@@ -70,9 +103,9 @@ namespace PolyTraderSharp.Services
}
}
public void Log(string message, LogLevel level = LogLevel.Info)
public void Log(string message, LogLevel level = LogLevel.Info, string correlationId = "")
{
var e = new LogMessageEventArgs(message, level);
var e = new LogMessageEventArgs(message, level, correlationId);
lock (_lock)
{
_history.Add(e);