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
+29 -22
View File
@@ -25,6 +25,8 @@ namespace PolyTraderSharp.Ui
btn_settings = new ToolStripButton();
btn_terminal = new ToolStripButton();
btn_jobs = new ToolStripButton();
btn_accounting = new ToolStripButton();
toolStripSeparator1 = new ToolStripSeparator();
toolstrip_quickbar = new ToolStrip();
btn_liveTrading = new ToolStripButton();
btn_demoTrading = new ToolStripButton();
@@ -40,8 +42,6 @@ namespace PolyTraderSharp.Ui
colAccPnl3d = new DataGridViewTextBoxColumn();
colAccWin3d = new DataGridViewTextBoxColumn();
colAccOverall = new DataGridViewTextBoxColumn();
btn_accounting = new ToolStripButton();
toolStripSeparator1 = new ToolStripSeparator();
menuStrip.SuspendLayout();
toolstrip_windows.SuspendLayout();
toolstrip_quickbar.SuspendLayout();
@@ -122,6 +122,21 @@ namespace PolyTraderSharp.Ui
btn_jobs.Text = "Server Jobs";
btn_jobs.TextImageRelation = TextImageRelation.ImageAboveText;
//
// btn_accounting
//
btn_accounting.Image = Properties.Resources.coins_in_hand;
btn_accounting.ImageScaling = ToolStripItemImageScaling.None;
btn_accounting.ImageTransparentColor = Color.Magenta;
btn_accounting.Name = "btn_accounting";
btn_accounting.Size = new Size(106, 65);
btn_accounting.Text = "Accounting";
btn_accounting.TextImageRelation = TextImageRelation.ImageAboveText;
//
// toolStripSeparator1
//
toolStripSeparator1.Name = "toolStripSeparator1";
toolStripSeparator1.Size = new Size(6, 70);
//
// toolstrip_quickbar
//
toolstrip_quickbar.ImageScalingSize = new Size(24, 24);
@@ -179,10 +194,9 @@ namespace PolyTraderSharp.Ui
//
dgv_accountlist.AllowUserToAddRows = false;
dgv_accountlist.AllowUserToDeleteRows = false;
dgv_accountlist.AutoGenerateColumns = false;
dgv_accountlist.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.AllCells;
dgv_accountlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgv_accountlist.Columns.AddRange(new DataGridViewColumn[] {
colAccName, colAccModules, colAccPoly, colAccBalance, colAccPnl3d, colAccWin3d, colAccOverall });
dgv_accountlist.Columns.AddRange(new DataGridViewColumn[] { colAccName, colAccModules, colAccPoly, colAccBalance, colAccPnl3d, colAccWin3d, colAccOverall });
dgv_accountlist.Dock = DockStyle.Fill;
dgv_accountlist.Location = new Point(0, 139);
dgv_accountlist.Name = "dgv_accountlist";
@@ -192,11 +206,12 @@ namespace PolyTraderSharp.Ui
dgv_accountlist.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
dgv_accountlist.Size = new Size(2599, 498);
dgv_accountlist.TabIndex = 6;
//
//
// colAccName
//
colAccName.DataPropertyName = "Name";
colAccName.HeaderText = "Account";
colAccName.MinimumWidth = 8;
colAccName.Name = "colAccName";
colAccName.ReadOnly = true;
colAccName.Width = 200;
@@ -205,6 +220,7 @@ namespace PolyTraderSharp.Ui
//
colAccModules.DataPropertyName = "Modules";
colAccModules.HeaderText = "Module";
colAccModules.MinimumWidth = 8;
colAccModules.Name = "colAccModules";
colAccModules.ReadOnly = true;
colAccModules.Width = 220;
@@ -212,15 +228,18 @@ namespace PolyTraderSharp.Ui
// colAccPoly
//
colAccPoly.HeaderText = "Polymarket";
colAccPoly.MinimumWidth = 8;
colAccPoly.Name = "colAccPoly";
colAccPoly.ReadOnly = true;
colAccPoly.Text = "Öffnen";
colAccPoly.UseColumnTextForButtonValue = true;
colAccPoly.Width = 100;
colAccPoly.Width = 106;
//
// colAccBalance
//
colAccBalance.DataPropertyName = "Balance";
colAccBalance.HeaderText = "Wallet (USDC)";
colAccBalance.MinimumWidth = 8;
colAccBalance.Name = "colAccBalance";
colAccBalance.ReadOnly = true;
colAccBalance.Width = 130;
@@ -229,6 +248,7 @@ namespace PolyTraderSharp.Ui
//
colAccPnl3d.DataPropertyName = "Pnl3d";
colAccPnl3d.HeaderText = "3T PnL";
colAccPnl3d.MinimumWidth = 8;
colAccPnl3d.Name = "colAccPnl3d";
colAccPnl3d.ReadOnly = true;
colAccPnl3d.Width = 110;
@@ -237,6 +257,7 @@ namespace PolyTraderSharp.Ui
//
colAccWin3d.DataPropertyName = "WinRate3d";
colAccWin3d.HeaderText = "3T Winrate %";
colAccWin3d.MinimumWidth = 8;
colAccWin3d.Name = "colAccWin3d";
colAccWin3d.ReadOnly = true;
colAccWin3d.Width = 110;
@@ -245,24 +266,10 @@ namespace PolyTraderSharp.Ui
//
colAccOverall.DataPropertyName = "OverallPnl";
colAccOverall.HeaderText = "Overall P/L";
colAccOverall.MinimumWidth = 8;
colAccOverall.Name = "colAccOverall";
colAccOverall.ReadOnly = true;
colAccOverall.Width = 130;
//
// btn_accounting
//
btn_accounting.Image = Properties.Resources.coins_in_hand;
btn_accounting.ImageScaling = ToolStripItemImageScaling.None;
btn_accounting.ImageTransparentColor = Color.Magenta;
btn_accounting.Name = "btn_accounting";
btn_accounting.Size = new Size(106, 65);
btn_accounting.Text = "Accounting";
btn_accounting.TextImageRelation = TextImageRelation.ImageAboveText;
//
// toolStripSeparator1
//
toolStripSeparator1.Name = "toolStripSeparator1";
toolStripSeparator1.Size = new Size(6, 70);
//
// LauncherForm
//
@@ -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);
@@ -76,6 +76,9 @@ namespace PolyTraderSharp
public int AccountId { get; set; }
public string TokenId { get; set; } = string.Empty;
public int SourceTraderId { get; set; }
/// <summary>Korrelation zum auslösenden Signal (leer bei Profit-Target-Exits).</summary>
public string SignalId { get; set; } = string.Empty;
public string MarketQuestion { get; set; } = string.Empty;
public decimal ReferencePrice { get; set; } // Master-Exit-Preis
@@ -24,6 +24,9 @@ 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/System-Closes).</summary>
public string SignalId { get; set; } = string.Empty;
}
public class ClosedTradeRow : ClosedTrade
{
@@ -42,6 +42,7 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef
e.Property(x => x.RealizedPnl).HasPrecision(18, 6);
e.Property(x => x.PnlPercent).HasPrecision(18, 6);
e.Property(x => x.TotalFees).HasPrecision(18, 6);
e.Property(x => x.SignalId).HasMaxLength(40); // Korrelation zum Entscheidungsjournal
e.HasIndex(x => x.AccountId);
e.HasIndex(x => x.TokenId);
e.HasIndex(x => x.SourceTraderId);
@@ -1,12 +1,14 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using PolyTrader.Core.Configuration;
namespace PolyTrader.Modules.CopyTrading.Persistence.Ef
{
/// <summary>
/// Design-Time-Factory für EF-Tooling. Connection über Umgebungsvariable POLYTRADER_MYSQL
/// (keine Zugangsdaten im Code/Repo).
/// (keine Zugangsdaten im Code/Repo). Nutzt die fest gepinnte Server-Version (statt AutoDetect),
/// damit Migrations-Scaffolding OHNE DB-Verbindung funktioniert.
/// </summary>
public class CopyTradingDbContextFactory : IDesignTimeDbContextFactory<CopyTradingDbContext>
{
@@ -16,7 +18,7 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef
?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;";
var options = new DbContextOptionsBuilder<CopyTradingDbContext>()
.UseMySql(conn, ServerVersion.AutoDetect(conn))
.UseMySql(conn, DatabaseServerVersion.Value)
.Options;
return new CopyTradingDbContext(options);
@@ -0,0 +1,290 @@
// <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.Modules.CopyTrading.Persistence.Ef;
#nullable disable
namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations
{
[DbContext(typeof(CopyTradingDbContext))]
[Migration("20260716101729_AddClosedTradeSignalId")]
partial class AddClosedTradeSignalId
{
/// <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.ClosedTrade", b =>
{
b.Property<int>("TradeId")
.HasColumnType("int");
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>("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<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<int>("SourceTraderId")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<decimal>("TotalFees")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.HasKey("TradeId");
b.HasIndex("AccountId");
b.HasIndex("SourceTraderId");
b.HasIndex("TokenId");
b.ToTable("mod_copytrading_closed_trades", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.CopyTradingAccountSettings", b =>
{
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("MaxBuyPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MaxPriceDifference")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MaxSpreadPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MinSellRatioPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PerMarketLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PerMasterLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PreRedeemLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("ProfitTarget")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("SellFloorPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime24h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime6h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime72h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTimeNone")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.HasKey("AccountId");
b.ToTable("mod_copytrading_account_settings", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.MasterTraderHistoryRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ClosedAt");
b.HasIndex("TraderId");
b.ToTable("mod_copytrading_mt_history", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.TrackedTrader", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("AssignedAccountIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("AutoPauseEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("CopyAvgPnlPerTrade")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("CopyPnl30d")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("CopyProfitFactor")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("CopyTradeCount30d")
.HasColumnType("int");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsHidden")
.HasColumnType("tinyint(1)");
b.Property<bool>("MakerEntry")
.HasColumnType("tinyint(1)");
b.Property<string>("Reasoning")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<double>("TotalPnl")
.HasColumnType("double");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<string>("WalletAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("WinningTrades")
.HasColumnType("int");
b.Property<double>("Winrate30t")
.HasColumnType("double");
b.HasKey("Id");
b.ToTable("mod_copytrading_traders", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddClosedTradeSignalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SignalId",
table: "mod_copytrading_closed_trades",
type: "varchar(40)",
maxLength: 40,
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SignalId",
table: "mod_copytrading_closed_trades");
}
}
}
@@ -80,6 +80,11 @@ namespace PolyTrader.Modules.CopyTrading.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)");
@@ -26,6 +26,8 @@ namespace PolyTraderSharp.Services
private readonly IMarketRepository _marketRepo;
private readonly IAccountRepository _accountRepo;
private readonly SellLadderService _sellLadder;
private readonly IDecisionJournal _journal;
private readonly IOrderEventLog _orderEvents;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
@@ -40,7 +42,9 @@ namespace PolyTraderSharp.Services
IPositionRepository positionRepo,
IMarketRepository marketRepo,
IAccountRepository accountRepo,
SellLadderService sellLadder)
SellLadderService sellLadder,
IDecisionJournal journal,
IOrderEventLog orderEvents)
{
_state = state;
_copyState = copyState;
@@ -53,6 +57,39 @@ namespace PolyTraderSharp.Services
_marketRepo = marketRepo;
_accountRepo = accountRepo;
_sellLadder = sellLadder;
_journal = journal;
_orderEvents = orderEvents;
}
/// <summary>
/// S-0 (Supervisor): schreibt eine Entscheidung strukturiert ins Journal (core_decision_journal).
/// Ergänzt die bestehenden Freitext-Logs, ersetzt sie nicht. Darf den Trading-Pfad nie brechen
/// (Robustheit in der Journal-Implementierung).
/// </summary>
/// <summary>Kompakter Kontext als JSON (kulturinvariant kein deutsches Dezimalkomma).</summary>
private static string Ctx(object o) => System.Text.Json.JsonSerializer.Serialize(o);
private void Journal(CopySignal signal, AccountState? account, TradeDecision decision,
DecisionReason reason, string message, string contextJson = "")
{
_journal.Write(new DecisionRecord
{
SignalId = signal.SignalId,
ModuleName = "CopyTrading",
AccountId = account?.AccountId ?? 0,
IsDemo = account?.IsDemo ?? false,
SourceTraderId = signal.TraderId,
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Side = signal.Side,
SignalPrice = signal.Price,
MarketEndDate = signal.EndDate,
Decision = decision,
Reason = reason,
Message = message,
ContextJson = contextJson
});
}
public override async Task StartAsync(CancellationToken cancellationToken)
@@ -238,6 +275,8 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: {modeLabel}-Trading Modus ist 'Inactive'. Weitere Trades dieses Traders werden für 60s nicht geloggt.");
Journal(signal, account, TradeDecision.Skipped, DecisionReason.ModeInactive,
$"{modeLabel}-Trading 'Inactive' (Journal ratenbegrenzt wie das Log: 1/60s je Trader)");
}
return;
}
@@ -248,6 +287,7 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert.");
Journal(signal, account, TradeDecision.Skipped, DecisionReason.SellOnlyModeBuyBlocked, "SellOnly-Modus blockiert BUY");
return;
}
@@ -302,6 +342,7 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{shareType}] übersprungen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: SELL-Eskalationsleiter läuft (Exit aktiv) kein Zukauf während des Ausstiegs.");
Journal(signal, account, TradeDecision.Skipped, DecisionReason.ExitPendingBuySkip, "SELL-Leiter aktiv kein Zukauf (H3)");
return;
}
@@ -310,6 +351,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${settings.MaxBuyPrice:F3})");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.MaxBuyPriceExceeded,
$"Preis {signal.Price:F3} > MaxBuyPrice {settings.MaxBuyPrice:F3}",
Ctx(new { price = signal.Price, maxBuyPrice = settings.MaxBuyPrice }));
return;
}
@@ -345,6 +389,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({settings.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.PerMasterLimitReached,
$"PerMasterLimit {settings.PerMasterLimit:F1}% erreicht",
Ctx(new { investedInMaster, maxAllowedPerMaster, perMasterLimitPct = settings.PerMasterLimit }));
return;
}
@@ -368,6 +415,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.TimeWindowLimitReached,
$"TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht",
Ctx(new { bucket = timeframeLabel, investedInTimeframe, maxAllowedTimeframe, hoursLeft }));
return;
}
else
@@ -381,6 +431,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.MarketBudgetExhausted,
$"Markt-Budget erschöpft (Rest {maxAmountToBuy:F2} < MinTrade)",
Ctx(new { maxAllowed, investedInMarket, remaining = maxAmountToBuy, minTrade }));
return;
}
@@ -389,6 +442,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.InsufficientBalance,
"Verfügbares Guthaben reicht nicht",
Ctx(new { availableBalance = account.AvailableBalance, requested = maxAmountToBuy }));
return;
}
@@ -406,6 +462,9 @@ namespace PolyTraderSharp.Services
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.BelowPolymarketMinimum,
"Mathematisch unmöglicher Trade (Balance reicht für Minimum nicht)",
Ctx(new { shares = exact.shares, usdc = exact.usdc, availableBalance = account.AvailableBalance }));
return;
}
@@ -417,6 +476,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Unter Polymarket Minimum ({exact.shares:F1} Shares / ${exact.usdc:F2} USDC). Min: 5.5 Shares / $0.10.");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.BelowPolymarketMinimum,
$"Unter Polymarket-Minimum ({exact.shares:F1} Shares / {exact.usdc:F2} USDC)",
Ctx(new { shares = exact.shares, usdc = exact.usdc }));
return;
}
@@ -451,12 +513,17 @@ namespace PolyTraderSharp.Services
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position gehört Trader '{wrongTraderPos.SourceTraderName}' (ID {wrongTraderPos.SourceTraderId}), SELL kam aber von Trader ID {signal.TraderId}.");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.OwnershipMismatch,
$"Position gehört Trader {wrongTraderPos.SourceTraderId}, SELL kam von {signal.TraderId}",
Ctx(new { positionOwner = wrongTraderPos.SourceTraderId, signalTrader = signal.TraderId }));
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.PositionNotFound,
"Keine Position im Portfolio (möglicherweise zuvor gefiltert)");
}
return;
}
@@ -478,6 +545,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Teilverkauf ({sellRatio:P0} des Bestands). Master hält noch {masterShares:F1} Shares. Signal nur {signal.Size:F1} Shares. Schwelle: 30%.");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.PartialSellBelowThreshold,
$"Teilverkauf {sellRatio:P0} unter Schwelle",
Ctx(new { sellRatio, masterShares, signalSize = signal.Size }));
return;
}
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] FREIGEGEBEN:\n" +
@@ -492,6 +562,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Master hält laut Tracking 0 Shares, aber SELL Signal mit {signal.Size:F1} Shares erhalten. Inkonsistenz — ignoriert.");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.MasterPositionInconsistent,
"Master hält laut Tracking 0 Shares (Inkonsistenz)",
Ctx(new { signalSize = signal.Size }));
return;
}
}
@@ -504,6 +577,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kein Master-Position-Tracking verfügbar und Haltezeit erst {holdingMinutes:F1} Min. Warte auf ersten Sync.");
Journal(signal, account, TradeDecision.Skipped, DecisionReason.SyncGracePeriod,
$"Kein Tracking, Haltezeit {holdingMinutes:F1} Min < Schonfrist",
Ctx(new { holdingMinutes }));
return;
}
}
@@ -554,6 +630,9 @@ namespace PolyTraderSharp.Services
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
Journal(signal, account, TradeDecision.Executed, DecisionReason.DemoFilled,
$"Demo-BUY {exactShares:F2} Shares @ {orderPrice:F3}",
Ctx(new { shares = exactShares, usdc = exactUsdc, orderPrice }));
}
else
{
@@ -569,6 +648,22 @@ namespace PolyTraderSharp.Services
: FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, buyOrderType, _state.DebugOrderPayloadLog, isNegRisk, buyFeeBps);
// S-0: Order-Lifecycle als Daten (core_order_events).
_orderEvents.Write(new OrderEventRecord
{
SignalId = signal.SignalId,
ModuleName = "CopyTrading",
AccountId = account.AccountId,
TokenId = signal.TokenId,
EventType = result == "OK" ? OrderEventType.Placed : OrderEventType.PlaceFailed,
Side = "BUY",
Price = orderPrice,
AmountUsd = exactUsdc,
OrderType = buyOrderType,
Response = result,
DetailsJson = Ctx(new { feeBps = buyFeeBps, signalPrice = signal.Price })
});
if (result == "OK")
{
var pos = new Position
@@ -612,6 +707,16 @@ namespace PolyTraderSharp.Services
// The background sync will update with the real value within 30 seconds
string masterKey = $"{signal.TraderId}_{signal.TokenId}";
_copyState.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow));
Journal(signal, account, TradeDecision.Executed, DecisionReason.OrderPlaced,
$"Live-BUY {buyOrderType} {exactShares:F2} Shares @ {orderPrice:F3}",
Ctx(new { shares = exactShares, usdc = exactUsdc, orderPrice, orderType = buyOrderType }));
}
else
{
Journal(signal, account, TradeDecision.Failed, DecisionReason.OrderFailed,
$"Live-BUY fehlgeschlagen: {result}",
Ctx(new { usdc = exactUsdc, orderPrice, orderType = buyOrderType }));
}
}
}
@@ -625,6 +730,8 @@ namespace PolyTraderSharp.Services
// BUY blockiert einen legitimen schnellen SELL des Masters NICHT mehr.
if (pendingInfo.Side == "SELL" && (DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20)
{
Journal(signal, account, TradeDecision.Skipped, DecisionReason.SellSpamBlock,
"SELL <20s nach letztem SELL (Spam-Blockade)");
return; // Spam-Blockade: SELL wurde in den letzten 20 Sekunden bereits versendet
}
}
@@ -634,6 +741,7 @@ namespace PolyTraderSharp.Services
if (account.OpenPositions.TryGetValue(signal.TokenId, out var laddering) && laddering.ExitPending)
{
_logger.Info($"SELL [{signal.MarketQuestion}] ignoriert: Eskalationsleiter läuft bereits (Konto {account.Name}).");
Journal(signal, account, TradeDecision.Skipped, DecisionReason.LadderAlreadyActive, "Eskalationsleiter läuft bereits");
return;
}
@@ -653,6 +761,8 @@ namespace PolyTraderSharp.Services
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position gehört einem anderen Trader (Safety Check).");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.OwnershipMismatch,
"Position gehört einem anderen Trader (Defense-in-depth)");
}
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
@@ -712,7 +822,8 @@ namespace PolyTraderSharp.Services
TotalFees = totalFees,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
ExitReason = signal.Reason,
SignalId = signal.SignalId
};
_closedTradeWriter.TryWrite(ct);
@@ -720,6 +831,10 @@ namespace PolyTraderSharp.Services
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
Journal(signal, account, TradeDecision.Executed,
signal.TraderId == 0 ? DecisionReason.SystemResolutionClose : DecisionReason.DemoClosed,
$"Demo-Close {openPos.Size:F2} Shares, PnL {realizedPnl:F2}",
Ctx(new { size = openPos.Size, exitPrice = demoExitPrice, realizedPnl, totalFees }));
}
else
{
@@ -727,9 +842,14 @@ namespace PolyTraderSharp.Services
// SellLadderService gleiche Quelle wie der Profit-Target-Exit im Sync).
// openPos wurde oben entfernt; StartLadderAsync stellt es als ExitPending zurück.
bool isHf = trader != null && trader.Category == "HF";
await _sellLadder.StartLadderAsync(
bool ladderOk = await _sellLadder.StartLadderAsync(
account, openPos, signal.Price, signal.TraderId, isHf,
settings.MaxPriceDifference, settings.SellFloorPct, isNegRisk, "Master SELL");
settings.MaxPriceDifference, settings.SellFloorPct, isNegRisk, "Master SELL", signal.SignalId);
Journal(signal, account,
ladderOk ? TradeDecision.Executed : TradeDecision.Failed,
ladderOk ? DecisionReason.LadderStarted : DecisionReason.LadderStartFailed,
ladderOk ? $"SELL-Leiter gestartet (Referenz {signal.Price:F3})" : "SELL-Leiter-Start fehlgeschlagen/abgelehnt",
Ctx(new { referencePrice = signal.Price, size = openPos.Size, isHf }));
}
}
else
@@ -737,6 +857,8 @@ namespace PolyTraderSharp.Services
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
Journal(signal, account, TradeDecision.Rejected, DecisionReason.PositionNotFound,
"Position nicht im Portfolio gefunden");
}
}
}
@@ -94,7 +94,8 @@ namespace PolyTraderSharp.Services
PnlPercent = trade.PnlPercent,
OpenedAt = trade.OpenedAt,
ClosedAt = trade.ClosedAt,
ExitReason = trade.ExitReason
ExitReason = trade.ExitReason,
SignalId = trade.SignalId
});
_logger.Debug($"Saved ClosedTrade {trade.TradeId} to MySQL");
@@ -32,6 +32,7 @@ namespace PolyTraderSharp.Services
private readonly TerminalLogger _logger;
private readonly ThreemaService _threema;
private readonly IPositionRepository _positionRepo;
private readonly IOrderEventLog _orderEvents;
public SellLadderService(
CopyTradingState copyState,
@@ -39,7 +40,8 @@ namespace PolyTraderSharp.Services
IClobClient clob,
TerminalLogger logger,
ThreemaService threema,
IPositionRepository positionRepo)
IPositionRepository positionRepo,
IOrderEventLog orderEvents)
{
_copyState = copyState;
_state = state;
@@ -47,6 +49,26 @@ namespace PolyTraderSharp.Services
_logger = logger;
_threema = threema;
_positionRepo = positionRepo;
_orderEvents = orderEvents;
}
/// <summary>S-0: Order-Lifecycle-Event der Leiter strukturiert festhalten (core_order_events).</summary>
private void OrderEvent(ExitLadderState ladder, OrderEventType type, decimal price, decimal amountUsd, string response, string detailsJson = "")
{
_orderEvents.Write(new OrderEventRecord
{
SignalId = ladder.SignalId,
ModuleName = "CopyTrading",
AccountId = ladder.AccountId,
TokenId = ladder.TokenId,
EventType = type,
Side = "SELL",
Price = price,
AmountUsd = amountUsd,
OrderType = "GTC",
Response = response,
DetailsJson = detailsJson
});
}
/// <summary>
@@ -57,7 +79,8 @@ namespace PolyTraderSharp.Services
/// </summary>
public async Task<bool> StartLadderAsync(
AccountState account, Position pos, decimal referencePrice, int sourceTraderId,
bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag)
bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag,
string signalId = "")
{
string key = $"{account.AccountId}_{pos.TokenId}";
@@ -87,6 +110,7 @@ namespace PolyTraderSharp.Services
AccountId = account.AccountId,
TokenId = pos.TokenId,
SourceTraderId = sourceTraderId,
SignalId = signalId,
MarketQuestion = pos.MarketQuestion,
ReferencePrice = referencePrice,
CurrentLimit = firstLimit,
@@ -123,6 +147,9 @@ namespace PolyTraderSharp.Services
? FeeModel.ResolveBps(startMd.TakerFeeBps, startMd.Category)
: FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(account, pos.TokenId, "SELL", pos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk, feeBps);
OrderEvent(ladder, result == "OK" ? OrderEventType.LadderStart : OrderEventType.PlaceFailed,
firstLimit, pos.Size * firstLimit, result,
$"{{\"reasonTag\":\"{reasonTag}\",\"floor\":{floor.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}");
if (result == "OK")
{
@@ -187,6 +214,8 @@ namespace PolyTraderSharp.Services
pos.ExitPending = false;
if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos);
_logger.Warning($"🧹 [SELL-LEITER Dust] {account.Name} | {ladder.MarketQuestion}: Restgröße {pos.Size:F2} < Minimum {CopyTradingRisk.MinShares} Shares Leiter beendet, Rest gehalten.");
OrderEvent(ladder, OrderEventType.DustAbort, ladder.CurrentLimit, pos.Size * ladder.CurrentLimit,
"Dust", $"{{\"restShares\":{pos.Size.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}");
return;
}
@@ -221,6 +250,7 @@ namespace PolyTraderSharp.Services
try
{
var r = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", pos.Size * ladder.Floor, ladder.Floor, "GTC", _state.DebugOrderPayloadLog, isNegFloor, floorFeeBps);
OrderEvent(ladder, OrderEventType.FloorReplaced, ladder.Floor, pos.Size * ladder.Floor, r);
if (r == "OK")
_copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL");
else
@@ -259,6 +289,9 @@ namespace PolyTraderSharp.Services
decimal usdc = pos.Size * next;
var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk, stepFeeBps);
OrderEvent(ladder, result == "OK" ? OrderEventType.LadderStep : OrderEventType.PlaceFailed,
next, usdc, result,
$"{{\"attempt\":{ladder.Attempt + 1},\"floor\":{ladder.Floor.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}");
if (result == "OK")
{
@@ -0,0 +1,91 @@
using System;
using System.Text.Json;
using PolyTrader.Core.Persistence.Ef;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für das S-0-Datenfundament: JSONL-Log-Format (pur) und die
/// Entscheidungsjournal-/Order-Event-Repos (EF InMemory, inkl. Enum-als-String-Roundtrip).
/// </summary>
public class AnalysisJournalTests
{
// ----- LogJson (pur) -----
[Fact]
public void LogJson_produces_valid_single_line_json()
{
var e = new LogMessageEventArgs("Zeile1\nZeile2 mit \"Quotes\" und 0,95 €", LogLevel.Trade, "sig-123");
string line = LogJson.Format(e);
Assert.DoesNotContain('\n', line); // eine Zeile (JSONL)
using var doc = JsonDocument.Parse(line); // valides JSON
Assert.Equal("Trade", doc.RootElement.GetProperty("level").GetString());
Assert.Equal("sig-123", doc.RootElement.GetProperty("cid").GetString());
Assert.Contains("Zeile2", doc.RootElement.GetProperty("msg").GetString());
}
[Fact]
public void LogJson_omits_empty_correlation_id()
{
string line = LogJson.Format(new LogMessageEventArgs("msg", LogLevel.Info));
using var doc = JsonDocument.Parse(line);
Assert.False(doc.RootElement.TryGetProperty("cid", out _));
}
// ----- Repos (EF InMemory) -----
private static InMemoryContextFactory<CoreDbContext> Factory() => new(o => new CoreDbContext(o));
[Fact]
public void DecisionJournal_write_and_query_roundtrip()
{
var journal = new EfDecisionJournal(Factory());
journal.Write(new DecisionRecord
{
SignalId = "sig-1", ModuleName = "CopyTrading", AccountId = 1, TokenId = "tok",
Side = "BUY", Decision = TradeDecision.Rejected, Reason = DecisionReason.MaxBuyPriceExceeded,
SignalPrice = 0.97m, Message = "test"
});
journal.Write(new DecisionRecord
{
SignalId = "sig-2", AccountId = 1, TokenId = "tok",
Decision = TradeDecision.Executed, Reason = DecisionReason.OrderPlaced
});
var rejects = journal.Query(d => d.Reason == DecisionReason.MaxBuyPriceExceeded);
Assert.Single(rejects);
Assert.Equal("sig-1", rejects[0].SignalId);
Assert.Equal(TradeDecision.Rejected, rejects[0].Decision); // Enum-Roundtrip
Assert.Equal(2, journal.Query(d => d.AccountId == 1).Count);
}
[Fact]
public void OrderEventLog_write_and_query_roundtrip()
{
var log = new EfOrderEventLog(Factory());
log.Write(new OrderEventRecord
{
SignalId = "sig-1", AccountId = 1, TokenId = "tok",
EventType = OrderEventType.LadderStep, Side = "SELL", Price = 0.42m, Response = "OK"
});
var events = log.Query(e => e.SignalId == "sig-1");
Assert.Single(events);
Assert.Equal(OrderEventType.LadderStep, events[0].EventType);
}
[Fact]
public void CopySignal_gets_signal_id_automatically()
{
var a = new CopySignal();
var b = new CopySignal();
Assert.False(string.IsNullOrEmpty(a.SignalId));
Assert.NotEqual(a.SignalId, b.SignalId);
}
}
}
@@ -27,6 +27,7 @@ namespace PolyTrader.Tests
public CopyTradingState Copy = null!;
public FakeClobClient Clob = null!;
public ChannelReader<ClosedTrade> ClosedReader = null!;
public FakeDecisionJournal Journal = null!;
}
private static Harness Build()
@@ -40,18 +41,20 @@ namespace PolyTrader.Tests
var marketRepo = new FakeMarketRepository();
var accountRepo = new FakeAccountRepository();
var threema = new ThreemaService(logger, new JobManager());
var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo);
var journal = new FakeDecisionJournal();
var orderEvents = new FakeOrderEventLog();
var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo, orderEvents);
var signalCh = Channel.CreateUnbounded<CopySignal>();
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
var engine = new CopyTradingEngine(state, copy, signalCh.Reader, closedCh.Writer, logger,
clob, api, posRepo, marketRepo, accountRepo, ladder);
clob, api, posRepo, marketRepo, accountRepo, ladder, journal, orderEvents);
// MarketData cachen, damit der API-Pfad (Cache-Miss) nie läuft.
state.MarketCache[Tok] = new MarketData { Slug = "slug", Question = "Frage?", Category = "Politics", TakerFeeBps = 0, NegRisk = false };
return new Harness { Engine = engine, State = state, Copy = copy, Clob = clob, ClosedReader = closedCh.Reader };
return new Harness { Engine = engine, State = state, Copy = copy, Clob = clob, ClosedReader = closedCh.Reader, Journal = journal };
}
private static AccountState Account(bool demo) => new()
@@ -88,6 +91,9 @@ namespace PolyTrader.Tests
Assert.Empty(h.Clob.Placed); // kein Zukauf während des Ausstiegs
Assert.True(acc.OpenPositions[Tok].ExitPending);
// S-0: Entscheidung strukturiert im Journal (nicht nur Freitext-Log).
Assert.Contains(h.Journal.Written, d =>
d.Decision == TradeDecision.Skipped && d.Reason == DecisionReason.ExitPendingBuySkip && d.TokenId == Tok);
}
// ---------- Doppel-SELL-Guard ----------
@@ -120,6 +126,9 @@ namespace PolyTrader.Tests
Assert.False(acc.OpenPositions.ContainsKey(Tok)); // Demo-Position geschlossen
Assert.True(h.ClosedReader.TryRead(out var ct)); // ClosedTrade geschrieben
Assert.Equal(Tok, ct!.TokenId);
Assert.False(string.IsNullOrEmpty(ct.SignalId)); // S-0: Korrelation gesetzt
Assert.Contains(h.Journal.Written, d =>
d.Decision == TradeDecision.Executed && d.Reason == DecisionReason.SystemResolutionClose);
}
[Fact]
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using PolyTrader.Core.Persistence;
using PolyTraderSharp.Models;
namespace PolyTrader.Tests.Fakes
{
/// <summary>In-Memory-Stub des Entscheidungsjournals Tests können Journal-Einträge asserten.</summary>
public sealed class FakeDecisionJournal : IDecisionJournal
{
public List<DecisionRecord> Written { get; } = new();
public void Write(DecisionRecord record) => Written.Add(record);
public List<DecisionRecord> Query(Expression<Func<DecisionRecord, bool>> predicate, int limit = 1000)
=> Written.Where(predicate.Compile()).Take(limit).ToList();
}
/// <summary>In-Memory-Stub des Order-Lifecycle-Logs.</summary>
public sealed class FakeOrderEventLog : IOrderEventLog
{
public List<OrderEventRecord> Written { get; } = new();
public void Write(OrderEventRecord record) => Written.Add(record);
public List<OrderEventRecord> Query(Expression<Func<OrderEventRecord, bool>> predicate, int limit = 1000)
=> Written.Where(predicate.Compile()).Take(limit).ToList();
}
}
@@ -27,7 +27,7 @@ namespace PolyTrader.Tests
var logger = new TerminalLogger();
var threema = new ThreemaService(logger, new JobManager());
var repo = new FakePositionRepository();
var svc = new SellLadderService(copy, state, clob, logger, threema, repo);
var svc = new SellLadderService(copy, state, clob, logger, threema, repo, new FakeOrderEventLog());
return (svc, copy, state, clob, repo);
}
@@ -30,7 +30,7 @@ namespace PolyTrader.Tests
var marketRepo = new FakeMarketRepository();
var tradeLog = new FakeCopyTradeLogRepository();
var threema = new ThreemaService(logger, new JobManager());
var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo);
var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo, new FakeOrderEventLog());
var signalCh = Channel.CreateUnbounded<CopySignal>();
var closedCh = Channel.CreateUnbounded<ClosedTrade>();