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_settings = new ToolStripButton();
btn_terminal = new ToolStripButton(); btn_terminal = new ToolStripButton();
btn_jobs = new ToolStripButton(); btn_jobs = new ToolStripButton();
btn_accounting = new ToolStripButton();
toolStripSeparator1 = new ToolStripSeparator();
toolstrip_quickbar = new ToolStrip(); toolstrip_quickbar = new ToolStrip();
btn_liveTrading = new ToolStripButton(); btn_liveTrading = new ToolStripButton();
btn_demoTrading = new ToolStripButton(); btn_demoTrading = new ToolStripButton();
@@ -40,8 +42,6 @@ namespace PolyTraderSharp.Ui
colAccPnl3d = new DataGridViewTextBoxColumn(); colAccPnl3d = new DataGridViewTextBoxColumn();
colAccWin3d = new DataGridViewTextBoxColumn(); colAccWin3d = new DataGridViewTextBoxColumn();
colAccOverall = new DataGridViewTextBoxColumn(); colAccOverall = new DataGridViewTextBoxColumn();
btn_accounting = new ToolStripButton();
toolStripSeparator1 = new ToolStripSeparator();
menuStrip.SuspendLayout(); menuStrip.SuspendLayout();
toolstrip_windows.SuspendLayout(); toolstrip_windows.SuspendLayout();
toolstrip_quickbar.SuspendLayout(); toolstrip_quickbar.SuspendLayout();
@@ -122,6 +122,21 @@ namespace PolyTraderSharp.Ui
btn_jobs.Text = "Server Jobs"; btn_jobs.Text = "Server Jobs";
btn_jobs.TextImageRelation = TextImageRelation.ImageAboveText; 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
// //
toolstrip_quickbar.ImageScalingSize = new Size(24, 24); toolstrip_quickbar.ImageScalingSize = new Size(24, 24);
@@ -179,10 +194,9 @@ namespace PolyTraderSharp.Ui
// //
dgv_accountlist.AllowUserToAddRows = false; dgv_accountlist.AllowUserToAddRows = false;
dgv_accountlist.AllowUserToDeleteRows = false; dgv_accountlist.AllowUserToDeleteRows = false;
dgv_accountlist.AutoGenerateColumns = false; dgv_accountlist.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.AllCells;
dgv_accountlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; dgv_accountlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgv_accountlist.Columns.AddRange(new DataGridViewColumn[] { dgv_accountlist.Columns.AddRange(new DataGridViewColumn[] { colAccName, colAccModules, colAccPoly, colAccBalance, colAccPnl3d, colAccWin3d, colAccOverall });
colAccName, colAccModules, colAccPoly, colAccBalance, colAccPnl3d, colAccWin3d, colAccOverall });
dgv_accountlist.Dock = DockStyle.Fill; dgv_accountlist.Dock = DockStyle.Fill;
dgv_accountlist.Location = new Point(0, 139); dgv_accountlist.Location = new Point(0, 139);
dgv_accountlist.Name = "dgv_accountlist"; dgv_accountlist.Name = "dgv_accountlist";
@@ -192,11 +206,12 @@ namespace PolyTraderSharp.Ui
dgv_accountlist.SelectionMode = DataGridViewSelectionMode.FullRowSelect; dgv_accountlist.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
dgv_accountlist.Size = new Size(2599, 498); dgv_accountlist.Size = new Size(2599, 498);
dgv_accountlist.TabIndex = 6; dgv_accountlist.TabIndex = 6;
// //
// colAccName // colAccName
// //
colAccName.DataPropertyName = "Name"; colAccName.DataPropertyName = "Name";
colAccName.HeaderText = "Account"; colAccName.HeaderText = "Account";
colAccName.MinimumWidth = 8;
colAccName.Name = "colAccName"; colAccName.Name = "colAccName";
colAccName.ReadOnly = true; colAccName.ReadOnly = true;
colAccName.Width = 200; colAccName.Width = 200;
@@ -205,6 +220,7 @@ namespace PolyTraderSharp.Ui
// //
colAccModules.DataPropertyName = "Modules"; colAccModules.DataPropertyName = "Modules";
colAccModules.HeaderText = "Module"; colAccModules.HeaderText = "Module";
colAccModules.MinimumWidth = 8;
colAccModules.Name = "colAccModules"; colAccModules.Name = "colAccModules";
colAccModules.ReadOnly = true; colAccModules.ReadOnly = true;
colAccModules.Width = 220; colAccModules.Width = 220;
@@ -212,15 +228,18 @@ namespace PolyTraderSharp.Ui
// colAccPoly // colAccPoly
// //
colAccPoly.HeaderText = "Polymarket"; colAccPoly.HeaderText = "Polymarket";
colAccPoly.MinimumWidth = 8;
colAccPoly.Name = "colAccPoly"; colAccPoly.Name = "colAccPoly";
colAccPoly.ReadOnly = true;
colAccPoly.Text = "Öffnen"; colAccPoly.Text = "Öffnen";
colAccPoly.UseColumnTextForButtonValue = true; colAccPoly.UseColumnTextForButtonValue = true;
colAccPoly.Width = 100; colAccPoly.Width = 106;
// //
// colAccBalance // colAccBalance
// //
colAccBalance.DataPropertyName = "Balance"; colAccBalance.DataPropertyName = "Balance";
colAccBalance.HeaderText = "Wallet (USDC)"; colAccBalance.HeaderText = "Wallet (USDC)";
colAccBalance.MinimumWidth = 8;
colAccBalance.Name = "colAccBalance"; colAccBalance.Name = "colAccBalance";
colAccBalance.ReadOnly = true; colAccBalance.ReadOnly = true;
colAccBalance.Width = 130; colAccBalance.Width = 130;
@@ -229,6 +248,7 @@ namespace PolyTraderSharp.Ui
// //
colAccPnl3d.DataPropertyName = "Pnl3d"; colAccPnl3d.DataPropertyName = "Pnl3d";
colAccPnl3d.HeaderText = "3T PnL"; colAccPnl3d.HeaderText = "3T PnL";
colAccPnl3d.MinimumWidth = 8;
colAccPnl3d.Name = "colAccPnl3d"; colAccPnl3d.Name = "colAccPnl3d";
colAccPnl3d.ReadOnly = true; colAccPnl3d.ReadOnly = true;
colAccPnl3d.Width = 110; colAccPnl3d.Width = 110;
@@ -237,6 +257,7 @@ namespace PolyTraderSharp.Ui
// //
colAccWin3d.DataPropertyName = "WinRate3d"; colAccWin3d.DataPropertyName = "WinRate3d";
colAccWin3d.HeaderText = "3T Winrate %"; colAccWin3d.HeaderText = "3T Winrate %";
colAccWin3d.MinimumWidth = 8;
colAccWin3d.Name = "colAccWin3d"; colAccWin3d.Name = "colAccWin3d";
colAccWin3d.ReadOnly = true; colAccWin3d.ReadOnly = true;
colAccWin3d.Width = 110; colAccWin3d.Width = 110;
@@ -245,24 +266,10 @@ namespace PolyTraderSharp.Ui
// //
colAccOverall.DataPropertyName = "OverallPnl"; colAccOverall.DataPropertyName = "OverallPnl";
colAccOverall.HeaderText = "Overall P/L"; colAccOverall.HeaderText = "Overall P/L";
colAccOverall.MinimumWidth = 8;
colAccOverall.Name = "colAccOverall"; colAccOverall.Name = "colAccOverall";
colAccOverall.ReadOnly = true; colAccOverall.ReadOnly = true;
colAccOverall.Width = 130; 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 // LauncherForm
// //
@@ -24,6 +24,10 @@ namespace PolyTrader.Core.DependencyInjection
services.AddSingleton<IPositionRepository, EfPositionRepository>(); services.AddSingleton<IPositionRepository, EfPositionRepository>();
services.AddSingleton<ITradeLogRepository, EfTradeLogRepository>(); services.AddSingleton<ITradeLogRepository, EfTradeLogRepository>();
// S-0 (Supervisor-Konzept): Entscheidungsjournal + Order-Lifecycle-Events.
services.AddSingleton<IDecisionJournal, EfDecisionJournal>();
services.AddSingleton<IOrderEventLog, EfOrderEventLog>();
return services; return services;
} }
} }
+7
View File
@@ -2,6 +2,13 @@ namespace PolyTraderSharp.Models
{ {
public class CopySignal 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 SourceTradeId { get; set; }
public int TraderId { get; set; } public int TraderId { get; set; }
public string MarketSlug { get; set; } = string.Empty; 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 OpenedAt { get; set; }
public DateTime ClosedAt { get; set; } public DateTime ClosedAt { get; set; }
public string ExitReason { get; set; } = string.Empty; 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<Position> Positions => Set<Position>();
public DbSet<MarketData> Markets => Set<MarketData>(); public DbSet<MarketData> Markets => Set<MarketData>();
public DbSet<TradeRecord> TradeLog => Set<TradeRecord>(); public DbSet<TradeRecord> TradeLog => Set<TradeRecord>();
public DbSet<DecisionRecord> DecisionJournal => Set<DecisionRecord>();
public DbSet<OrderEventRecord> OrderEvents => Set<OrderEventRecord>();
protected override void OnModelCreating(ModelBuilder b) protected override void OnModelCreating(ModelBuilder b)
{ {
@@ -93,6 +95,52 @@ namespace PolyTrader.Core.Persistence.Ef
e.HasIndex(x => x.ClosedAt); e.HasIndex(x => x.ClosedAt);
e.HasIndex(x => x.ModuleName); e.HasIndex(x => x.ModuleName);
e.HasIndex(x => x.AccountId); 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); 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 => modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -145,6 +235,79 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_markets", (string)null); 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 => modelBuilder.Entity("PolyTraderSharp.Models.Position", b =>
{ {
b.Property<int>("AccountId") b.Property<int>("AccountId")
@@ -285,6 +448,11 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
.HasMaxLength(10) .HasMaxLength(10)
.HasColumnType("varchar(10)"); .HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("Size") b.Property<decimal>("Size")
.HasPrecision(18, 6) .HasPrecision(18, 6)
.HasColumnType("decimal(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 LogLevel Level { get; }
public DateTime Timestamp { 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; Message = message;
Level = level; Level = level;
Timestamp = DateTime.Now; 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}"; string logLine = $"[{e.Timestamp:HH:mm:ss}] {safeMsg}{Environment.NewLine}";
await File.AppendAllTextAsync(fullPath, logLine); 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 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) lock (_lock)
{ {
_history.Add(e); _history.Add(e);
@@ -76,6 +76,9 @@ namespace PolyTraderSharp
public int AccountId { get; set; } public int AccountId { get; set; }
public string TokenId { get; set; } = string.Empty; public string TokenId { get; set; } = string.Empty;
public int SourceTraderId { get; set; } 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 string MarketQuestion { get; set; } = string.Empty;
public decimal ReferencePrice { get; set; } // Master-Exit-Preis public decimal ReferencePrice { get; set; } // Master-Exit-Preis
@@ -24,6 +24,9 @@ namespace PolyTraderSharp.Models
public DateTime OpenedAt { get; set; } public DateTime OpenedAt { get; set; }
public DateTime ClosedAt { get; set; } public DateTime ClosedAt { get; set; }
public string ExitReason { get; set; } = string.Empty; 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 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.RealizedPnl).HasPrecision(18, 6);
e.Property(x => x.PnlPercent).HasPrecision(18, 6); e.Property(x => x.PnlPercent).HasPrecision(18, 6);
e.Property(x => x.TotalFees).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.AccountId);
e.HasIndex(x => x.TokenId); e.HasIndex(x => x.TokenId);
e.HasIndex(x => x.SourceTraderId); e.HasIndex(x => x.SourceTraderId);
@@ -1,12 +1,14 @@
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using PolyTrader.Core.Configuration;
namespace PolyTrader.Modules.CopyTrading.Persistence.Ef namespace PolyTrader.Modules.CopyTrading.Persistence.Ef
{ {
/// <summary> /// <summary>
/// Design-Time-Factory für EF-Tooling. Connection über Umgebungsvariable POLYTRADER_MYSQL /// 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> /// </summary>
public class CopyTradingDbContextFactory : IDesignTimeDbContextFactory<CopyTradingDbContext> 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=;"; ?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;";
var options = new DbContextOptionsBuilder<CopyTradingDbContext>() var options = new DbContextOptionsBuilder<CopyTradingDbContext>()
.UseMySql(conn, ServerVersion.AutoDetect(conn)) .UseMySql(conn, DatabaseServerVersion.Value)
.Options; .Options;
return new CopyTradingDbContext(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) .HasMaxLength(10)
.HasColumnType("varchar(10)"); .HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("Size") b.Property<decimal>("Size")
.HasPrecision(18, 6) .HasPrecision(18, 6)
.HasColumnType("decimal(18,6)"); .HasColumnType("decimal(18,6)");
@@ -26,6 +26,8 @@ namespace PolyTraderSharp.Services
private readonly IMarketRepository _marketRepo; private readonly IMarketRepository _marketRepo;
private readonly IAccountRepository _accountRepo; private readonly IAccountRepository _accountRepo;
private readonly SellLadderService _sellLadder; private readonly SellLadderService _sellLadder;
private readonly IDecisionJournal _journal;
private readonly IOrderEventLog _orderEvents;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new(); private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new(); private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
@@ -40,7 +42,9 @@ namespace PolyTraderSharp.Services
IPositionRepository positionRepo, IPositionRepository positionRepo,
IMarketRepository marketRepo, IMarketRepository marketRepo,
IAccountRepository accountRepo, IAccountRepository accountRepo,
SellLadderService sellLadder) SellLadderService sellLadder,
IDecisionJournal journal,
IOrderEventLog orderEvents)
{ {
_state = state; _state = state;
_copyState = copyState; _copyState = copyState;
@@ -53,6 +57,39 @@ namespace PolyTraderSharp.Services
_marketRepo = marketRepo; _marketRepo = marketRepo;
_accountRepo = accountRepo; _accountRepo = accountRepo;
_sellLadder = sellLadder; _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) 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" + _logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: {modeLabel}-Trading Modus ist 'Inactive'. Weitere Trades dieses Traders werden für 60s nicht geloggt."); $" 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; return;
} }
@@ -248,6 +287,7 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" + _logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert."); $" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert.");
Journal(signal, account, TradeDecision.Skipped, DecisionReason.SellOnlyModeBuyBlocked, "SellOnly-Modus blockiert BUY");
return; return;
} }
@@ -302,6 +342,7 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{shareType}] übersprungen:\n" + _logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{shareType}] übersprungen:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: SELL-Eskalationsleiter läuft (Exit aktiv) kein Zukauf während des Ausstiegs."); $" 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; return;
} }
@@ -310,6 +351,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" + _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${settings.MaxBuyPrice:F3})"); $" 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; return;
} }
@@ -345,6 +389,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({settings.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%)."); $" 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; return;
} }
@@ -368,6 +415,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}"); $" 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; return;
} }
else else
@@ -381,6 +431,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})"); $" 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; return;
} }
@@ -389,6 +442,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})"); $" 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; return;
} }
@@ -406,6 +462,9 @@ namespace PolyTraderSharp.Services
{ {
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" + _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."); $" 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; return;
} }
@@ -417,6 +476,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" + _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Unter Polymarket Minimum ({exact.shares:F1} Shares / ${exact.usdc:F2} USDC). Min: 5.5 Shares / $0.10."); $" 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; return;
} }
@@ -451,12 +513,17 @@ namespace PolyTraderSharp.Services
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Position gehört Trader '{wrongTraderPos.SourceTraderName}' (ID {wrongTraderPos.SourceTraderId}), SELL kam aber von Trader ID {signal.TraderId}."); $" 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 else
{ {
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert)."); $" 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; return;
} }
@@ -478,6 +545,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\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%."); $" 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; return;
} }
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] FREIGEGEBEN:\n" + _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" + _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\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."); $" 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; return;
} }
} }
@@ -504,6 +577,9 @@ namespace PolyTraderSharp.Services
_logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + _logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Kein Master-Position-Tracking verfügbar und Haltezeit erst {holdingMinutes:F1} Min. Warte auf ersten Sync."); $" 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; return;
} }
} }
@@ -554,6 +630,9 @@ namespace PolyTraderSharp.Services
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" + $" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})"); $" 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 else
{ {
@@ -569,6 +648,22 @@ namespace PolyTraderSharp.Services
: FeeModel.FallbackBps(null); : FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, buyOrderType, _state.DebugOrderPayloadLog, isNegRisk, buyFeeBps); 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") if (result == "OK")
{ {
var pos = new Position var pos = new Position
@@ -612,6 +707,16 @@ namespace PolyTraderSharp.Services
// The background sync will update with the real value within 30 seconds // The background sync will update with the real value within 30 seconds
string masterKey = $"{signal.TraderId}_{signal.TokenId}"; string masterKey = $"{signal.TraderId}_{signal.TokenId}";
_copyState.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow)); _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. // BUY blockiert einen legitimen schnellen SELL des Masters NICHT mehr.
if (pendingInfo.Side == "SELL" && (DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20) 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 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) if (account.OpenPositions.TryGetValue(signal.TokenId, out var laddering) && laddering.ExitPending)
{ {
_logger.Info($"SELL [{signal.MarketQuestion}] ignoriert: Eskalationsleiter läuft bereits (Konto {account.Name})."); _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; return;
} }
@@ -653,6 +761,8 @@ namespace PolyTraderSharp.Services
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Position gehört einem anderen Trader (Safety Check)."); $" 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)) if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
@@ -712,7 +822,8 @@ namespace PolyTraderSharp.Services
TotalFees = totalFees, TotalFees = totalFees,
OpenedAt = openPos.OpenedAt, OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow, ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason ExitReason = signal.Reason,
SignalId = signal.SignalId
}; };
_closedTradeWriter.TryWrite(ct); _closedTradeWriter.TryWrite(ct);
@@ -720,6 +831,10 @@ namespace PolyTraderSharp.Services
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" + $" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})"); $" 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 else
{ {
@@ -727,9 +842,14 @@ namespace PolyTraderSharp.Services
// SellLadderService gleiche Quelle wie der Profit-Target-Exit im Sync). // SellLadderService gleiche Quelle wie der Profit-Target-Exit im Sync).
// openPos wurde oben entfernt; StartLadderAsync stellt es als ExitPending zurück. // openPos wurde oben entfernt; StartLadderAsync stellt es als ExitPending zurück.
bool isHf = trader != null && trader.Category == "HF"; bool isHf = trader != null && trader.Category == "HF";
await _sellLadder.StartLadderAsync( bool ladderOk = await _sellLadder.StartLadderAsync(
account, openPos, signal.Price, signal.TraderId, isHf, 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 else
@@ -737,6 +857,8 @@ namespace PolyTraderSharp.Services
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" + $" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert)."); $" 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, PnlPercent = trade.PnlPercent,
OpenedAt = trade.OpenedAt, OpenedAt = trade.OpenedAt,
ClosedAt = trade.ClosedAt, ClosedAt = trade.ClosedAt,
ExitReason = trade.ExitReason ExitReason = trade.ExitReason,
SignalId = trade.SignalId
}); });
_logger.Debug($"Saved ClosedTrade {trade.TradeId} to MySQL"); _logger.Debug($"Saved ClosedTrade {trade.TradeId} to MySQL");
@@ -32,6 +32,7 @@ namespace PolyTraderSharp.Services
private readonly TerminalLogger _logger; private readonly TerminalLogger _logger;
private readonly ThreemaService _threema; private readonly ThreemaService _threema;
private readonly IPositionRepository _positionRepo; private readonly IPositionRepository _positionRepo;
private readonly IOrderEventLog _orderEvents;
public SellLadderService( public SellLadderService(
CopyTradingState copyState, CopyTradingState copyState,
@@ -39,7 +40,8 @@ namespace PolyTraderSharp.Services
IClobClient clob, IClobClient clob,
TerminalLogger logger, TerminalLogger logger,
ThreemaService threema, ThreemaService threema,
IPositionRepository positionRepo) IPositionRepository positionRepo,
IOrderEventLog orderEvents)
{ {
_copyState = copyState; _copyState = copyState;
_state = state; _state = state;
@@ -47,6 +49,26 @@ namespace PolyTraderSharp.Services
_logger = logger; _logger = logger;
_threema = threema; _threema = threema;
_positionRepo = positionRepo; _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> /// <summary>
@@ -57,7 +79,8 @@ namespace PolyTraderSharp.Services
/// </summary> /// </summary>
public async Task<bool> StartLadderAsync( public async Task<bool> StartLadderAsync(
AccountState account, Position pos, decimal referencePrice, int sourceTraderId, 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}"; string key = $"{account.AccountId}_{pos.TokenId}";
@@ -87,6 +110,7 @@ namespace PolyTraderSharp.Services
AccountId = account.AccountId, AccountId = account.AccountId,
TokenId = pos.TokenId, TokenId = pos.TokenId,
SourceTraderId = sourceTraderId, SourceTraderId = sourceTraderId,
SignalId = signalId,
MarketQuestion = pos.MarketQuestion, MarketQuestion = pos.MarketQuestion,
ReferencePrice = referencePrice, ReferencePrice = referencePrice,
CurrentLimit = firstLimit, CurrentLimit = firstLimit,
@@ -123,6 +147,9 @@ namespace PolyTraderSharp.Services
? FeeModel.ResolveBps(startMd.TakerFeeBps, startMd.Category) ? FeeModel.ResolveBps(startMd.TakerFeeBps, startMd.Category)
: FeeModel.FallbackBps(null); : FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(account, pos.TokenId, "SELL", pos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk, feeBps); 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") if (result == "OK")
{ {
@@ -187,6 +214,8 @@ namespace PolyTraderSharp.Services
pos.ExitPending = false; pos.ExitPending = false;
if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos); 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."); _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; return;
} }
@@ -221,6 +250,7 @@ namespace PolyTraderSharp.Services
try try
{ {
var r = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", pos.Size * ladder.Floor, ladder.Floor, "GTC", _state.DebugOrderPayloadLog, isNegFloor, floorFeeBps); 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") if (r == "OK")
_copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL"); _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL");
else else
@@ -259,6 +289,9 @@ namespace PolyTraderSharp.Services
decimal usdc = pos.Size * next; decimal usdc = pos.Size * next;
var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk, stepFeeBps); 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") 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 CopyTradingState Copy = null!;
public FakeClobClient Clob = null!; public FakeClobClient Clob = null!;
public ChannelReader<ClosedTrade> ClosedReader = null!; public ChannelReader<ClosedTrade> ClosedReader = null!;
public FakeDecisionJournal Journal = null!;
} }
private static Harness Build() private static Harness Build()
@@ -40,18 +41,20 @@ namespace PolyTrader.Tests
var marketRepo = new FakeMarketRepository(); var marketRepo = new FakeMarketRepository();
var accountRepo = new FakeAccountRepository(); var accountRepo = new FakeAccountRepository();
var threema = new ThreemaService(logger, new JobManager()); 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 signalCh = Channel.CreateUnbounded<CopySignal>();
var closedCh = Channel.CreateUnbounded<ClosedTrade>(); var closedCh = Channel.CreateUnbounded<ClosedTrade>();
var engine = new CopyTradingEngine(state, copy, signalCh.Reader, closedCh.Writer, logger, 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. // 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 }; 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() 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.Empty(h.Clob.Placed); // kein Zukauf während des Ausstiegs
Assert.True(acc.OpenPositions[Tok].ExitPending); 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 ---------- // ---------- Doppel-SELL-Guard ----------
@@ -120,6 +126,9 @@ namespace PolyTrader.Tests
Assert.False(acc.OpenPositions.ContainsKey(Tok)); // Demo-Position geschlossen Assert.False(acc.OpenPositions.ContainsKey(Tok)); // Demo-Position geschlossen
Assert.True(h.ClosedReader.TryRead(out var ct)); // ClosedTrade geschrieben Assert.True(h.ClosedReader.TryRead(out var ct)); // ClosedTrade geschrieben
Assert.Equal(Tok, ct!.TokenId); 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] [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 logger = new TerminalLogger();
var threema = new ThreemaService(logger, new JobManager()); var threema = new ThreemaService(logger, new JobManager());
var repo = new FakePositionRepository(); 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); return (svc, copy, state, clob, repo);
} }
@@ -30,7 +30,7 @@ namespace PolyTrader.Tests
var marketRepo = new FakeMarketRepository(); var marketRepo = new FakeMarketRepository();
var tradeLog = new FakeCopyTradeLogRepository(); var tradeLog = new FakeCopyTradeLogRepository();
var threema = new ThreemaService(logger, new JobManager()); 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 signalCh = Channel.CreateUnbounded<CopySignal>();
var closedCh = Channel.CreateUnbounded<ClosedTrade>(); var closedCh = Channel.CreateUnbounded<ClosedTrade>();