diff --git a/Ui/LauncherForm.Designer.cs b/Ui/LauncherForm.Designer.cs index d6252c6..8b75316 100644 --- a/Ui/LauncherForm.Designer.cs +++ b/Ui/LauncherForm.Designer.cs @@ -25,6 +25,8 @@ namespace PolyTraderSharp.Ui btn_settings = new ToolStripButton(); btn_terminal = new ToolStripButton(); btn_jobs = new ToolStripButton(); + btn_accounting = new ToolStripButton(); + toolStripSeparator1 = new ToolStripSeparator(); toolstrip_quickbar = new ToolStrip(); btn_liveTrading = new ToolStripButton(); btn_demoTrading = new ToolStripButton(); @@ -40,8 +42,6 @@ namespace PolyTraderSharp.Ui colAccPnl3d = new DataGridViewTextBoxColumn(); colAccWin3d = new DataGridViewTextBoxColumn(); colAccOverall = new DataGridViewTextBoxColumn(); - btn_accounting = new ToolStripButton(); - toolStripSeparator1 = new ToolStripSeparator(); menuStrip.SuspendLayout(); toolstrip_windows.SuspendLayout(); toolstrip_quickbar.SuspendLayout(); @@ -122,6 +122,21 @@ namespace PolyTraderSharp.Ui btn_jobs.Text = "Server Jobs"; btn_jobs.TextImageRelation = TextImageRelation.ImageAboveText; // + // btn_accounting + // + btn_accounting.Image = Properties.Resources.coins_in_hand; + btn_accounting.ImageScaling = ToolStripItemImageScaling.None; + btn_accounting.ImageTransparentColor = Color.Magenta; + btn_accounting.Name = "btn_accounting"; + btn_accounting.Size = new Size(106, 65); + btn_accounting.Text = "Accounting"; + btn_accounting.TextImageRelation = TextImageRelation.ImageAboveText; + // + // toolStripSeparator1 + // + toolStripSeparator1.Name = "toolStripSeparator1"; + toolStripSeparator1.Size = new Size(6, 70); + // // toolstrip_quickbar // toolstrip_quickbar.ImageScalingSize = new Size(24, 24); @@ -179,10 +194,9 @@ namespace PolyTraderSharp.Ui // dgv_accountlist.AllowUserToAddRows = false; dgv_accountlist.AllowUserToDeleteRows = false; - dgv_accountlist.AutoGenerateColumns = false; + dgv_accountlist.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.AllCells; dgv_accountlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; - dgv_accountlist.Columns.AddRange(new DataGridViewColumn[] { - colAccName, colAccModules, colAccPoly, colAccBalance, colAccPnl3d, colAccWin3d, colAccOverall }); + dgv_accountlist.Columns.AddRange(new DataGridViewColumn[] { colAccName, colAccModules, colAccPoly, colAccBalance, colAccPnl3d, colAccWin3d, colAccOverall }); dgv_accountlist.Dock = DockStyle.Fill; dgv_accountlist.Location = new Point(0, 139); dgv_accountlist.Name = "dgv_accountlist"; @@ -192,11 +206,12 @@ namespace PolyTraderSharp.Ui dgv_accountlist.SelectionMode = DataGridViewSelectionMode.FullRowSelect; dgv_accountlist.Size = new Size(2599, 498); dgv_accountlist.TabIndex = 6; - // + // // colAccName // colAccName.DataPropertyName = "Name"; colAccName.HeaderText = "Account"; + colAccName.MinimumWidth = 8; colAccName.Name = "colAccName"; colAccName.ReadOnly = true; colAccName.Width = 200; @@ -205,6 +220,7 @@ namespace PolyTraderSharp.Ui // colAccModules.DataPropertyName = "Modules"; colAccModules.HeaderText = "Module"; + colAccModules.MinimumWidth = 8; colAccModules.Name = "colAccModules"; colAccModules.ReadOnly = true; colAccModules.Width = 220; @@ -212,15 +228,18 @@ namespace PolyTraderSharp.Ui // colAccPoly // colAccPoly.HeaderText = "Polymarket"; + colAccPoly.MinimumWidth = 8; colAccPoly.Name = "colAccPoly"; + colAccPoly.ReadOnly = true; colAccPoly.Text = "Öffnen"; colAccPoly.UseColumnTextForButtonValue = true; - colAccPoly.Width = 100; + colAccPoly.Width = 106; // // colAccBalance // colAccBalance.DataPropertyName = "Balance"; colAccBalance.HeaderText = "Wallet (USDC)"; + colAccBalance.MinimumWidth = 8; colAccBalance.Name = "colAccBalance"; colAccBalance.ReadOnly = true; colAccBalance.Width = 130; @@ -229,6 +248,7 @@ namespace PolyTraderSharp.Ui // colAccPnl3d.DataPropertyName = "Pnl3d"; colAccPnl3d.HeaderText = "3T PnL"; + colAccPnl3d.MinimumWidth = 8; colAccPnl3d.Name = "colAccPnl3d"; colAccPnl3d.ReadOnly = true; colAccPnl3d.Width = 110; @@ -237,6 +257,7 @@ namespace PolyTraderSharp.Ui // colAccWin3d.DataPropertyName = "WinRate3d"; colAccWin3d.HeaderText = "3T Winrate %"; + colAccWin3d.MinimumWidth = 8; colAccWin3d.Name = "colAccWin3d"; colAccWin3d.ReadOnly = true; colAccWin3d.Width = 110; @@ -245,24 +266,10 @@ namespace PolyTraderSharp.Ui // colAccOverall.DataPropertyName = "OverallPnl"; colAccOverall.HeaderText = "Overall P/L"; + colAccOverall.MinimumWidth = 8; colAccOverall.Name = "colAccOverall"; colAccOverall.ReadOnly = true; colAccOverall.Width = 130; - // - // btn_accounting - // - btn_accounting.Image = Properties.Resources.coins_in_hand; - btn_accounting.ImageScaling = ToolStripItemImageScaling.None; - btn_accounting.ImageTransparentColor = Color.Magenta; - btn_accounting.Name = "btn_accounting"; - btn_accounting.Size = new Size(106, 65); - btn_accounting.Text = "Accounting"; - btn_accounting.TextImageRelation = TextImageRelation.ImageAboveText; - // - // toolStripSeparator1 - // - toolStripSeparator1.Name = "toolStripSeparator1"; - toolStripSeparator1.Size = new Size(6, 70); // // LauncherForm // diff --git a/src/PolyTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/PolyTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs index 40b99fd..b1d1fac 100644 --- a/src/PolyTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/PolyTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -24,6 +24,10 @@ namespace PolyTrader.Core.DependencyInjection services.AddSingleton(); services.AddSingleton(); + // S-0 (Supervisor-Konzept): Entscheidungsjournal + Order-Lifecycle-Events. + services.AddSingleton(); + services.AddSingleton(); + return services; } } diff --git a/src/PolyTrader.Core/Models/CopySignal.cs b/src/PolyTrader.Core/Models/CopySignal.cs index 2d64b6c..3817167 100644 --- a/src/PolyTrader.Core/Models/CopySignal.cs +++ b/src/PolyTrader.Core/Models/CopySignal.cs @@ -2,6 +2,13 @@ namespace PolyTraderSharp.Models { public class CopySignal { + /// + /// 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. + /// + public string SignalId { get; set; } = Guid.NewGuid().ToString("N"); + public int SourceTradeId { get; set; } public int TraderId { get; set; } public string MarketSlug { get; set; } = string.Empty; diff --git a/src/PolyTrader.Core/Models/DecisionRecord.cs b/src/PolyTrader.Core/Models/DecisionRecord.cs new file mode 100644 index 0000000..3540ae6 --- /dev/null +++ b/src/PolyTrader.Core/Models/DecisionRecord.cs @@ -0,0 +1,96 @@ +using System; + +namespace PolyTraderSharp.Models +{ + /// Ausgang einer Handelsentscheidung im Entscheidungsjournal. + 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) + } + + /// + /// 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. + /// + 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) + } + + /// + /// 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. + /// + public class DecisionRecord + { + public long Id { get; set; } // DB-Autoincrement + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// Korrelation: verbindet Signal → Entscheidungen → Orders → ClosedTrade. + 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; } + + /// Kompakte Kontext-Zahlen als JSON (Limitwerte, Budgets, berechnete Größen …). + public string ContextJson { get; set; } = string.Empty; + + /// Menschlicher Begründungstext (wie bisher im Log). + public string Message { get; set; } = string.Empty; + } +} diff --git a/src/PolyTrader.Core/Models/OrderEventRecord.cs b/src/PolyTrader.Core/Models/OrderEventRecord.cs new file mode 100644 index 0000000..f8ae359 --- /dev/null +++ b/src/PolyTrader.Core/Models/OrderEventRecord.cs @@ -0,0 +1,47 @@ +using System; + +namespace PolyTraderSharp.Models +{ + /// Art eines Order-Lifecycle-Ereignisses (als String persistiert – erweiterbar). + 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 + } + + /// + /// 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). + /// + 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 + + /// CLOB-Antwort ("OK" oder Fehlertext) bzw. Ergebnis der Aktion. + public string Response { get; set; } = string.Empty; + + /// Zusatzkontext als kompaktes JSON (z. B. Leiter-Stufe, Floor, Timeout-Grund). + public string DetailsJson { get; set; } = string.Empty; + } +} diff --git a/src/PolyTrader.Core/Models/TradeRecord.cs b/src/PolyTrader.Core/Models/TradeRecord.cs index b7549f0..90484f4 100644 --- a/src/PolyTrader.Core/Models/TradeRecord.cs +++ b/src/PolyTrader.Core/Models/TradeRecord.cs @@ -34,5 +34,8 @@ namespace PolyTraderSharp.Models public DateTime OpenedAt { get; set; } public DateTime ClosedAt { get; set; } public string ExitReason { get; set; } = string.Empty; + + /// Korrelation zum Entscheidungsjournal/Order-Events (leer bei Alt-Daten). + public string SignalId { get; set; } = string.Empty; } } diff --git a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs index 2e6a165..a0adc84 100644 --- a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs +++ b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs @@ -17,6 +17,8 @@ namespace PolyTrader.Core.Persistence.Ef public DbSet Positions => Set(); public DbSet Markets => Set(); public DbSet TradeLog => Set(); + public DbSet DecisionJournal => Set(); + public DbSet OrderEvents => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -93,6 +95,52 @@ namespace PolyTrader.Core.Persistence.Ef e.HasIndex(x => x.ClosedAt); e.HasIndex(x => x.ModuleName); e.HasIndex(x => x.AccountId); + e.Property(x => x.SignalId).HasMaxLength(40); // Korrelation zum Entscheidungsjournal + }); + + // Entscheidungsjournal (S-0, Supervisor-Konzept): jede Handelsentscheidung strukturiert. + b.Entity(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().HasMaxLength(20); + e.Property(x => x.Reason).HasConversion().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(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().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 }); }); } } diff --git a/src/PolyTrader.Core/Persistence/Ef/EfAnalysisJournals.cs b/src/PolyTrader.Core/Persistence/Ef/EfAnalysisJournals.cs new file mode 100644 index 0000000..2324363 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/EfAnalysisJournals.cs @@ -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 +{ + /// + /// 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. + /// + public class EfDecisionJournal : IDecisionJournal + { + private readonly IDbContextFactory _factory; + public EfDecisionJournal(IDbContextFactory 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 Query(Expression> predicate, int limit = 1000) + { + using var ctx = _factory.CreateDbContext(); + return ctx.DecisionJournal.AsNoTracking() + .Where(predicate) + .OrderByDescending(r => r.Timestamp) + .Take(limit) + .ToList(); + } + } + + /// EF-Implementierung des Order-Lifecycle-Logs (gleiche Robustheits-Garantie). + public class EfOrderEventLog : IOrderEventLog + { + private readonly IDbContextFactory _factory; + public EfOrderEventLog(IDbContextFactory 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 Query(Expression> predicate, int limit = 1000) + { + using var ctx = _factory.CreateDbContext(); + return ctx.OrderEvents.AsNoTracking() + .Where(predicate) + .OrderByDescending(r => r.Timestamp) + .Take(limit) + .ToList(); + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/20260716101647_AddDecisionJournalAndOrderEvents.Designer.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260716101647_AddDecisionJournalAndOrderEvents.Designer.cs new file mode 100644 index 0000000..da55f12 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260716101647_AddDecisionJournalAndOrderEvents.Designer.cs @@ -0,0 +1,481 @@ +// +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 + { + /// + 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("AccountId") + .HasColumnType("int"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ApiPassphrase") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("ApiSecret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("AvailableBalance") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CloseOnlyMode") + .HasColumnType("tinyint(1)"); + + b.Property("HasOpenLimitOrders") + .HasColumnType("tinyint(1)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PayoutAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("PayoutLimitUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PrivateKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("TotalBalance") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WalletAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("AccountId"); + + b.ToTable("core_accounts", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.DecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ContextJson") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketEndDate") + .HasColumnType("datetime(6)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("SignalPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("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("Id") + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ClobTokenIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Closed") + .HasColumnType("tinyint(1)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("NegRisk") + .HasColumnType("tinyint(1)"); + + b.Property("Outcomes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("TakerFeeBps") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("core_markets", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.OrderEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AmountUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("DetailsJson") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Price") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Response") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("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("AccountId") + .HasColumnType("int"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("TokenId") + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("AmountUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("CurrentPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CurrentValueUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitReason") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PnlPercent") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("RealizedPnl") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("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 + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/20260716101647_AddDecisionJournalAndOrderEvents.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260716101647_AddDecisionJournalAndOrderEvents.cs new file mode 100644 index 0000000..97047a3 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260716101647_AddDecisionJournalAndOrderEvents.cs @@ -0,0 +1,146 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Core.Persistence.Ef.Migrations +{ + /// + public partial class AddDecisionJournalAndOrderEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + SignalId = table.Column(type: "varchar(40)", maxLength: 40, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ModuleName = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + AccountId = table.Column(type: "int", nullable: false), + IsDemo = table.Column(type: "tinyint(1)", nullable: false), + SourceTraderId = table.Column(type: "int", nullable: false), + TokenId = table.Column(type: "varchar(120)", maxLength: 120, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MarketSlug = table.Column(type: "varchar(300)", maxLength: 300, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MarketQuestion = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SignalPrice = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + MarketEndDate = table.Column(type: "datetime(6)", nullable: true), + Decision = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Reason = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ContextJson = table.Column(type: "varchar(2000)", maxLength: 2000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Message = table.Column(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(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + SignalId = table.Column(type: "varchar(40)", maxLength: 40, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ModuleName = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + AccountId = table.Column(type: "int", nullable: false), + TokenId = table.Column(type: "varchar(120)", maxLength: 120, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + EventType = table.Column(type: "varchar(30)", maxLength: 30, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Price = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + AmountUsd = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + OrderType = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Response = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DetailsJson = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index d418737..0d96234 100644 --- a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -91,6 +91,96 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations b.ToTable("core_accounts", (string)null); }); + modelBuilder.Entity("PolyTraderSharp.Models.DecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ContextJson") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketEndDate") + .HasColumnType("datetime(6)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("SignalPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("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("Id") @@ -145,6 +235,79 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations b.ToTable("core_markets", (string)null); }); + modelBuilder.Entity("PolyTraderSharp.Models.OrderEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AmountUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("DetailsJson") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Price") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Response") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("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("AccountId") @@ -285,6 +448,11 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations .HasMaxLength(10) .HasColumnType("varchar(10)"); + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + b.Property("Size") .HasPrecision(18, 6) .HasColumnType("decimal(18,6)"); diff --git a/src/PolyTrader.Core/Persistence/IAnalysisJournals.cs b/src/PolyTrader.Core/Persistence/IAnalysisJournals.cs new file mode 100644 index 0000000..49c2f69 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/IAnalysisJournals.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using PolyTraderSharp.Models; + +namespace PolyTrader.Core.Persistence +{ + /// + /// Entscheidungsjournal (core_decision_journal). Write darf den Trading-Pfad NIEMALS brechen – + /// Implementierungen fangen Persistenzfehler ab (Log statt Exception). + /// + public interface IDecisionJournal + { + void Write(DecisionRecord record); + List Query(Expression> predicate, int limit = 1000); + } + + /// Order-Lifecycle-Log (core_order_events). Gleiche Robustheits-Garantie wie das Journal. + public interface IOrderEventLog + { + void Write(OrderEventRecord record); + List Query(Expression> predicate, int limit = 1000); + } +} diff --git a/src/PolyTrader.Core/Services/TerminalLogger.cs b/src/PolyTrader.Core/Services/TerminalLogger.cs index 46cab15..a7fbed8 100644 --- a/src/PolyTrader.Core/Services/TerminalLogger.cs +++ b/src/PolyTrader.Core/Services/TerminalLogger.cs @@ -13,11 +13,39 @@ namespace PolyTraderSharp.Services public LogLevel Level { get; } public DateTime Timestamp { get; } - public LogMessageEventArgs(string message, LogLevel level) + /// Korrelations-ID (z. B. SignalId) für die Log-Forensik; leer wenn ohne Kontext. + public string CorrelationId { get; } + + public LogMessageEventArgs(string message, LogLevel level, string correlationId = "") { Message = message; Level = level; Timestamp = DateTime.Now; + CorrelationId = correlationId ?? ""; + } + } + + /// + /// 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. + /// + public static class LogJson + { + public static string Format(LogMessageEventArgs e) + { + var obj = new + { + ts = e.Timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"), + level = e.Level.ToString(), + cid = string.IsNullOrEmpty(e.CorrelationId) ? null : e.CorrelationId, + msg = e.Message + }; + return System.Text.Json.JsonSerializer.Serialize(obj, + new System.Text.Json.JsonSerializerOptions + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); } } @@ -62,6 +90,11 @@ namespace PolyTraderSharp.Services string logLine = $"[{e.Timestamp:HH:mm:ss}] {safeMsg}{Environment.NewLine}"; await File.AppendAllTextAsync(fullPath, logLine); + + // S-0: zusätzlich JSONL (eine Datei je Tag, alle Level) – maschinen-/KI-lesbar, + // Grundlage für den Log Viewer. Dual-Sink; Text-Sink später abschaltbar. + string jsonlPath = Path.Combine(_logsDirectory, $"{e.Timestamp:yyyy-MM-dd}.jsonl"); + await File.AppendAllTextAsync(jsonlPath, LogJson.Format(e) + Environment.NewLine); } catch { @@ -70,9 +103,9 @@ namespace PolyTraderSharp.Services } } - public void Log(string message, LogLevel level = LogLevel.Info) + public void Log(string message, LogLevel level = LogLevel.Info, string correlationId = "") { - var e = new LogMessageEventArgs(message, level); + var e = new LogMessageEventArgs(message, level, correlationId); lock (_lock) { _history.Add(e); diff --git a/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs b/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs index 736ec41..37c4461 100644 --- a/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs +++ b/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs @@ -76,6 +76,9 @@ namespace PolyTraderSharp public int AccountId { get; set; } public string TokenId { get; set; } = string.Empty; public int SourceTraderId { get; set; } + + /// Korrelation zum auslösenden Signal (leer bei Profit-Target-Exits). + public string SignalId { get; set; } = string.Empty; public string MarketQuestion { get; set; } = string.Empty; public decimal ReferencePrice { get; set; } // Master-Exit-Preis diff --git a/src/PolyTrader.Modules.CopyTrading/Models/ClosedTrade.cs b/src/PolyTrader.Modules.CopyTrading/Models/ClosedTrade.cs index ce0f987..6dfd313 100644 --- a/src/PolyTrader.Modules.CopyTrading/Models/ClosedTrade.cs +++ b/src/PolyTrader.Modules.CopyTrading/Models/ClosedTrade.cs @@ -24,6 +24,9 @@ namespace PolyTraderSharp.Models public DateTime OpenedAt { get; set; } public DateTime ClosedAt { get; set; } public string ExitReason { get; set; } = string.Empty; + + /// Korrelation zum Entscheidungsjournal/Order-Events (leer bei Alt-Daten/System-Closes). + public string SignalId { get; set; } = string.Empty; } public class ClosedTradeRow : ClosedTrade { diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs index 99d148d..4261b53 100644 --- a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs @@ -42,6 +42,7 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef e.Property(x => x.RealizedPnl).HasPrecision(18, 6); e.Property(x => x.PnlPercent).HasPrecision(18, 6); e.Property(x => x.TotalFees).HasPrecision(18, 6); + e.Property(x => x.SignalId).HasMaxLength(40); // Korrelation zum Entscheidungsjournal e.HasIndex(x => x.AccountId); e.HasIndex(x => x.TokenId); e.HasIndex(x => x.SourceTraderId); diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContextFactory.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContextFactory.cs index 69302f0..8f7d67d 100644 --- a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContextFactory.cs +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContextFactory.cs @@ -1,12 +1,14 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using PolyTrader.Core.Configuration; namespace PolyTrader.Modules.CopyTrading.Persistence.Ef { /// /// 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. /// public class CopyTradingDbContextFactory : IDesignTimeDbContextFactory { @@ -16,7 +18,7 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef ?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;"; var options = new DbContextOptionsBuilder() - .UseMySql(conn, ServerVersion.AutoDetect(conn)) + .UseMySql(conn, DatabaseServerVersion.Value) .Options; return new CopyTradingDbContext(options); diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260716101729_AddClosedTradeSignalId.Designer.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260716101729_AddClosedTradeSignalId.Designer.cs new file mode 100644 index 0000000..a480a3c --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260716101729_AddClosedTradeSignalId.Designer.cs @@ -0,0 +1,290 @@ +// +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 + { + /// + 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("TradeId") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitReason") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PnlPercent") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("RealizedPnl") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("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("AccountId") + .HasColumnType("int"); + + b.Property("MaxBuyPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("MaxPriceDifference") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("MaxSpreadPct") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("MinSellRatioPct") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PerMarketLimit") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PerMasterLimit") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PreRedeemLimit") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ProfitTarget") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SellFloorPct") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("perMaxTime24h") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("perMaxTime6h") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("perMaxTime72h") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("RealizedPnl") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("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("Id") + .HasColumnType("int"); + + b.Property("AssignedAccountIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("AutoPauseEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CopyAvgPnlPerTrade") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyPnl30d") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyProfitFactor") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyTradeCount30d") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsHidden") + .HasColumnType("tinyint(1)"); + + b.Property("MakerEntry") + .HasColumnType("tinyint(1)"); + + b.Property("Reasoning") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("TotalPnl") + .HasColumnType("double"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("WalletAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("WinningTrades") + .HasColumnType("int"); + + b.Property("Winrate30t") + .HasColumnType("double"); + + b.HasKey("Id"); + + b.ToTable("mod_copytrading_traders", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260716101729_AddClosedTradeSignalId.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260716101729_AddClosedTradeSignalId.cs new file mode 100644 index 0000000..0d04e34 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260716101729_AddClosedTradeSignalId.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations +{ + /// + public partial class AddClosedTradeSignalId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SignalId", + table: "mod_copytrading_closed_trades", + type: "varchar(40)", + maxLength: 40, + nullable: false, + defaultValue: "") + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SignalId", + table: "mod_copytrading_closed_trades"); + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs index 5e55ba2..5cedbc9 100644 --- a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs @@ -80,6 +80,11 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations .HasMaxLength(10) .HasColumnType("varchar(10)"); + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + b.Property("Size") .HasPrecision(18, 6) .HasColumnType("decimal(18,6)"); diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index 341131c..0943685 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -26,6 +26,8 @@ namespace PolyTraderSharp.Services private readonly IMarketRepository _marketRepo; private readonly IAccountRepository _accountRepo; private readonly SellLadderService _sellLadder; + private readonly IDecisionJournal _journal; + private readonly IOrderEventLog _orderEvents; private readonly ConcurrentDictionary _accountSemaphores = new(); private readonly ConcurrentDictionary _lastInactiveLogPerTrader = new(); @@ -40,7 +42,9 @@ namespace PolyTraderSharp.Services IPositionRepository positionRepo, IMarketRepository marketRepo, IAccountRepository accountRepo, - SellLadderService sellLadder) + SellLadderService sellLadder, + IDecisionJournal journal, + IOrderEventLog orderEvents) { _state = state; _copyState = copyState; @@ -53,6 +57,39 @@ namespace PolyTraderSharp.Services _marketRepo = marketRepo; _accountRepo = accountRepo; _sellLadder = sellLadder; + _journal = journal; + _orderEvents = orderEvents; + } + + /// + /// 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). + /// + /// Kompakter Kontext als JSON (kulturinvariant – kein deutsches Dezimalkomma). + private static string Ctx(object o) => System.Text.Json.JsonSerializer.Serialize(o); + + private void Journal(CopySignal signal, AccountState? account, TradeDecision decision, + DecisionReason reason, string message, string contextJson = "") + { + _journal.Write(new DecisionRecord + { + SignalId = signal.SignalId, + ModuleName = "CopyTrading", + AccountId = account?.AccountId ?? 0, + IsDemo = account?.IsDemo ?? false, + SourceTraderId = signal.TraderId, + TokenId = signal.TokenId, + MarketSlug = signal.MarketSlug, + MarketQuestion = signal.MarketQuestion, + Side = signal.Side, + SignalPrice = signal.Price, + MarketEndDate = signal.EndDate, + Decision = decision, + Reason = reason, + Message = message, + ContextJson = contextJson + }); } public override async Task StartAsync(CancellationToken cancellationToken) @@ -238,6 +275,8 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: {modeLabel}-Trading Modus ist 'Inactive'. Weitere Trades dieses Traders werden für 60s nicht geloggt."); + Journal(signal, account, TradeDecision.Skipped, DecisionReason.ModeInactive, + $"{modeLabel}-Trading 'Inactive' (Journal ratenbegrenzt wie das Log: 1/60s je Trader)"); } return; } @@ -248,6 +287,7 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert."); + Journal(signal, account, TradeDecision.Skipped, DecisionReason.SellOnlyModeBuyBlocked, "SellOnly-Modus blockiert BUY"); return; } @@ -302,6 +342,7 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{shareType}] übersprungen:\n" + $" Konto: {account.Name}\n" + $" Begründung: SELL-Eskalationsleiter läuft (Exit aktiv) – kein Zukauf während des Ausstiegs."); + Journal(signal, account, TradeDecision.Skipped, DecisionReason.ExitPendingBuySkip, "SELL-Leiter aktiv – kein Zukauf (H3)"); return; } @@ -310,6 +351,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" + $" Konto: {account.Name}\n" + $" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${settings.MaxBuyPrice:F3})"); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.MaxBuyPriceExceeded, + $"Preis {signal.Price:F3} > MaxBuyPrice {settings.MaxBuyPrice:F3}", + Ctx(new { price = signal.Price, maxBuyPrice = settings.MaxBuyPrice })); return; } @@ -345,6 +389,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: PerMasterLimit ({settings.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%)."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.PerMasterLimitReached, + $"PerMasterLimit {settings.PerMasterLimit:F1}% erreicht", + Ctx(new { investedInMaster, maxAllowedPerMaster, perMasterLimitPct = settings.PerMasterLimit })); return; } @@ -368,6 +415,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}"); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.TimeWindowLimitReached, + $"TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht", + Ctx(new { bucket = timeframeLabel, investedInTimeframe, maxAllowedTimeframe, hoursLeft })); return; } else @@ -381,6 +431,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})"); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.MarketBudgetExhausted, + $"Markt-Budget erschöpft (Rest {maxAmountToBuy:F2} < MinTrade)", + Ctx(new { maxAllowed, investedInMarket, remaining = maxAmountToBuy, minTrade })); return; } @@ -389,6 +442,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})"); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.InsufficientBalance, + "Verfügbares Guthaben reicht nicht", + Ctx(new { availableBalance = account.AvailableBalance, requested = maxAmountToBuy })); return; } @@ -406,6 +462,9 @@ namespace PolyTraderSharp.Services { _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" + $" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.BelowPolymarketMinimum, + "Mathematisch unmöglicher Trade (Balance reicht für Minimum nicht)", + Ctx(new { shares = exact.shares, usdc = exact.usdc, availableBalance = account.AvailableBalance })); return; } @@ -417,6 +476,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" + $" Konto: {account.Name}\n" + $" Begründung: Unter Polymarket Minimum ({exact.shares:F1} Shares / ${exact.usdc:F2} USDC). Min: 5.5 Shares / $0.10."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.BelowPolymarketMinimum, + $"Unter Polymarket-Minimum ({exact.shares:F1} Shares / {exact.usdc:F2} USDC)", + Ctx(new { shares = exact.shares, usdc = exact.usdc })); return; } @@ -451,12 +513,17 @@ namespace PolyTraderSharp.Services _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position gehört Trader '{wrongTraderPos.SourceTraderName}' (ID {wrongTraderPos.SourceTraderId}), SELL kam aber von Trader ID {signal.TraderId}."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.OwnershipMismatch, + $"Position gehört Trader {wrongTraderPos.SourceTraderId}, SELL kam von {signal.TraderId}", + Ctx(new { positionOwner = wrongTraderPos.SourceTraderId, signalTrader = signal.TraderId })); } else { _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert)."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.PositionNotFound, + "Keine Position im Portfolio (möglicherweise zuvor gefiltert)"); } return; } @@ -478,6 +545,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Teilverkauf ({sellRatio:P0} des Bestands). Master hält noch {masterShares:F1} Shares. Signal nur {signal.Size:F1} Shares. Schwelle: 30%."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.PartialSellBelowThreshold, + $"Teilverkauf {sellRatio:P0} unter Schwelle", + Ctx(new { sellRatio, masterShares, signalSize = signal.Size })); return; } _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] FREIGEGEBEN:\n" + @@ -492,6 +562,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Master hält laut Tracking 0 Shares, aber SELL Signal mit {signal.Size:F1} Shares erhalten. Inkonsistenz — ignoriert."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.MasterPositionInconsistent, + "Master hält laut Tracking 0 Shares (Inkonsistenz)", + Ctx(new { signalSize = signal.Size })); return; } } @@ -504,6 +577,9 @@ namespace PolyTraderSharp.Services _logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Kein Master-Position-Tracking verfügbar und Haltezeit erst {holdingMinutes:F1} Min. Warte auf ersten Sync."); + Journal(signal, account, TradeDecision.Skipped, DecisionReason.SyncGracePeriod, + $"Kein Tracking, Haltezeit {holdingMinutes:F1} Min < Schonfrist", + Ctx(new { holdingMinutes })); return; } } @@ -554,6 +630,9 @@ namespace PolyTraderSharp.Services $" Konto: {account.Name}\n" + $" Markt: {signal.MarketQuestion}\n" + $" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})"); + Journal(signal, account, TradeDecision.Executed, DecisionReason.DemoFilled, + $"Demo-BUY {exactShares:F2} Shares @ {orderPrice:F3}", + Ctx(new { shares = exactShares, usdc = exactUsdc, orderPrice })); } else { @@ -569,6 +648,22 @@ namespace PolyTraderSharp.Services : FeeModel.FallbackBps(null); var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, buyOrderType, _state.DebugOrderPayloadLog, isNegRisk, buyFeeBps); + // S-0: Order-Lifecycle als Daten (core_order_events). + _orderEvents.Write(new OrderEventRecord + { + SignalId = signal.SignalId, + ModuleName = "CopyTrading", + AccountId = account.AccountId, + TokenId = signal.TokenId, + EventType = result == "OK" ? OrderEventType.Placed : OrderEventType.PlaceFailed, + Side = "BUY", + Price = orderPrice, + AmountUsd = exactUsdc, + OrderType = buyOrderType, + Response = result, + DetailsJson = Ctx(new { feeBps = buyFeeBps, signalPrice = signal.Price }) + }); + if (result == "OK") { var pos = new Position @@ -612,6 +707,16 @@ namespace PolyTraderSharp.Services // The background sync will update with the real value within 30 seconds string masterKey = $"{signal.TraderId}_{signal.TokenId}"; _copyState.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow)); + + Journal(signal, account, TradeDecision.Executed, DecisionReason.OrderPlaced, + $"Live-BUY {buyOrderType} {exactShares:F2} Shares @ {orderPrice:F3}", + Ctx(new { shares = exactShares, usdc = exactUsdc, orderPrice, orderType = buyOrderType })); + } + else + { + Journal(signal, account, TradeDecision.Failed, DecisionReason.OrderFailed, + $"Live-BUY fehlgeschlagen: {result}", + Ctx(new { usdc = exactUsdc, orderPrice, orderType = buyOrderType })); } } } @@ -625,6 +730,8 @@ namespace PolyTraderSharp.Services // BUY blockiert einen legitimen schnellen SELL des Masters NICHT mehr. if (pendingInfo.Side == "SELL" && (DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20) { + Journal(signal, account, TradeDecision.Skipped, DecisionReason.SellSpamBlock, + "SELL <20s nach letztem SELL (Spam-Blockade)"); return; // Spam-Blockade: SELL wurde in den letzten 20 Sekunden bereits versendet } } @@ -634,6 +741,7 @@ namespace PolyTraderSharp.Services if (account.OpenPositions.TryGetValue(signal.TokenId, out var laddering) && laddering.ExitPending) { _logger.Info($"SELL [{signal.MarketQuestion}] ignoriert: Eskalationsleiter läuft bereits (Konto {account.Name})."); + Journal(signal, account, TradeDecision.Skipped, DecisionReason.LadderAlreadyActive, "Eskalationsleiter läuft bereits"); return; } @@ -653,6 +761,8 @@ namespace PolyTraderSharp.Services _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position gehört einem anderen Trader (Safety Check)."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.OwnershipMismatch, + "Position gehört einem anderen Trader (Defense-in-depth)"); } if (!removed && !string.IsNullOrEmpty(signal.MarketSlug)) @@ -712,7 +822,8 @@ namespace PolyTraderSharp.Services TotalFees = totalFees, OpenedAt = openPos.OpenedAt, ClosedAt = DateTime.UtcNow, - ExitReason = signal.Reason + ExitReason = signal.Reason, + SignalId = signal.SignalId }; _closedTradeWriter.TryWrite(ct); @@ -720,6 +831,10 @@ namespace PolyTraderSharp.Services $" Konto: {account.Name}\n" + $" Markt: {signal.MarketQuestion}\n" + $" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})"); + Journal(signal, account, TradeDecision.Executed, + signal.TraderId == 0 ? DecisionReason.SystemResolutionClose : DecisionReason.DemoClosed, + $"Demo-Close {openPos.Size:F2} Shares, PnL {realizedPnl:F2}", + Ctx(new { size = openPos.Size, exitPrice = demoExitPrice, realizedPnl, totalFees })); } else { @@ -727,9 +842,14 @@ namespace PolyTraderSharp.Services // SellLadderService – gleiche Quelle wie der Profit-Target-Exit im Sync). // openPos wurde oben entfernt; StartLadderAsync stellt es als ExitPending zurück. bool isHf = trader != null && trader.Category == "HF"; - await _sellLadder.StartLadderAsync( + bool ladderOk = await _sellLadder.StartLadderAsync( account, openPos, signal.Price, signal.TraderId, isHf, - settings.MaxPriceDifference, settings.SellFloorPct, isNegRisk, "Master SELL"); + settings.MaxPriceDifference, settings.SellFloorPct, isNegRisk, "Master SELL", signal.SignalId); + Journal(signal, account, + ladderOk ? TradeDecision.Executed : TradeDecision.Failed, + ladderOk ? DecisionReason.LadderStarted : DecisionReason.LadderStartFailed, + ladderOk ? $"SELL-Leiter gestartet (Referenz {signal.Price:F3})" : "SELL-Leiter-Start fehlgeschlagen/abgelehnt", + Ctx(new { referencePrice = signal.Price, size = openPos.Size, isHf })); } } else @@ -737,6 +857,8 @@ namespace PolyTraderSharp.Services _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert)."); + Journal(signal, account, TradeDecision.Rejected, DecisionReason.PositionNotFound, + "Position nicht im Portfolio gefunden"); } } } diff --git a/src/PolyTrader.Modules.CopyTrading/Services/PersistenceService.cs b/src/PolyTrader.Modules.CopyTrading/Services/PersistenceService.cs index 4157a92..367a793 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/PersistenceService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/PersistenceService.cs @@ -94,7 +94,8 @@ namespace PolyTraderSharp.Services PnlPercent = trade.PnlPercent, OpenedAt = trade.OpenedAt, ClosedAt = trade.ClosedAt, - ExitReason = trade.ExitReason + ExitReason = trade.ExitReason, + SignalId = trade.SignalId }); _logger.Debug($"Saved ClosedTrade {trade.TradeId} to MySQL"); diff --git a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs index 9bea744..2d78898 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs @@ -32,6 +32,7 @@ namespace PolyTraderSharp.Services private readonly TerminalLogger _logger; private readonly ThreemaService _threema; private readonly IPositionRepository _positionRepo; + private readonly IOrderEventLog _orderEvents; public SellLadderService( CopyTradingState copyState, @@ -39,7 +40,8 @@ namespace PolyTraderSharp.Services IClobClient clob, TerminalLogger logger, ThreemaService threema, - IPositionRepository positionRepo) + IPositionRepository positionRepo, + IOrderEventLog orderEvents) { _copyState = copyState; _state = state; @@ -47,6 +49,26 @@ namespace PolyTraderSharp.Services _logger = logger; _threema = threema; _positionRepo = positionRepo; + _orderEvents = orderEvents; + } + + /// S-0: Order-Lifecycle-Event der Leiter strukturiert festhalten (core_order_events). + 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 + }); } /// @@ -57,7 +79,8 @@ namespace PolyTraderSharp.Services /// public async Task StartLadderAsync( AccountState account, Position pos, decimal referencePrice, int sourceTraderId, - bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag) + bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag, + string signalId = "") { string key = $"{account.AccountId}_{pos.TokenId}"; @@ -87,6 +110,7 @@ namespace PolyTraderSharp.Services AccountId = account.AccountId, TokenId = pos.TokenId, SourceTraderId = sourceTraderId, + SignalId = signalId, MarketQuestion = pos.MarketQuestion, ReferencePrice = referencePrice, CurrentLimit = firstLimit, @@ -123,6 +147,9 @@ namespace PolyTraderSharp.Services ? FeeModel.ResolveBps(startMd.TakerFeeBps, startMd.Category) : FeeModel.FallbackBps(null); var result = await _clob.PlaceOrderAsync(account, pos.TokenId, "SELL", pos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk, feeBps); + OrderEvent(ladder, result == "OK" ? OrderEventType.LadderStart : OrderEventType.PlaceFailed, + firstLimit, pos.Size * firstLimit, result, + $"{{\"reasonTag\":\"{reasonTag}\",\"floor\":{floor.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}"); if (result == "OK") { @@ -187,6 +214,8 @@ namespace PolyTraderSharp.Services pos.ExitPending = false; if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos); _logger.Warning($"🧹 [SELL-LEITER Dust] {account.Name} | {ladder.MarketQuestion}: Restgröße {pos.Size:F2} < Minimum {CopyTradingRisk.MinShares} Shares – Leiter beendet, Rest gehalten."); + OrderEvent(ladder, OrderEventType.DustAbort, ladder.CurrentLimit, pos.Size * ladder.CurrentLimit, + "Dust", $"{{\"restShares\":{pos.Size.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}"); return; } @@ -221,6 +250,7 @@ namespace PolyTraderSharp.Services try { var r = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", pos.Size * ladder.Floor, ladder.Floor, "GTC", _state.DebugOrderPayloadLog, isNegFloor, floorFeeBps); + OrderEvent(ladder, OrderEventType.FloorReplaced, ladder.Floor, pos.Size * ladder.Floor, r); if (r == "OK") _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL"); else @@ -259,6 +289,9 @@ namespace PolyTraderSharp.Services decimal usdc = pos.Size * next; var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk, stepFeeBps); + OrderEvent(ladder, result == "OK" ? OrderEventType.LadderStep : OrderEventType.PlaceFailed, + next, usdc, result, + $"{{\"attempt\":{ladder.Attempt + 1},\"floor\":{ladder.Floor.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}"); if (result == "OK") { diff --git a/tests/PolyTrader.Tests/AnalysisJournalTests.cs b/tests/PolyTrader.Tests/AnalysisJournalTests.cs new file mode 100644 index 0000000..1c83d78 --- /dev/null +++ b/tests/PolyTrader.Tests/AnalysisJournalTests.cs @@ -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 +{ + /// + /// Sicherheitsnetz für das S-0-Datenfundament: JSONL-Log-Format (pur) und die + /// Entscheidungsjournal-/Order-Event-Repos (EF InMemory, inkl. Enum-als-String-Roundtrip). + /// + 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 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); + } + } +} diff --git a/tests/PolyTrader.Tests/CopyTradingEngineTests.cs b/tests/PolyTrader.Tests/CopyTradingEngineTests.cs index b246478..9c5c79c 100644 --- a/tests/PolyTrader.Tests/CopyTradingEngineTests.cs +++ b/tests/PolyTrader.Tests/CopyTradingEngineTests.cs @@ -27,6 +27,7 @@ namespace PolyTrader.Tests public CopyTradingState Copy = null!; public FakeClobClient Clob = null!; public ChannelReader ClosedReader = null!; + public FakeDecisionJournal Journal = null!; } private static Harness Build() @@ -40,18 +41,20 @@ namespace PolyTrader.Tests var marketRepo = new FakeMarketRepository(); var accountRepo = new FakeAccountRepository(); var threema = new ThreemaService(logger, new JobManager()); - var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo); + var journal = new FakeDecisionJournal(); + var orderEvents = new FakeOrderEventLog(); + var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo, orderEvents); var signalCh = Channel.CreateUnbounded(); var closedCh = Channel.CreateUnbounded(); var engine = new CopyTradingEngine(state, copy, signalCh.Reader, closedCh.Writer, logger, - clob, api, posRepo, marketRepo, accountRepo, ladder); + clob, api, posRepo, marketRepo, accountRepo, ladder, journal, orderEvents); // MarketData cachen, damit der API-Pfad (Cache-Miss) nie läuft. state.MarketCache[Tok] = new MarketData { Slug = "slug", Question = "Frage?", Category = "Politics", TakerFeeBps = 0, NegRisk = false }; - return new Harness { Engine = engine, State = state, Copy = copy, Clob = clob, ClosedReader = closedCh.Reader }; + return new Harness { Engine = engine, State = state, Copy = copy, Clob = clob, ClosedReader = closedCh.Reader, Journal = journal }; } private static AccountState Account(bool demo) => new() @@ -88,6 +91,9 @@ namespace PolyTrader.Tests Assert.Empty(h.Clob.Placed); // kein Zukauf während des Ausstiegs Assert.True(acc.OpenPositions[Tok].ExitPending); + // S-0: Entscheidung strukturiert im Journal (nicht nur Freitext-Log). + Assert.Contains(h.Journal.Written, d => + d.Decision == TradeDecision.Skipped && d.Reason == DecisionReason.ExitPendingBuySkip && d.TokenId == Tok); } // ---------- Doppel-SELL-Guard ---------- @@ -120,6 +126,9 @@ namespace PolyTrader.Tests Assert.False(acc.OpenPositions.ContainsKey(Tok)); // Demo-Position geschlossen Assert.True(h.ClosedReader.TryRead(out var ct)); // ClosedTrade geschrieben Assert.Equal(Tok, ct!.TokenId); + Assert.False(string.IsNullOrEmpty(ct.SignalId)); // S-0: Korrelation gesetzt + Assert.Contains(h.Journal.Written, d => + d.Decision == TradeDecision.Executed && d.Reason == DecisionReason.SystemResolutionClose); } [Fact] diff --git a/tests/PolyTrader.Tests/Fakes/FakeAnalysisJournals.cs b/tests/PolyTrader.Tests/Fakes/FakeAnalysisJournals.cs new file mode 100644 index 0000000..464d78a --- /dev/null +++ b/tests/PolyTrader.Tests/Fakes/FakeAnalysisJournals.cs @@ -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 +{ + /// In-Memory-Stub des Entscheidungsjournals – Tests können Journal-Einträge asserten. + public sealed class FakeDecisionJournal : IDecisionJournal + { + public List Written { get; } = new(); + public void Write(DecisionRecord record) => Written.Add(record); + public List Query(Expression> predicate, int limit = 1000) + => Written.Where(predicate.Compile()).Take(limit).ToList(); + } + + /// In-Memory-Stub des Order-Lifecycle-Logs. + public sealed class FakeOrderEventLog : IOrderEventLog + { + public List Written { get; } = new(); + public void Write(OrderEventRecord record) => Written.Add(record); + public List Query(Expression> predicate, int limit = 1000) + => Written.Where(predicate.Compile()).Take(limit).ToList(); + } +} diff --git a/tests/PolyTrader.Tests/SellLadderServiceTests.cs b/tests/PolyTrader.Tests/SellLadderServiceTests.cs index 4894454..878478f 100644 --- a/tests/PolyTrader.Tests/SellLadderServiceTests.cs +++ b/tests/PolyTrader.Tests/SellLadderServiceTests.cs @@ -27,7 +27,7 @@ namespace PolyTrader.Tests var logger = new TerminalLogger(); var threema = new ThreemaService(logger, new JobManager()); var repo = new FakePositionRepository(); - var svc = new SellLadderService(copy, state, clob, logger, threema, repo); + var svc = new SellLadderService(copy, state, clob, logger, threema, repo, new FakeOrderEventLog()); return (svc, copy, state, clob, repo); } diff --git a/tests/PolyTrader.Tests/TraderMonitorServiceTests.cs b/tests/PolyTrader.Tests/TraderMonitorServiceTests.cs index 71d3941..2bb51aa 100644 --- a/tests/PolyTrader.Tests/TraderMonitorServiceTests.cs +++ b/tests/PolyTrader.Tests/TraderMonitorServiceTests.cs @@ -30,7 +30,7 @@ namespace PolyTrader.Tests var marketRepo = new FakeMarketRepository(); var tradeLog = new FakeCopyTradeLogRepository(); var threema = new ThreemaService(logger, new JobManager()); - var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo); + var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo, new FakeOrderEventLog()); var signalCh = Channel.CreateUnbounded(); var closedCh = Channel.CreateUnbounded();