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));
+ }
+ }
+}