From c7668170a4b7915ac193760453c79262af36c319 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 18 Jul 2026 11:14:45 +0200 Subject: [PATCH] Supervisor S-3-Rest + RF-Designer-Konvertierung: Counterfactual, Tagesbericht, UI RF-UI designerfaehig (Richards Vorgabe, letzter code-only-Altbestand): - ResolutionFarmingMainForm auf partial + .Designer.cs umgestellt (4 Tabs Kandidaten/ Positionen/Historie/Settings, alle Controls im Designer; Verhalten unveraendert). S-3 Counterfactual ('Was waere aus abgelehnten BUYs geworden?'): - CounterfactualJob (alle 6h, API-gedrosselt): nimmt Rejected-BUY-Entscheidungen mit abgelaufenem MarketEndDate aus dem Journal, prueft die Marktaufloesung (ICounterfactualResolutionSource; live = Adapter um CheckMarketResolutionAsync) und speichert IsWinner + hypothetischen PnL/Share (CounterfactualMath, pur) nach sup_counterfactuals (unique je DecisionId -> idempotent). Migration generiert+angewendet. - Agent-Tool query_counterfactuals + UI-Tab 'Counterfactual' (via Designer). S-3 Threema-Tagesbericht: - DailyReportService: taeglich zur konfigurierten Stunde (OPT-IN via POLYTRADER_SUPERVISOR_DAILY=0-23) laesst der Agent einen 24h-Kurzbericht erstellen (KPIs je Modul, Fehler, Reject-Haeufungen), sendet via Threema und legt ihn als sup_report ab. Ohne OpenRouter-Key: stiller Skip mit Log. Tests: +7 (CounterfactualMath, Job aufgeloest/unaufgeloest/idempotent, Tagesbericht mit/ohne Key). Build 0 Fehler, 367 Tests gruen, --smoke-ui alle 5 Views gruen. Co-Authored-By: Claude Opus 4.8 --- .../Ui/ResolutionFarmingMainForm.Designer.cs | 352 ++++++++++++++++++ .../Ui/ResolutionFarmingMainForm.cs | 114 ++---- .../Agent/SupervisorTools.cs | 22 +- .../Counterfactual/CounterfactualJob.cs | 122 ++++++ .../Counterfactual/CounterfactualRecord.cs | 43 +++ ...60718091331_AddCounterfactuals.Designer.cs | 150 ++++++++ .../20260718091331_AddCounterfactuals.cs | 70 ++++ .../SupervisorDbContextModelSnapshot.cs | 70 ++++ .../Persistence/SupervisorDbContext.cs | 63 ++++ .../Services/DailyReportService.cs | 100 +++++ .../SupervisorModule.cs | 10 +- .../Ui/SupervisorMainForm.Designer.cs | 59 +++ .../Ui/SupervisorMainForm.cs | 19 + tests/PolyTrader.Tests/SupervisorS3Tests.cs | 153 ++++++++ 14 files changed, 1252 insertions(+), 95 deletions(-) create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.Designer.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualJob.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualRecord.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.Designer.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Services/DailyReportService.cs create mode 100644 tests/PolyTrader.Tests/SupervisorS3Tests.cs diff --git a/src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.Designer.cs b/src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.Designer.cs new file mode 100644 index 0000000..7d68b52 --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.Designer.cs @@ -0,0 +1,352 @@ +namespace PolyTrader.Modules.ResolutionFarming.Ui +{ + partial class ResolutionFarmingMainForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Vom Komponenten-Designer generierter Code + + private void InitializeComponent() + { + this.tabControlRf = new System.Windows.Forms.TabControl(); + this.tabKandidaten = new System.Windows.Forms.TabPage(); + this.dgvCandidates = new System.Windows.Forms.DataGridView(); + this.pnlCandTop = new System.Windows.Forms.Panel(); + this.lblCandKonto = new System.Windows.Forms.Label(); + this.cbCandAccount = new System.Windows.Forms.ComboBox(); + this.btnCandRefresh = new System.Windows.Forms.Button(); + this.tabPositionen = new System.Windows.Forms.TabPage(); + this.dgvPositions = new System.Windows.Forms.DataGridView(); + this.pnlPosTop = new System.Windows.Forms.Panel(); + this.btnPosRefresh = new System.Windows.Forms.Button(); + this.tabHistorie = new System.Windows.Forms.TabPage(); + this.dgvHistory = new System.Windows.Forms.DataGridView(); + this.lblHistSummary = new System.Windows.Forms.Label(); + this.pnlHistTop = new System.Windows.Forms.Panel(); + this.btnHistRefresh = new System.Windows.Forms.Button(); + this.tabSettings = new System.Windows.Forms.TabPage(); + this.pgSettings = new System.Windows.Forms.PropertyGrid(); + this.pnlSetTop = new System.Windows.Forms.Panel(); + this.lblSetKonto = new System.Windows.Forms.Label(); + this.cbSettingsAccount = new System.Windows.Forms.ComboBox(); + this.btnSettingsSave = new System.Windows.Forms.Button(); + this.lblRfStatus = new System.Windows.Forms.Label(); + this.tabControlRf.SuspendLayout(); + this.tabKandidaten.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvCandidates)).BeginInit(); + this.pnlCandTop.SuspendLayout(); + this.tabPositionen.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvPositions)).BeginInit(); + this.pnlPosTop.SuspendLayout(); + this.tabHistorie.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvHistory)).BeginInit(); + this.pnlHistTop.SuspendLayout(); + this.tabSettings.SuspendLayout(); + this.pnlSetTop.SuspendLayout(); + this.SuspendLayout(); + // + // tabControlRf + // + this.tabControlRf.Controls.Add(this.tabKandidaten); + this.tabControlRf.Controls.Add(this.tabPositionen); + this.tabControlRf.Controls.Add(this.tabHistorie); + this.tabControlRf.Controls.Add(this.tabSettings); + this.tabControlRf.Dock = System.Windows.Forms.DockStyle.Fill; + this.tabControlRf.Location = new System.Drawing.Point(0, 0); + this.tabControlRf.Name = "tabControlRf"; + this.tabControlRf.SelectedIndex = 0; + this.tabControlRf.Size = new System.Drawing.Size(1000, 596); + this.tabControlRf.TabIndex = 0; + // + // tabKandidaten + // + this.tabKandidaten.Controls.Add(this.dgvCandidates); + this.tabKandidaten.Controls.Add(this.pnlCandTop); + this.tabKandidaten.Location = new System.Drawing.Point(4, 24); + this.tabKandidaten.Name = "tabKandidaten"; + this.tabKandidaten.Padding = new System.Windows.Forms.Padding(3); + this.tabKandidaten.Size = new System.Drawing.Size(992, 568); + this.tabKandidaten.TabIndex = 0; + this.tabKandidaten.Text = "Kandidaten"; + this.tabKandidaten.UseVisualStyleBackColor = true; + // + // dgvCandidates + // + this.dgvCandidates.AllowUserToAddRows = false; + this.dgvCandidates.AllowUserToDeleteRows = false; + this.dgvCandidates.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvCandidates.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvCandidates.Location = new System.Drawing.Point(3, 37); + this.dgvCandidates.Name = "dgvCandidates"; + this.dgvCandidates.ReadOnly = true; + this.dgvCandidates.RowHeadersVisible = false; + this.dgvCandidates.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvCandidates.Size = new System.Drawing.Size(986, 528); + this.dgvCandidates.TabIndex = 1; + // + // pnlCandTop + // + this.pnlCandTop.Controls.Add(this.btnCandRefresh); + this.pnlCandTop.Controls.Add(this.cbCandAccount); + this.pnlCandTop.Controls.Add(this.lblCandKonto); + this.pnlCandTop.Dock = System.Windows.Forms.DockStyle.Top; + this.pnlCandTop.Location = new System.Drawing.Point(3, 3); + this.pnlCandTop.Name = "pnlCandTop"; + this.pnlCandTop.Size = new System.Drawing.Size(986, 34); + this.pnlCandTop.TabIndex = 0; + // + // lblCandKonto + // + this.lblCandKonto.AutoSize = true; + this.lblCandKonto.Location = new System.Drawing.Point(6, 9); + this.lblCandKonto.Name = "lblCandKonto"; + this.lblCandKonto.Size = new System.Drawing.Size(43, 15); + this.lblCandKonto.TabIndex = 0; + this.lblCandKonto.Text = "Konto:"; + // + // cbCandAccount + // + this.cbCandAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cbCandAccount.Location = new System.Drawing.Point(55, 5); + this.cbCandAccount.Name = "cbCandAccount"; + this.cbCandAccount.Size = new System.Drawing.Size(260, 23); + this.cbCandAccount.TabIndex = 1; + // + // btnCandRefresh + // + this.btnCandRefresh.Location = new System.Drawing.Point(325, 4); + this.btnCandRefresh.Name = "btnCandRefresh"; + this.btnCandRefresh.Size = new System.Drawing.Size(110, 26); + this.btnCandRefresh.TabIndex = 2; + this.btnCandRefresh.Text = "Aktualisieren"; + this.btnCandRefresh.UseVisualStyleBackColor = true; + // + // tabPositionen + // + this.tabPositionen.Controls.Add(this.dgvPositions); + this.tabPositionen.Controls.Add(this.pnlPosTop); + this.tabPositionen.Location = new System.Drawing.Point(4, 24); + this.tabPositionen.Name = "tabPositionen"; + this.tabPositionen.Padding = new System.Windows.Forms.Padding(3); + this.tabPositionen.Size = new System.Drawing.Size(992, 568); + this.tabPositionen.TabIndex = 1; + this.tabPositionen.Text = "Positionen"; + this.tabPositionen.UseVisualStyleBackColor = true; + // + // dgvPositions + // + this.dgvPositions.AllowUserToAddRows = false; + this.dgvPositions.AllowUserToDeleteRows = false; + this.dgvPositions.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvPositions.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvPositions.Location = new System.Drawing.Point(3, 37); + this.dgvPositions.Name = "dgvPositions"; + this.dgvPositions.ReadOnly = true; + this.dgvPositions.RowHeadersVisible = false; + this.dgvPositions.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvPositions.Size = new System.Drawing.Size(986, 528); + this.dgvPositions.TabIndex = 1; + // + // pnlPosTop + // + this.pnlPosTop.Controls.Add(this.btnPosRefresh); + this.pnlPosTop.Dock = System.Windows.Forms.DockStyle.Top; + this.pnlPosTop.Location = new System.Drawing.Point(3, 3); + this.pnlPosTop.Name = "pnlPosTop"; + this.pnlPosTop.Size = new System.Drawing.Size(986, 34); + this.pnlPosTop.TabIndex = 0; + // + // btnPosRefresh + // + this.btnPosRefresh.Location = new System.Drawing.Point(6, 4); + this.btnPosRefresh.Name = "btnPosRefresh"; + this.btnPosRefresh.Size = new System.Drawing.Size(110, 26); + this.btnPosRefresh.TabIndex = 0; + this.btnPosRefresh.Text = "Aktualisieren"; + this.btnPosRefresh.UseVisualStyleBackColor = true; + // + // tabHistorie + // + this.tabHistorie.Controls.Add(this.dgvHistory); + this.tabHistorie.Controls.Add(this.lblHistSummary); + this.tabHistorie.Controls.Add(this.pnlHistTop); + this.tabHistorie.Location = new System.Drawing.Point(4, 24); + this.tabHistorie.Name = "tabHistorie"; + this.tabHistorie.Padding = new System.Windows.Forms.Padding(3); + this.tabHistorie.Size = new System.Drawing.Size(992, 568); + this.tabHistorie.TabIndex = 2; + this.tabHistorie.Text = "Historie / Statistik"; + this.tabHistorie.UseVisualStyleBackColor = true; + // + // dgvHistory + // + this.dgvHistory.AllowUserToAddRows = false; + this.dgvHistory.AllowUserToDeleteRows = false; + this.dgvHistory.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvHistory.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvHistory.Location = new System.Drawing.Point(3, 85); + this.dgvHistory.Name = "dgvHistory"; + this.dgvHistory.ReadOnly = true; + this.dgvHistory.RowHeadersVisible = false; + this.dgvHistory.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvHistory.Size = new System.Drawing.Size(986, 480); + this.dgvHistory.TabIndex = 2; + // + // lblHistSummary + // + this.lblHistSummary.Dock = System.Windows.Forms.DockStyle.Top; + this.lblHistSummary.Location = new System.Drawing.Point(3, 37); + this.lblHistSummary.Name = "lblHistSummary"; + this.lblHistSummary.Padding = new System.Windows.Forms.Padding(6); + this.lblHistSummary.Size = new System.Drawing.Size(986, 48); + this.lblHistSummary.TabIndex = 1; + this.lblHistSummary.Text = ""; + // + // pnlHistTop + // + this.pnlHistTop.Controls.Add(this.btnHistRefresh); + this.pnlHistTop.Dock = System.Windows.Forms.DockStyle.Top; + this.pnlHistTop.Location = new System.Drawing.Point(3, 3); + this.pnlHistTop.Name = "pnlHistTop"; + this.pnlHistTop.Size = new System.Drawing.Size(986, 34); + this.pnlHistTop.TabIndex = 0; + // + // btnHistRefresh + // + this.btnHistRefresh.Location = new System.Drawing.Point(6, 4); + this.btnHistRefresh.Name = "btnHistRefresh"; + this.btnHistRefresh.Size = new System.Drawing.Size(110, 26); + this.btnHistRefresh.TabIndex = 0; + this.btnHistRefresh.Text = "Aktualisieren"; + this.btnHistRefresh.UseVisualStyleBackColor = true; + // + // tabSettings + // + this.tabSettings.Controls.Add(this.pgSettings); + this.tabSettings.Controls.Add(this.pnlSetTop); + this.tabSettings.Location = new System.Drawing.Point(4, 24); + this.tabSettings.Name = "tabSettings"; + this.tabSettings.Padding = new System.Windows.Forms.Padding(3); + this.tabSettings.Size = new System.Drawing.Size(992, 568); + this.tabSettings.TabIndex = 3; + this.tabSettings.Text = "Settings"; + this.tabSettings.UseVisualStyleBackColor = true; + // + // pgSettings + // + this.pgSettings.Dock = System.Windows.Forms.DockStyle.Fill; + this.pgSettings.Location = new System.Drawing.Point(3, 37); + this.pgSettings.Name = "pgSettings"; + this.pgSettings.Size = new System.Drawing.Size(986, 528); + this.pgSettings.TabIndex = 1; + // + // pnlSetTop + // + this.pnlSetTop.Controls.Add(this.btnSettingsSave); + this.pnlSetTop.Controls.Add(this.cbSettingsAccount); + this.pnlSetTop.Controls.Add(this.lblSetKonto); + this.pnlSetTop.Dock = System.Windows.Forms.DockStyle.Top; + this.pnlSetTop.Location = new System.Drawing.Point(3, 3); + this.pnlSetTop.Name = "pnlSetTop"; + this.pnlSetTop.Size = new System.Drawing.Size(986, 34); + this.pnlSetTop.TabIndex = 0; + // + // lblSetKonto + // + this.lblSetKonto.AutoSize = true; + this.lblSetKonto.Location = new System.Drawing.Point(6, 9); + this.lblSetKonto.Name = "lblSetKonto"; + this.lblSetKonto.Size = new System.Drawing.Size(43, 15); + this.lblSetKonto.TabIndex = 0; + this.lblSetKonto.Text = "Konto:"; + // + // cbSettingsAccount + // + this.cbSettingsAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cbSettingsAccount.Location = new System.Drawing.Point(55, 5); + this.cbSettingsAccount.Name = "cbSettingsAccount"; + this.cbSettingsAccount.Size = new System.Drawing.Size(260, 23); + this.cbSettingsAccount.TabIndex = 1; + // + // btnSettingsSave + // + this.btnSettingsSave.Location = new System.Drawing.Point(325, 4); + this.btnSettingsSave.Name = "btnSettingsSave"; + this.btnSettingsSave.Size = new System.Drawing.Size(110, 26); + this.btnSettingsSave.TabIndex = 2; + this.btnSettingsSave.Text = "Speichern"; + this.btnSettingsSave.UseVisualStyleBackColor = true; + // + // lblRfStatus + // + this.lblRfStatus.Dock = System.Windows.Forms.DockStyle.Bottom; + this.lblRfStatus.Location = new System.Drawing.Point(0, 596); + this.lblRfStatus.Name = "lblRfStatus"; + this.lblRfStatus.Padding = new System.Windows.Forms.Padding(6, 2, 6, 2); + this.lblRfStatus.Size = new System.Drawing.Size(1000, 22); + this.lblRfStatus.TabIndex = 1; + this.lblRfStatus.Text = ""; + // + // ResolutionFarmingMainForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(1000, 618); + this.Controls.Add(this.tabControlRf); + this.Controls.Add(this.lblRfStatus); + this.Name = "ResolutionFarmingMainForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "ResolutionFarming"; + this.tabControlRf.ResumeLayout(false); + this.tabKandidaten.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.dgvCandidates)).EndInit(); + this.pnlCandTop.ResumeLayout(false); + this.pnlCandTop.PerformLayout(); + this.tabPositionen.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.dgvPositions)).EndInit(); + this.pnlPosTop.ResumeLayout(false); + this.tabHistorie.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.dgvHistory)).EndInit(); + this.pnlHistTop.ResumeLayout(false); + this.tabSettings.ResumeLayout(false); + this.pnlSetTop.ResumeLayout(false); + this.pnlSetTop.PerformLayout(); + this.ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.TabControl tabControlRf; + private System.Windows.Forms.TabPage tabKandidaten; + private System.Windows.Forms.Panel pnlCandTop; + private System.Windows.Forms.Label lblCandKonto; + private System.Windows.Forms.ComboBox cbCandAccount; + private System.Windows.Forms.Button btnCandRefresh; + private System.Windows.Forms.DataGridView dgvCandidates; + private System.Windows.Forms.TabPage tabPositionen; + private System.Windows.Forms.Panel pnlPosTop; + private System.Windows.Forms.Button btnPosRefresh; + private System.Windows.Forms.DataGridView dgvPositions; + private System.Windows.Forms.TabPage tabHistorie; + private System.Windows.Forms.Panel pnlHistTop; + private System.Windows.Forms.Button btnHistRefresh; + private System.Windows.Forms.Label lblHistSummary; + private System.Windows.Forms.DataGridView dgvHistory; + private System.Windows.Forms.TabPage tabSettings; + private System.Windows.Forms.Panel pnlSetTop; + private System.Windows.Forms.Label lblSetKonto; + private System.Windows.Forms.ComboBox cbSettingsAccount; + private System.Windows.Forms.Button btnSettingsSave; + private System.Windows.Forms.PropertyGrid pgSettings; + private System.Windows.Forms.Label lblRfStatus; + } +} diff --git a/src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.cs b/src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.cs index 819dc5e..b00f36f 100644 --- a/src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.cs +++ b/src/PolyTrader.Modules.ResolutionFarming/Ui/ResolutionFarmingMainForm.cs @@ -9,11 +9,11 @@ using PolyTraderSharp; namespace PolyTrader.Modules.ResolutionFarming.Ui { /// - /// Hauptfenster des ResolutionFarming-Moduls: ein TabControl mit Kandidaten, Positionen, - /// Historie/Statistik und Settings. Bewusst code-only konstruiert (kein Designer/.resx). DB-Zugriffe - /// sind defensiv (try/catch) – die UI bleibt bedienbar, auch bevor die rf_-Migration angewendet ist. + /// Hauptfenster des ResolutionFarming-Moduls: Tabs Kandidaten, Positionen, Historie/Statistik, + /// Settings. Layout im Designer (ResolutionFarmingMainForm.Designer.cs), Verhalten/Daten hier. + /// DB-Zugriffe defensiv (Guarded), damit die UI auch ohne angewendete rf_-Migration bedienbar bleibt. /// - public sealed class ResolutionFarmingMainForm : Form + public partial class ResolutionFarmingMainForm : Form { private IRfCandidateRepository? _candidates; private IRfPositionRepository? _positions; @@ -21,24 +21,18 @@ namespace PolyTrader.Modules.ResolutionFarming.Ui private IRfSettingsRepository? _settingsRepo; private TradingState? _state; - private readonly ComboBox _candAccount = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 260 }; - private readonly ComboBox _settingsAccount = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 260 }; - private readonly DataGridView _candGrid = NewGrid(); - private readonly DataGridView _posGrid = NewGrid(); - private readonly DataGridView _histGrid = NewGrid(); - private readonly PropertyGrid _settingsGrid = new() { Dock = DockStyle.Fill }; - private readonly Label _histSummary = new() { Dock = DockStyle.Top, Height = 48, Padding = new Padding(6), Text = "" }; - private readonly Label _status = new() { Dock = DockStyle.Bottom, Height = 22, Padding = new Padding(6, 2, 6, 2), Text = "" }; - private RfSettings? _currentSettings; public ResolutionFarmingMainForm() { - Text = "ResolutionFarming"; - Width = 1000; - Height = 640; - StartPosition = FormStartPosition.CenterScreen; - BuildUi(); + InitializeComponent(); + + btnCandRefresh.Click += (_, _) => RefreshCandidates(); + cbCandAccount.SelectedIndexChanged += (_, _) => RefreshCandidates(); + btnPosRefresh.Click += (_, _) => RefreshPositions(); + btnHistRefresh.Click += (_, _) => RefreshHistory(); + btnSettingsSave.Click += (_, _) => SaveSettings(); + cbSettingsAccount.SelectedIndexChanged += (_, _) => LoadSettings(); } /// Injiziert Repos/State (nach DI-Auflösung) und lädt die Ansichten. @@ -56,72 +50,6 @@ namespace PolyTrader.Modules.ResolutionFarming.Ui RefreshHistory(); } - // ---------------- UI-Aufbau ---------------- - - private static DataGridView NewGrid() => new() - { - Dock = DockStyle.Fill, - ReadOnly = true, - AllowUserToAddRows = false, - AllowUserToDeleteRows = false, - AutoGenerateColumns = true, - AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells, - SelectionMode = DataGridViewSelectionMode.FullRowSelect, - RowHeadersVisible = false - }; - - private void BuildUi() - { - var tabs = new TabControl { Dock = DockStyle.Fill }; - - // --- Kandidaten --- - var candTab = new TabPage("Kandidaten"); - var candTop = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(4) }; - candTop.Controls.Add(new Label { Text = "Konto:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) }); - candTop.Controls.Add(_candAccount); - var candRefresh = new Button { Text = "Aktualisieren" }; - candRefresh.Click += (_, _) => RefreshCandidates(); - candTop.Controls.Add(candRefresh); - candTab.Controls.Add(_candGrid); - candTab.Controls.Add(candTop); - _candAccount.SelectedIndexChanged += (_, _) => RefreshCandidates(); - - // --- Positionen --- - var posTab = new TabPage("Positionen"); - var posTop = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(4) }; - var posRefresh = new Button { Text = "Aktualisieren" }; - posRefresh.Click += (_, _) => RefreshPositions(); - posTop.Controls.Add(posRefresh); - posTab.Controls.Add(_posGrid); - posTab.Controls.Add(posTop); - - // --- Historie / Statistik --- - var histTab = new TabPage("Historie / Statistik"); - var histTop = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(4) }; - var histRefresh = new Button { Text = "Aktualisieren" }; - histRefresh.Click += (_, _) => RefreshHistory(); - histTop.Controls.Add(histRefresh); - histTab.Controls.Add(_histGrid); - histTab.Controls.Add(_histSummary); - histTab.Controls.Add(histTop); - - // --- Settings --- - var setTab = new TabPage("Settings"); - var setTop = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(4) }; - setTop.Controls.Add(new Label { Text = "Konto:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) }); - setTop.Controls.Add(_settingsAccount); - var setSave = new Button { Text = "Speichern" }; - setSave.Click += (_, _) => SaveSettings(); - setTop.Controls.Add(setSave); - setTab.Controls.Add(_settingsGrid); - setTab.Controls.Add(setTop); - _settingsAccount.SelectedIndexChanged += (_, _) => LoadSettings(); - - tabs.TabPages.AddRange(new[] { candTab, posTab, histTab, setTab }); - Controls.Add(tabs); - Controls.Add(_status); - } - // ---------------- Daten ---------------- private void PopulateAccounts() @@ -132,7 +60,7 @@ namespace PolyTrader.Modules.ResolutionFarming.Ui .Select(a => new AccountItem(a.AccountId, string.IsNullOrEmpty(a.Name) ? $"#{a.AccountId}" : $"{a.Name} (#{a.AccountId}){(a.IsDemo ? " · Demo" : "")}")) .ToList(); - foreach (var combo in new[] { _candAccount, _settingsAccount }) + foreach (var combo in new[] { cbCandAccount, cbSettingsAccount }) { combo.DisplayMember = nameof(AccountItem.Label); combo.ValueMember = nameof(AccountItem.Id); @@ -148,14 +76,14 @@ namespace PolyTrader.Modules.ResolutionFarming.Ui private void RefreshCandidates() { - if (_candidates == null || SelectedAccountId(_candAccount) is not int accId) return; - Guarded("Kandidaten", () => _candGrid.DataSource = _candidates.GetRecent(accId, 200)); + if (_candidates == null || SelectedAccountId(cbCandAccount) is not int accId) return; + Guarded("Kandidaten", () => dgvCandidates.DataSource = _candidates.GetRecent(accId, 200)); } private void RefreshPositions() { if (_positions == null) return; - Guarded("Positionen", () => _posGrid.DataSource = _positions.GetAllOpen()); + Guarded("Positionen", () => dgvPositions.DataSource = _positions.GetAllOpen()); } private void RefreshHistory() @@ -164,8 +92,8 @@ namespace PolyTrader.Modules.ResolutionFarming.Ui Guarded("Historie", () => { var trades = _closed.Find(_ => true); - _histGrid.DataSource = trades; - _histSummary.Text = BuildSummary(trades); + dgvHistory.DataSource = trades; + lblHistSummary.Text = BuildSummary(trades); }); } @@ -187,11 +115,11 @@ namespace PolyTrader.Modules.ResolutionFarming.Ui private void LoadSettings() { - if (_settingsRepo == null || SelectedAccountId(_settingsAccount) is not int accId) return; + if (_settingsRepo == null || SelectedAccountId(cbSettingsAccount) is not int accId) return; Guarded("Settings", () => { _currentSettings = _settingsRepo.Get(accId) ?? new RfSettings { AccountId = accId }; - _settingsGrid.SelectedObject = _currentSettings; + pgSettings.SelectedObject = _currentSettings; SetStatus($"Einstellungen für Konto #{accId}."); }); } @@ -212,7 +140,7 @@ namespace PolyTrader.Modules.ResolutionFarming.Ui catch (Exception ex) { SetStatus($"{what}: DB nicht bereit ({ex.Message}). Migration angewendet?"); } } - private void SetStatus(string text) => _status.Text = text; + private void SetStatus(string text) => lblRfStatus.Text = text; private sealed record AccountItem(int Id, string Label); } diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs index 8e1c9b0..5f22a4f 100644 --- a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs @@ -26,7 +26,8 @@ namespace PolyTrader.Modules.Supervisor.Agent IDecisionJournal journal, IOrderEventLog orderEvents, ITradeLogRepository tradeLog, - DossierService dossiers) + DossierService dossiers, + PolyTrader.Modules.Supervisor.Persistence.ISupervisorCounterfactualRepository? counterfactuals = null) { var reg = new SupervisorToolRegistry(); string logsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); @@ -197,6 +198,25 @@ namespace PolyTrader.Modules.Supervisor.Agent """{"type":"object","properties":{}}""", _ => ArchitectureContext.Load())); + if (counterfactuals != null) + { + reg.Register(new SupervisorTool( + "query_counterfactuals", + "Was wäre aus ABGELEHNTEN BUY-Signalen geworden? Liefert nach Marktauflösung ausgewertete Rejects " + + "(Reason, Signalpreis, Gewinner ja/nein, hypothetischer PnL je Share) — zeigt, ob Risk-Limits Gewinne oder Verluste verhindert haben.", + """{"type":"object","properties":{"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 200, 1, 500); + var rows = counterfactuals.GetRecent(limit).Select(c => new + { + c.SignalId, c.CheckedAt, c.ModuleName, c.AccountId, market = c.MarketQuestion, + reason = c.Reason, price = c.SignalPrice, c.IsWinner, pnlPerShare = c.HypotheticalPnlPerShare + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + } + return reg; } } diff --git a/src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualJob.cs b/src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualJob.cs new file mode 100644 index 0000000..d9349a0 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualJob.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTrader.Core.Persistence; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.Supervisor.Counterfactual +{ + /// + /// Auflösungsstatus-Quelle für Counterfactuals. Interface, damit der Job ohne echte + /// Polymarket-API testbar ist; die Live-Implementierung wickelt den PolymarketApiService. + /// + public interface ICounterfactualResolutionSource + { + Task<(bool isResolved, bool isWinner)> CheckAsync(string marketSlug, string tokenId, CancellationToken ct); + } + + /// Live-Quelle: dünner Adapter um (read-only). + public sealed class ApiCounterfactualResolutionSource : ICounterfactualResolutionSource + { + private readonly PolymarketApiService _api; + public ApiCounterfactualResolutionSource(PolymarketApiService api) => _api = api; + public Task<(bool isResolved, bool isWinner)> CheckAsync(string marketSlug, string tokenId, CancellationToken ct) + => _api.CheckMarketResolutionAsync(marketSlug, tokenId); + } + + /// + /// Counterfactual-Job (S-3): nimmt ABGELEHNTE BUY-Entscheidungen aus dem Journal, deren Markt + /// inzwischen abgelaufen ist, prüft die Auflösung und speichert den hypothetischen Ausgang + /// (sup_counterfactuals; ein Ergebnis je Entscheidung). Läuft alle 6 h, API-schonend gedrosselt. + /// + public sealed class CounterfactualJob : BackgroundService + { + private static readonly TimeSpan Interval = TimeSpan.FromHours(6); + private const int BatchLimit = 100; // API-Last je Lauf begrenzen + + private readonly IDecisionJournal _journal; + private readonly Persistence.ISupervisorCounterfactualRepository _repo; + private readonly ICounterfactualResolutionSource _resolution; + private readonly TerminalLogger _logger; + + public CounterfactualJob(IDecisionJournal journal, Persistence.ISupervisorCounterfactualRepository repo, + ICounterfactualResolutionSource resolution, TerminalLogger logger) + { + _journal = journal; + _repo = repo; + _resolution = resolution; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); // nach den Trading-Services + _logger.Info("Counterfactual-Job gestartet (abgelehnte Signale vs. Marktausgang)."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + int added = await CheckOnceAsync(stoppingToken); + if (added > 0) + _logger.Info($"🔮 [Counterfactual] {added} abgelehnte Signale nach Auflösung ausgewertet."); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Error($"Counterfactual-Job Fehler: {ex.Message}"); } + + await Task.Delay(Interval, stoppingToken); + } + } + + /// Testbarer Kern: einen Prüf-Durchlauf ausführen. Liefert die Anzahl neuer Ergebnisse. + internal async Task CheckOnceAsync(CancellationToken ct) + { + DateTime now = DateTime.UtcNow; + var candidates = _journal.Query(d => + d.Decision == TradeDecision.Rejected && + d.Side == "BUY" && + d.MarketEndDate != null && d.MarketEndDate < now && + d.MarketSlug != "" && d.TokenId != "", BatchLimit * 3) + .ToList(); + if (candidates.Count == 0) return 0; + + var known = _repo.ExistingDecisionIds(candidates.Select(c => c.Id)); + int added = 0; + + foreach (var d in candidates.Where(c => !known.Contains(c.Id)).Take(BatchLimit)) + { + if (ct.IsCancellationRequested) break; + + (bool isResolved, bool isWinner) result; + try { result = await _resolution.CheckAsync(d.MarketSlug, d.TokenId, ct); } + catch (Exception ex) + { + _logger.Warning($"[Counterfactual] Resolution-Check fehlgeschlagen ({d.MarketSlug}): {ex.Message}"); + continue; + } + if (!result.isResolved) continue; // noch nicht aufgelöst → späterer Lauf + + _repo.Insert(new CounterfactualRecord + { + DecisionId = d.Id, + SignalId = d.SignalId, + ModuleName = d.ModuleName, + AccountId = d.AccountId, + TokenId = d.TokenId, + MarketSlug = d.MarketSlug, + MarketQuestion = d.MarketQuestion, + Reason = d.Reason.ToString(), + SignalPrice = d.SignalPrice, + IsWinner = result.isWinner, + HypotheticalPnlPerShare = CounterfactualMath.PnlPerShare(d.SignalPrice, result.isWinner) + }); + added++; + } + return added; + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualRecord.cs b/src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualRecord.cs new file mode 100644 index 0000000..5de7b81 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Counterfactual/CounterfactualRecord.cs @@ -0,0 +1,43 @@ +using System; + +namespace PolyTrader.Modules.Supervisor.Counterfactual +{ + /// + /// Counterfactual-Ergebnis (Tabelle sup_counterfactuals): Was wäre aus einem ABGELEHNTEN + /// BUY-Signal geworden, wenn wir gekauft hätten? Wird nach Marktauflösung berechnet — + /// die halbe Strategie-Kalibrierung: verhindern unsere Risk-Limits Verluste oder Gewinne? + /// + public class CounterfactualRecord + { + public long Id { get; set; } // DB-Autoincrement + public DateTime CheckedAt { get; set; } = DateTime.UtcNow; + + /// Journal-Eintrag, auf den sich dieses Ergebnis bezieht (ein Ergebnis je Entscheidung). + public long DecisionId { get; set; } + 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 string MarketSlug { get; set; } = string.Empty; + public string MarketQuestion { get; set; } = string.Empty; + + /// Ablehnungsgrund (ReasonCode als String) — die Auswertedimension. + public string Reason { get; set; } = string.Empty; + + /// Signalpreis zum Ablehnungszeitpunkt (Einstieg, den wir NICHT genommen haben). + public decimal SignalPrice { get; set; } + + public bool IsWinner { get; set; } + + /// Hypothetischer PnL je Share: Gewinner (1 − Preis), Verlierer (−Preis). Ohne Fees. + public decimal HypotheticalPnlPerShare { get; set; } + } + + /// Reine Rechenlogik (getestet): hypothetischer PnL je Share eines nicht genommenen BUYs. + public static class CounterfactualMath + { + public static decimal PnlPerShare(decimal signalPrice, bool isWinner) => + isWinner ? 1m - signalPrice : -signalPrice; + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.Designer.cs b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.Designer.cs new file mode 100644 index 0000000..d4a341e --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.Designer.cs @@ -0,0 +1,150 @@ +// +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.Supervisor.Persistence; + +#nullable disable + +namespace PolyTrader.Modules.Supervisor.Persistence.Migrations +{ + [DbContext(typeof(SupervisorDbContext))] + [Migration("20260718091331_AddCounterfactuals")] + partial class AddCounterfactuals + { + /// + 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("PolyTrader.Modules.Supervisor.Counterfactual.CounterfactualRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CheckedAt") + .HasColumnType("datetime(6)"); + + b.Property("DecisionId") + .HasColumnType("bigint"); + + b.Property("HypotheticalPnlPerShare") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("IsWinner") + .HasColumnType("tinyint(1)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("SignalPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.HasKey("Id"); + + b.HasIndex("CheckedAt"); + + b.HasIndex("DecisionId") + .IsUnique(); + + b.HasIndex("Reason"); + + b.ToTable("sup_counterfactuals", (string)null); + }); + + modelBuilder.Entity("PolyTrader.Modules.Supervisor.Persistence.SupervisorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ToolCallCount") + .HasColumnType("int"); + + b.Property("ToolCallsJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("sup_reports", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.cs b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.cs new file mode 100644 index 0000000..4edbe63 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718091331_AddCounterfactuals.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Modules.Supervisor.Persistence.Migrations +{ + /// + public partial class AddCounterfactuals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "sup_counterfactuals", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + CheckedAt = table.Column(type: "datetime(6)", nullable: false), + DecisionId = table.Column(type: "bigint", 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"), + 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"), + Reason = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SignalPrice = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + IsWinner = table.Column(type: "tinyint(1)", nullable: false), + HypotheticalPnlPerShare = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sup_counterfactuals", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_sup_counterfactuals_CheckedAt", + table: "sup_counterfactuals", + column: "CheckedAt"); + + migrationBuilder.CreateIndex( + name: "IX_sup_counterfactuals_DecisionId", + table: "sup_counterfactuals", + column: "DecisionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sup_counterfactuals_Reason", + table: "sup_counterfactuals", + column: "Reason"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "sup_counterfactuals"); + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs index 47bbb57..1e787ac 100644 --- a/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs +++ b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs @@ -22,6 +22,76 @@ namespace PolyTrader.Modules.Supervisor.Persistence.Migrations MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + modelBuilder.Entity("PolyTrader.Modules.Supervisor.Counterfactual.CounterfactualRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CheckedAt") + .HasColumnType("datetime(6)"); + + b.Property("DecisionId") + .HasColumnType("bigint"); + + b.Property("HypotheticalPnlPerShare") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("IsWinner") + .HasColumnType("tinyint(1)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("SignalPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.HasKey("Id"); + + b.HasIndex("CheckedAt"); + + b.HasIndex("DecisionId") + .IsUnique(); + + b.HasIndex("Reason"); + + b.ToTable("sup_counterfactuals", (string)null); + }); + modelBuilder.Entity("PolyTrader.Modules.Supervisor.Persistence.SupervisorReport", b => { b.Property("Id") diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs b/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs index e002f9f..8a97b8d 100644 --- a/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs +++ b/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs @@ -13,6 +13,7 @@ namespace PolyTrader.Modules.Supervisor.Persistence public SupervisorDbContext(DbContextOptions options) : base(options) { } public DbSet Reports => Set(); + public DbSet Counterfactuals => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -28,6 +29,24 @@ namespace PolyTrader.Modules.Supervisor.Persistence e.Property(x => x.ToolCallsJson).HasColumnType("text"); e.HasIndex(x => x.CreatedAt); }); + + b.Entity(e => + { + e.ToTable("sup_counterfactuals"); + 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.Reason).HasMaxLength(50); + e.Property(x => x.SignalPrice).HasPrecision(18, 6); + e.Property(x => x.HypotheticalPnlPerShare).HasPrecision(18, 6); + e.HasIndex(x => x.DecisionId).IsUnique(); // ein Ergebnis je Entscheidung + e.HasIndex(x => x.CheckedAt); + e.HasIndex(x => x.Reason); + }); } } @@ -54,6 +73,50 @@ namespace PolyTrader.Modules.Supervisor.Persistence List GetRecent(int limit); } + /// Counterfactual-Ablage. Write fehlertolerant. + public interface ISupervisorCounterfactualRepository + { + HashSet ExistingDecisionIds(IEnumerable decisionIds); + void Insert(Counterfactual.CounterfactualRecord record); + List GetRecent(int limit); + } + + public class EfSupervisorCounterfactualRepository : ISupervisorCounterfactualRepository + { + private readonly IDbContextFactory _factory; + public EfSupervisorCounterfactualRepository(IDbContextFactory factory) => _factory = factory; + + public HashSet ExistingDecisionIds(IEnumerable decisionIds) + { + var ids = decisionIds.ToList(); + using var ctx = _factory.CreateDbContext(); + return ctx.Counterfactuals.AsNoTracking() + .Where(c => ids.Contains(c.DecisionId)) + .Select(c => c.DecisionId) + .ToHashSet(); + } + + public void Insert(Counterfactual.CounterfactualRecord record) + { + try + { + using var ctx = _factory.CreateDbContext(); + ctx.Counterfactuals.Add(record); + ctx.SaveChanges(); + } + catch (Exception ex) + { + Console.WriteLine($"[Counterfactuals] Write fehlgeschlagen (ignoriert): {ex.Message}"); + } + } + + public List GetRecent(int limit) + { + using var ctx = _factory.CreateDbContext(); + return ctx.Counterfactuals.AsNoTracking().OrderByDescending(c => c.CheckedAt).Take(limit).ToList(); + } + } + public class EfSupervisorReportRepository : ISupervisorReportRepository { private readonly IDbContextFactory _factory; diff --git a/src/PolyTrader.Modules.Supervisor/Services/DailyReportService.cs b/src/PolyTrader.Modules.Supervisor/Services/DailyReportService.cs new file mode 100644 index 0000000..c7c54ef --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Services/DailyReportService.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTrader.Modules.Supervisor.Agent; +using PolyTrader.Modules.Supervisor.Persistence; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.Supervisor.Services +{ + /// + /// Täglicher Supervisor-Bericht (S-3): lässt den Agenten einmal am Tag eine Kurz-Analyse der + /// letzten 24 h erstellen (KPIs, Auffälligkeiten, Fehler) und sendet sie via Threema; zusätzlich + /// als sup_report gespeichert. OPT-IN: läuft nur, wenn POLYTRADER_SUPERVISOR_DAILY die Ziel-Stunde + /// (0–23, lokale Zeit) enthält — und braucht den OpenRouter-Key (sonst still übersprungen). + /// + public sealed class DailyReportService : BackgroundService + { + public const string DailyPrompt = + "Erstelle den täglichen Supervisor-Kurzbericht für die letzten 24 Stunden: " + + "1) KPIs gesamt und je Modul (get_kpis, sinceDays=1), 2) auffällige Fehler/Warnings " + + "(read_logs, level=Error für heute), 3) bemerkenswerte Reject-Häufungen (query_decisions, " + + "sinceHours=24, decision=Rejected). Maximal ~1200 Zeichen, deutsch, stichpunktartig, " + + "mit klarem Fazit (läuft alles normal / Handlungsbedarf X)."; + + private readonly SupervisorAgent _agent; + private readonly ISupervisorReportRepository _reports; + private readonly Func _sendAsync; // Threema-Versand (injizierbar für Tests) + private readonly TerminalLogger _logger; + + public DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports, + ThreemaService threema, TerminalLogger logger) + : this(agent, reports, async msg => await threema.SendMessageAsync(msg), logger) { } + + internal DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports, + Func sendAsync, TerminalLogger logger) + { + _agent = agent; + _reports = reports; + _sendAsync = sendAsync; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + string? hourRaw = Environment.GetEnvironmentVariable("POLYTRADER_SUPERVISOR_DAILY"); + if (string.IsNullOrWhiteSpace(hourRaw)) + { + _logger.Info("Supervisor-Tagesbericht: deaktiviert (POLYTRADER_SUPERVISOR_DAILY nicht gesetzt; Wert = Stunde 0–23)."); + return; + } + if (!int.TryParse(hourRaw, out int hour) || hour is < 0 or > 23) + { + _logger.Warning($"Supervisor-Tagesbericht: ungültige Stunde '{hourRaw}' – deaktiviert."); + return; + } + + _logger.Info($"Supervisor-Tagesbericht aktiv: täglich um {hour:D2}:00 (lokal)."); + while (!stoppingToken.IsCancellationRequested) + { + var now = DateTime.Now; + var next = now.Date.AddHours(hour); + if (next <= now) next = next.AddDays(1); + try { await Task.Delay(next - now, stoppingToken); } + catch (OperationCanceledException) { break; } + + try { await RunOnceAsync(stoppingToken); } + catch (Exception ex) { _logger.Error($"Supervisor-Tagesbericht fehlgeschlagen: {ex.Message}"); } + } + } + + /// Testbarer Kern: erstellt den Bericht, sendet ihn und legt ihn ab. + internal async Task RunOnceAsync(CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(OpenRouterClient.DefaultApiKeyProvider())) + { + _logger.Info("Supervisor-Tagesbericht: übersprungen (kein OpenRouter-Key)."); + return; + } + + var result = await _agent.AskAsync(DailyPrompt, profile: SupervisorProfiles.Allgemein, ct: ct); + + _reports.Insert(new SupervisorReport + { + Profile = "Tagesbericht", + Model = SupervisorAgent.DefaultModel, + Question = DailyPrompt, + Answer = result.Answer, + ToolCallCount = result.ToolInvocations.Count, + PromptTokens = result.PromptTokens, + CompletionTokens = result.CompletionTokens + }); + + try { await _sendAsync($"📋 Supervisor-Tagesbericht\n\n{result.Answer}"); } + catch (Exception ex) { _logger.Error($"Tagesbericht-Versand fehlgeschlagen: {ex.Message}"); } + + _logger.Info("📋 Supervisor-Tagesbericht erstellt und versendet."); + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs index 50ed606..6e40f37 100644 --- a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs +++ b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs @@ -30,7 +30,8 @@ namespace PolyTrader.Modules.Supervisor sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddSingleton(_ => new Agent.OpenRouterClient(new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMinutes(3) })); services.AddSingleton(); @@ -40,6 +41,13 @@ namespace PolyTrader.Modules.Supervisor services.AddDbContextFactory(o => o.UseMySql(conn, PolyTrader.Core.Configuration.DatabaseServerVersion.Value)); services.AddSingleton(); + services.AddSingleton(); + + // S-3: Counterfactual-Job (abgelehnte BUYs vs. Marktausgang) + täglicher Threema-Bericht + // (OPT-IN via POLYTRADER_SUPERVISOR_DAILY = Stunde 0–23). + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); // S-4: MCP-Light – exponiert die read-only Tool-Registry für externe KI-Clients // (z. B. Claude Code). OPT-IN via POLYTRADER_MCP_PORT, bindet nur 127.0.0.1. diff --git a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs index 7d85ca8..94c5985 100644 --- a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs +++ b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs @@ -46,6 +46,10 @@ namespace PolyTrader.Modules.Supervisor.Ui this.tbReport = new System.Windows.Forms.TextBox(); this.toolStripReports = new System.Windows.Forms.ToolStrip(); this.btnReportsRefresh = new System.Windows.Forms.ToolStripButton(); + this.tabCounterfactual = new System.Windows.Forms.TabPage(); + this.dgvCounterfactuals = new System.Windows.Forms.DataGridView(); + this.toolStripCf = new System.Windows.Forms.ToolStrip(); + this.btnCfRefresh = new System.Windows.Forms.ToolStripButton(); this.lblStatus = new System.Windows.Forms.Label(); this.tabControlSup.SuspendLayout(); this.tabAnalyse.SuspendLayout(); @@ -65,6 +69,9 @@ namespace PolyTrader.Modules.Supervisor.Ui this.splitReports.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.dgvReports)).BeginInit(); this.toolStripReports.SuspendLayout(); + this.tabCounterfactual.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvCounterfactuals)).BeginInit(); + this.toolStripCf.SuspendLayout(); this.SuspendLayout(); // // tabControlSup @@ -72,6 +79,7 @@ namespace PolyTrader.Modules.Supervisor.Ui this.tabControlSup.Controls.Add(this.tabAnalyse); this.tabControlSup.Controls.Add(this.tabDossiers); this.tabControlSup.Controls.Add(this.tabBerichte); + this.tabControlSup.Controls.Add(this.tabCounterfactual); this.tabControlSup.Dock = System.Windows.Forms.DockStyle.Fill; this.tabControlSup.Location = new System.Drawing.Point(0, 0); this.tabControlSup.Name = "tabControlSup"; @@ -329,6 +337,48 @@ namespace PolyTrader.Modules.Supervisor.Ui this.btnReportsRefresh.Name = "btnReportsRefresh"; this.btnReportsRefresh.Text = "Aktualisieren"; // + // tabCounterfactual + // + this.tabCounterfactual.Controls.Add(this.dgvCounterfactuals); + this.tabCounterfactual.Controls.Add(this.toolStripCf); + this.tabCounterfactual.Location = new System.Drawing.Point(4, 24); + this.tabCounterfactual.Name = "tabCounterfactual"; + this.tabCounterfactual.Padding = new System.Windows.Forms.Padding(3); + this.tabCounterfactual.Size = new System.Drawing.Size(1242, 648); + this.tabCounterfactual.TabIndex = 3; + this.tabCounterfactual.Text = "Counterfactual"; + this.tabCounterfactual.UseVisualStyleBackColor = true; + // + // dgvCounterfactuals + // + this.dgvCounterfactuals.AllowUserToAddRows = false; + this.dgvCounterfactuals.AllowUserToDeleteRows = false; + this.dgvCounterfactuals.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvCounterfactuals.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvCounterfactuals.Location = new System.Drawing.Point(3, 28); + this.dgvCounterfactuals.MultiSelect = false; + this.dgvCounterfactuals.Name = "dgvCounterfactuals"; + this.dgvCounterfactuals.ReadOnly = true; + this.dgvCounterfactuals.RowHeadersVisible = false; + this.dgvCounterfactuals.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvCounterfactuals.Size = new System.Drawing.Size(1236, 617); + this.dgvCounterfactuals.TabIndex = 1; + // + // toolStripCf + // + this.toolStripCf.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.btnCfRefresh}); + this.toolStripCf.Location = new System.Drawing.Point(3, 3); + this.toolStripCf.Name = "toolStripCf"; + this.toolStripCf.Size = new System.Drawing.Size(1236, 25); + this.toolStripCf.TabIndex = 0; + // + // btnCfRefresh + // + this.btnCfRefresh.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnCfRefresh.Name = "btnCfRefresh"; + this.btnCfRefresh.Text = "Aktualisieren"; + // // lblStatus // this.lblStatus.Dock = System.Windows.Forms.DockStyle.Bottom; @@ -373,6 +423,11 @@ namespace PolyTrader.Modules.Supervisor.Ui ((System.ComponentModel.ISupportInitialize)(this.dgvReports)).EndInit(); this.toolStripReports.ResumeLayout(false); this.toolStripReports.PerformLayout(); + this.tabCounterfactual.ResumeLayout(false); + this.tabCounterfactual.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvCounterfactuals)).EndInit(); + this.toolStripCf.ResumeLayout(false); + this.toolStripCf.PerformLayout(); this.ResumeLayout(false); } @@ -407,6 +462,10 @@ namespace PolyTrader.Modules.Supervisor.Ui private System.Windows.Forms.SplitContainer splitReports; private System.Windows.Forms.DataGridView dgvReports; private System.Windows.Forms.TextBox tbReport; + private System.Windows.Forms.TabPage tabCounterfactual; + private System.Windows.Forms.ToolStrip toolStripCf; + private System.Windows.Forms.ToolStripButton btnCfRefresh; + private System.Windows.Forms.DataGridView dgvCounterfactuals; private System.Windows.Forms.Label lblStatus; } } diff --git a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs index b8bf0d9..d4737a6 100644 --- a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs +++ b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs @@ -19,6 +19,7 @@ namespace PolyTrader.Modules.Supervisor.Ui private DossierService? _dossiers; private SupervisorAgent? _agent; private ISupervisorReportRepository? _reports; + private ISupervisorCounterfactualRepository? _counterfactuals; public SupervisorMainForm() { @@ -39,6 +40,7 @@ namespace PolyTrader.Modules.Supervisor.Ui dgvSignals.SelectionChanged += (_, _) => OpenSelectedSignal(); btnReportsRefresh.Click += (_, _) => LoadReports(); dgvReports.SelectionChanged += (_, _) => ShowSelectedReport(); + btnCfRefresh.Click += (_, _) => LoadCounterfactuals(); } public void Initialize(IServiceProvider services) @@ -46,8 +48,10 @@ namespace PolyTrader.Modules.Supervisor.Ui _dossiers = services.GetRequiredService(); _agent = services.GetRequiredService(); _reports = services.GetRequiredService(); + _counterfactuals = services.GetRequiredService(); LoadSignals(); LoadReports(); + LoadCounterfactuals(); AppendChat("System", "Supervisor bereit (read-only). API-Key: POLYTRADER_OPENROUTER_KEY oder Datei openrouter.key. " + "MCP-Light für externe Clients: POLYTRADER_MCP_PORT setzen.", System.Drawing.Color.Gray); } @@ -174,6 +178,21 @@ namespace PolyTrader.Modules.Supervisor.Ui } } + // ===== Counterfactuals ===== + + private void LoadCounterfactuals() + { + if (_counterfactuals == null) return; + try + { + dgvCounterfactuals.DataSource = _counterfactuals.GetRecent(200); + } + catch (Exception ex) + { + lblStatus.Text = $"Counterfactuals nicht lesbar (Migration angewendet?): {ex.Message}"; + } + } + private void ShowSelectedReport() { if (dgvReports.CurrentRow?.DataBoundItem is SupervisorReport r) diff --git a/tests/PolyTrader.Tests/SupervisorS3Tests.cs b/tests/PolyTrader.Tests/SupervisorS3Tests.cs new file mode 100644 index 0000000..939950a --- /dev/null +++ b/tests/PolyTrader.Tests/SupervisorS3Tests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.Supervisor.Agent; +using PolyTrader.Modules.Supervisor.Counterfactual; +using PolyTrader.Modules.Supervisor.Persistence; +using PolyTrader.Tests.Fakes; +using PolyTrader.Tests.TestSupport; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// Sicherheitsnetz für S-3: Counterfactual-Job (Rejects vs. Marktausgang) und Tagesbericht. + public class SupervisorS3Tests + { + // ----- CounterfactualMath (pur) ----- + + [Theory] + [InlineData(0.95, true, 0.05)] // Gewinner: 1 - 0.95 + [InlineData(0.95, false, -0.95)] // Verlierer: -Preis + [InlineData(0.40, true, 0.60)] + public void PnlPerShare_winner_and_loser(double price, bool winner, double expected) + { + Assert.Equal((decimal)expected, CounterfactualMath.PnlPerShare((decimal)price, winner)); + } + + // ----- CounterfactualJob ----- + + private sealed class FakeResolutionSource : ICounterfactualResolutionSource + { + public Dictionary ByToken { get; } = new(); + public int Calls { get; private set; } + public Task<(bool isResolved, bool isWinner)> CheckAsync(string slug, string tokenId, CancellationToken ct) + { + Calls++; + return Task.FromResult(ByToken.TryGetValue(tokenId, out var r) ? r : (false, false)); + } + } + + private static DecisionRecord RejectedBuy(long id, string token, decimal price, DateTime endDate) => new() + { + Id = id, SignalId = "sig-" + id, ModuleName = "CopyTrading", AccountId = 1, + TokenId = token, MarketSlug = "slug-" + token, MarketQuestion = "Q-" + token, Side = "BUY", + SignalPrice = price, MarketEndDate = endDate, + Decision = TradeDecision.Rejected, Reason = DecisionReason.MaxBuyPriceExceeded + }; + + private static (CounterfactualJob job, FakeDecisionJournal journal, EfSupervisorCounterfactualRepository repo, FakeResolutionSource res) BuildJob() + { + var journal = new FakeDecisionJournal(); + var factory = new InMemoryContextFactory(o => new SupervisorDbContext(o)); + var repo = new EfSupervisorCounterfactualRepository(factory); + var res = new FakeResolutionSource(); + var job = new CounterfactualJob(journal, repo, res, new TerminalLogger()); + return (job, journal, repo, res); + } + + [Fact] + public async Task Job_evaluates_resolved_rejects_and_skips_unresolved() + { + var (job, journal, repo, res) = BuildJob(); + var past = DateTime.UtcNow.AddDays(-1); + journal.Written.Add(RejectedBuy(1, "a", 0.95m, past)); // aufgelöst: Gewinner + journal.Written.Add(RejectedBuy(2, "b", 0.60m, past)); // aufgelöst: Verlierer + journal.Written.Add(RejectedBuy(3, "c", 0.90m, past)); // noch NICHT aufgelöst + journal.Written.Add(RejectedBuy(4, "d", 0.90m, DateTime.UtcNow.AddDays(2))); // Markt läuft noch + res.ByToken["a"] = (true, true); + res.ByToken["b"] = (true, false); + + int added = await job.CheckOnceAsync(CancellationToken.None); + + Assert.Equal(2, added); + var rows = repo.GetRecent(10); + Assert.Equal(2, rows.Count); + var a = rows.Single(r => r.TokenId == "a"); + Assert.True(a.IsWinner); + Assert.Equal(0.05m, a.HypotheticalPnlPerShare); + Assert.Equal("MaxBuyPriceExceeded", a.Reason); + var b = rows.Single(r => r.TokenId == "b"); + Assert.False(b.IsWinner); + Assert.Equal(-0.60m, b.HypotheticalPnlPerShare); + } + + [Fact] + public async Task Job_is_idempotent_per_decision() + { + var (job, journal, repo, res) = BuildJob(); + journal.Written.Add(RejectedBuy(1, "a", 0.95m, DateTime.UtcNow.AddDays(-1))); + res.ByToken["a"] = (true, true); + + Assert.Equal(1, await job.CheckOnceAsync(CancellationToken.None)); + Assert.Equal(0, await job.CheckOnceAsync(CancellationToken.None)); // schon ausgewertet + Assert.Single(repo.GetRecent(10)); + } + + // ----- DailyReportService ----- + + private sealed class OneShotChatClient : IChatCompletionClient + { + public Task CompleteAsync(string model, IReadOnlyList messages, + IReadOnlyList tools, CancellationToken ct) + => Task.FromResult(new ChatResponse { Content = "Alles im grünen Bereich." }); + } + + [Fact] + public async Task DailyReport_runs_agent_saves_report_and_sends() + { + Environment.SetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY", "test-key"); + try + { + var factory = new InMemoryContextFactory(o => new SupervisorDbContext(o)); + var reports = new EfSupervisorReportRepository(factory); + var agent = new SupervisorAgent(new OneShotChatClient(), new SupervisorToolRegistry()); + string? sent = null; + var svc = new PolyTrader.Modules.Supervisor.Services.DailyReportService( + agent, reports, msg => { sent = msg; return Task.CompletedTask; }, new TerminalLogger()); + + await svc.RunOnceAsync(CancellationToken.None); + + Assert.NotNull(sent); + Assert.Contains("Alles im grünen Bereich", sent); + var saved = reports.GetRecent(5); + Assert.Single(saved); + Assert.Equal("Tagesbericht", saved[0].Profile); + } + finally + { + Environment.SetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY", null); + } + } + + [Fact] + public async Task DailyReport_skips_without_api_key() + { + Environment.SetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY", null); + var factory = new InMemoryContextFactory(o => new SupervisorDbContext(o)); + var reports = new EfSupervisorReportRepository(factory); + var agent = new SupervisorAgent(new OneShotChatClient(), new SupervisorToolRegistry()); + bool sent = false; + var svc = new PolyTrader.Modules.Supervisor.Services.DailyReportService( + agent, reports, _ => { sent = true; return Task.CompletedTask; }, new TerminalLogger()); + + await svc.RunOnceAsync(CancellationToken.None); + + Assert.False(sent); + Assert.Empty(reports.GetRecent(5)); + } + } +}