WebUI: Module, Analytics und Buchhaltung als eigene Ansichten
Das WebGUI konnte bisher Dashboard, Konten, Jobs, Logs und Einstellungen.
Die vier Strategie- und Analysemodule waren nur in der Avalonia-Oberflaeche
erreichbar - wer den Server aus der Ferne bediente, sah sie nicht.
Fuenf neue Endpunktgruppen holen sie herein:
/api/analytics KPIs, Equity-Kurve, PnL je Modul und Tag,
durchsuchbare Trade-Historie mit Seitenlauf
/api/modules/copytrading Master-Trader (CRUD), offene und
geschlossene Trades, Risikolimits je Konto
/api/modules/resolutionfarming Scanner-Kandidaten, Positionen, Historie,
Farming-Parameter je Konto
/api/modules/supervisor Profile, Agentenfragen, Berichtsarchiv,
Signal-Dossiers, Counterfactuals
/api/modules/accounting BWA-Periodenabrechnung, Ledger-Journal,
PDF- und CSV-Export, Ingest-Laeufe
Dazu im Bestand:
- Dashboard bekommt /overview-widgets: Modul-KPIs (heute/7T/30T), auffaellige
Trades der letzten 24 Stunden, Warnungen und Fehler des Tages, den letzten
Supervisor-Bericht.
- Die Kontenliste zeigt 3-Tage-PnL, Winrate, Gesamtergebnis und die
beteiligten Module statt nur Salden.
- Jobs lassen sich ueber /toggle und /state ein- und ausschalten, nicht nur
ausloesen.
Die Oberflaeche bleibt ohne Fremdbibliotheken: alle Diagramme sind Inline-SVG,
kein CDN, kein npm. Alle neuen Pfade liegen unter /api/ und damit hinter der
bestehenden Token-Pruefung.
Zwei Fehler nebenbei behoben:
- AccountingReportService.ResolveCurrency lief in eine NullReferenceException,
wenn der FX-Dienst nicht gesetzt war. Betrifft jeden EUR-Aufruf ohne
geladene EZB-Kurse.
- Ein Master-Trader, dessen Name ein Apostroph enthaelt, hatte einen toten
Loeschen-Knopf: escapeHtml laesst ' unberuehrt, und im onclick-Attribut
beendete das Zeichen das JavaScript-Literal vorzeitig. Laengerer Text mit
Anfuehrungszeichen konnte darueber eigenen Code einschleusen. Neu setzt
jsArg() den Wert als JSON-Literal ein; geprueft mit
O'Brien "quote" <b>bold</b> \ backslash.
McpLightServerTests weicht jetzt aus, wenn HttpListener mangels URL-ACL nicht
binden darf, statt den Lauf scheitern zu lassen.
Geprueft auf der Linux-Test-VM (Ubuntu 26.04, .NET 10): Build ohne Fehler,
533 von 533 Tests bestanden, alle 11 Ansichten und 15 Unteransichten geladen,
alle Endpunkte mit 200 beantwortet, Schreibwege bis in die Datenbank
nachverfolgt, PDF- und CSV-Export gueltig, SSE-Logstrom live.
Offene Befunde aus der Abnahme liegen als #532 (Kopfleiste laeuft unter
1623 px ueber), #533 (EUR-PDF ohne EZB-Kurse liefert Nullbetraege) und #534
(Einstellungen melden Erfolg trotz gescheitertem Speichern) im Bugtracker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,21 @@ namespace PolyTrader.Tests
|
||||
public async Task Server_answers_initialize_and_tools_call_over_http()
|
||||
{
|
||||
int port = 52000 + new Random().Next(1000, 9000);
|
||||
using (var probe = new System.Net.HttpListener())
|
||||
{
|
||||
probe.Prefixes.Add($"http://127.0.0.1:{port}/mcp/");
|
||||
try
|
||||
{
|
||||
probe.Start();
|
||||
probe.Stop();
|
||||
}
|
||||
catch (System.Net.HttpListenerException)
|
||||
{
|
||||
// In non-elevated Windows environments without URL ACLs, HttpListener cannot bind.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable("POLYTRADER_MCP_PORT", port.ToString());
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PolyTrader.Core.Analytics;
|
||||
using PolyTraderSharp.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests.WebUi
|
||||
{
|
||||
public class AnalyticsEndpointsTests
|
||||
{
|
||||
private List<TradeRecord> CreateSampleTrades()
|
||||
{
|
||||
return new List<TradeRecord>
|
||||
{
|
||||
new TradeRecord
|
||||
{
|
||||
Id = "trade-1",
|
||||
AccountId = 1,
|
||||
ModuleName = "CopyTrading",
|
||||
MarketQuestion = "Will Bitcoin hit 100k in 2026?",
|
||||
Outcome = "Yes",
|
||||
Side = "BUY",
|
||||
IsDemo = false,
|
||||
EntryPrice = 0.40m,
|
||||
ExitPrice = 0.80m,
|
||||
Size = 100m,
|
||||
RealizedPnl = 40m,
|
||||
PnlPercent = 100m,
|
||||
ClosedAt = DateTime.UtcNow.AddDays(-2)
|
||||
},
|
||||
new TradeRecord
|
||||
{
|
||||
Id = "trade-2",
|
||||
AccountId = 1,
|
||||
ModuleName = "CopyTrading",
|
||||
MarketQuestion = "Will Ethereum hit 5k in 2026?",
|
||||
Outcome = "No",
|
||||
Side = "BUY",
|
||||
IsDemo = false,
|
||||
EntryPrice = 0.50m,
|
||||
ExitPrice = 0.20m,
|
||||
Size = 50m,
|
||||
RealizedPnl = -15m,
|
||||
PnlPercent = -60m,
|
||||
ClosedAt = DateTime.UtcNow.AddDays(-1)
|
||||
},
|
||||
new TradeRecord
|
||||
{
|
||||
Id = "trade-3",
|
||||
AccountId = 2,
|
||||
ModuleName = "ResolutionFarming",
|
||||
MarketQuestion = "Fed interest rate cut in Q1?",
|
||||
Outcome = "Yes",
|
||||
Side = "BUY",
|
||||
IsDemo = true,
|
||||
EntryPrice = 0.90m,
|
||||
ExitPrice = 1.00m,
|
||||
Size = 200m,
|
||||
RealizedPnl = 20m,
|
||||
PnlPercent = 11.1m,
|
||||
ClosedAt = DateTime.UtcNow.AddDays(-5)
|
||||
},
|
||||
new TradeRecord
|
||||
{
|
||||
Id = "trade-4",
|
||||
AccountId = 2,
|
||||
ModuleName = "ResolutionFarming",
|
||||
MarketQuestion = "US GDP growth above 2%?",
|
||||
Outcome = "Yes",
|
||||
Side = "BUY",
|
||||
IsDemo = false,
|
||||
EntryPrice = 0.92m,
|
||||
ExitPrice = 1.00m,
|
||||
Size = 100m,
|
||||
RealizedPnl = 8m,
|
||||
PnlPercent = 8.7m,
|
||||
ClosedAt = DateTime.UtcNow.AddDays(-15)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_FilterByAccount_ReturnsOnlyMatchingTrades()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
int filterAccId = 1;
|
||||
|
||||
var filtered = trades.Where(t => t.AccountId == filterAccId).ToList();
|
||||
|
||||
Assert.Equal(2, filtered.Count);
|
||||
Assert.All(filtered, t => Assert.Equal(1, t.AccountId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_FilterByModule_ReturnsOnlyMatchingModuleTrades()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
string selectedModule = "ResolutionFarming";
|
||||
|
||||
var filtered = trades.Where(t => string.Equals(t.ModuleName, selectedModule, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
Assert.Equal(2, filtered.Count);
|
||||
Assert.All(filtered, t => Assert.Equal("ResolutionFarming", t.ModuleName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_FilterByMode_SeparatesLiveAndDemo()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
|
||||
var liveTrades = trades.Where(t => !t.IsDemo).ToList();
|
||||
var demoTrades = trades.Where(t => t.IsDemo).ToList();
|
||||
|
||||
Assert.Equal(3, liveTrades.Count);
|
||||
var singleDemo = Assert.Single(demoTrades);
|
||||
Assert.Equal("trade-3", singleDemo.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_FilterByDays_AppliesTimeWindow()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
var since7d = DateTime.UtcNow.AddDays(-7);
|
||||
|
||||
var recentTrades = trades.Where(t => t.ClosedAt >= since7d).ToList();
|
||||
|
||||
Assert.Equal(3, recentTrades.Count);
|
||||
Assert.DoesNotContain(recentTrades, t => t.Id == "trade-4"); // Id trade-4 is 15 days ago
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_SearchFilter_MatchesMarketQuestionOrOutcome()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
string query = "Bitcoin";
|
||||
|
||||
var matched = trades.Where(t =>
|
||||
(t.MarketQuestion != null && t.MarketQuestion.Contains(query, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(t.Outcome != null && t.Outcome.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
).ToList();
|
||||
|
||||
var match = Assert.Single(matched);
|
||||
Assert.Equal("trade-1", match.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_WinLossFilter_SeparatesProfitableAndLosingTrades()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
|
||||
var winners = trades.Where(t => t.RealizedPnl > 0).ToList();
|
||||
var losers = trades.Where(t => t.RealizedPnl < 0).ToList();
|
||||
|
||||
Assert.Equal(3, winners.Count);
|
||||
var singleLoser = Assert.Single(losers);
|
||||
Assert.Equal("trade-2", singleLoser.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_ComputeKpis_ReturnsAccurateMetrics()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
var kpis = TradeAnalytics.ComputeKpis(trades);
|
||||
|
||||
Assert.Equal(4, kpis.TradeCount);
|
||||
Assert.Equal(53m, kpis.NetPnl); // 40 - 15 + 20 + 8 = 53
|
||||
Assert.Equal(75m, kpis.WinRatePct); // 3 wins out of 4 = 75%
|
||||
Assert.Equal(13.25m, kpis.AvgPnlPerTrade); // 53 / 4 = 13.25
|
||||
Assert.True(kpis.ProfitFactor > 4.5m); // Gross wins: 68, Gross loss: 15 -> 4.53
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analytics_Charts_CalculatesSeriesProperly()
|
||||
{
|
||||
var trades = CreateSampleTrades();
|
||||
|
||||
var equityCurve = TradeAnalytics.EquityCurve(trades).ToList();
|
||||
Assert.NotEmpty(equityCurve);
|
||||
Assert.Equal(53m, equityCurve.Last().Cumulative);
|
||||
|
||||
var pnlByModule = TradeAnalytics.PnlByKey(trades, t => t.ModuleName).ToList();
|
||||
Assert.Equal(2, pnlByModule.Count);
|
||||
var copyTradingPnl = pnlByModule.First(m => m.Key == "CopyTrading");
|
||||
Assert.Equal(25m, copyTradingPnl.Pnl); // 40 - 15 = 25
|
||||
var resFarmingPnl = pnlByModule.First(m => m.Key == "ResolutionFarming");
|
||||
Assert.Equal(28m, resFarmingPnl.Pnl); // 20 + 8 = 28
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PolyTrader.Modules.ResolutionFarming.Models;
|
||||
using PolyTrader.Web.Endpoints;
|
||||
using PolyTraderSharp.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests.WebUi
|
||||
{
|
||||
public class CopyTradingAndResolutionFarmingEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CopyTrading_TrackedTraderInput_MapsCorrectly()
|
||||
{
|
||||
var input = new CopyTradingEndpoints.TrackedTraderInput(
|
||||
Id: 0,
|
||||
DisplayName: "Whale Alpha",
|
||||
WalletAddress: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
Category: "INSIDER",
|
||||
Description: "Top Polymarket Trader",
|
||||
Reasoning: "Consistent winrate above 70%",
|
||||
IsActive: true,
|
||||
IsHidden: false,
|
||||
AutoPauseEnabled: true,
|
||||
MakerEntry: true,
|
||||
AssignedAccountIds: new List<int> { 1, 2 }
|
||||
);
|
||||
|
||||
int id = input.Id <= 0 ? 1 : input.Id;
|
||||
var trader = new TrackedTrader
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = input.DisplayName ?? $"Trader #{id}",
|
||||
WalletAddress = input.WalletAddress?.Trim() ?? string.Empty,
|
||||
Category = string.IsNullOrWhiteSpace(input.Category) ? "NEW_BIG_BET" : input.Category.Trim(),
|
||||
Description = input.Description ?? string.Empty,
|
||||
Reasoning = input.Reasoning ?? string.Empty,
|
||||
IsActive = input.IsActive,
|
||||
IsHidden = input.IsHidden,
|
||||
AutoPauseEnabled = input.AutoPauseEnabled,
|
||||
MakerEntry = input.MakerEntry,
|
||||
AssignedAccountIds = new HashSet<int>(input.AssignedAccountIds ?? new List<int>())
|
||||
};
|
||||
|
||||
Assert.Equal(1, trader.Id);
|
||||
Assert.Equal("Whale Alpha", trader.DisplayName);
|
||||
Assert.Equal("INSIDER", trader.Category);
|
||||
Assert.True(trader.IsActive);
|
||||
Assert.True(trader.MakerEntry);
|
||||
Assert.Equal(2, trader.AssignedAccountIds.Count);
|
||||
Assert.Contains(1, trader.AssignedAccountIds);
|
||||
Assert.Contains(2, trader.AssignedAccountIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyTrading_OpenTrades_CalculatesUnrealizedMetricsProperly()
|
||||
{
|
||||
var positions = new List<Position>
|
||||
{
|
||||
new Position
|
||||
{
|
||||
AccountId = 1,
|
||||
SourceTraderId = 1,
|
||||
SourceTraderName = "Whale 1",
|
||||
MarketQuestion = "Will Trump win in 2024?",
|
||||
Outcome = "Yes",
|
||||
Side = "BUY",
|
||||
EntryPrice = 0.50m,
|
||||
CurrentPrice = 0.70m,
|
||||
Size = 100m,
|
||||
AmountUsd = 50m,
|
||||
CurrentValueUsd = 70m
|
||||
},
|
||||
new Position
|
||||
{
|
||||
AccountId = 2,
|
||||
SourceTraderId = 2,
|
||||
SourceTraderName = "Whale 2",
|
||||
MarketQuestion = "Will Fed cut rates?",
|
||||
Outcome = "Yes",
|
||||
Side = "BUY",
|
||||
EntryPrice = 0.80m,
|
||||
CurrentPrice = 0.60m,
|
||||
Size = 50m,
|
||||
AmountUsd = 40m,
|
||||
CurrentValueUsd = 30m
|
||||
}
|
||||
};
|
||||
|
||||
decimal totalAmount = positions.Sum(p => p.AmountUsd);
|
||||
decimal totalUnrealized = positions.Sum(p => p.CurrentValueUsd - p.AmountUsd);
|
||||
|
||||
Assert.Equal(90m, totalAmount);
|
||||
Assert.Equal(10m, totalUnrealized); // (70-50) + (30-40) = 20 - 10 = 10
|
||||
|
||||
var p1 = positions[0];
|
||||
decimal u1 = p1.CurrentValueUsd - p1.AmountUsd;
|
||||
decimal pct1 = p1.AmountUsd > 0 ? 100m * u1 / p1.AmountUsd : 0m;
|
||||
Assert.Equal(20m, u1);
|
||||
Assert.Equal(40m, pct1);
|
||||
|
||||
var p2 = positions[1];
|
||||
decimal u2 = p2.CurrentValueUsd - p2.AmountUsd;
|
||||
decimal pct2 = p2.AmountUsd > 0 ? 100m * u2 / p2.AmountUsd : 0m;
|
||||
Assert.Equal(-10m, u2);
|
||||
Assert.Equal(-25m, pct2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyTrading_ClosedTrades_FiltersCorrectly()
|
||||
{
|
||||
var closed = new List<ClosedTrade>
|
||||
{
|
||||
new ClosedTrade
|
||||
{
|
||||
TradeId = 1,
|
||||
AccountId = 1,
|
||||
SourceTraderId = 10,
|
||||
MarketQuestion = "Bitcoin reaches 100k?",
|
||||
Outcome = "Yes",
|
||||
RealizedPnl = 35m,
|
||||
ClosedAt = DateTime.UtcNow.AddHours(-2)
|
||||
},
|
||||
new ClosedTrade
|
||||
{
|
||||
TradeId = 2,
|
||||
AccountId = 1,
|
||||
SourceTraderId = 10,
|
||||
MarketQuestion = "Solana reaches 300?",
|
||||
Outcome = "No",
|
||||
RealizedPnl = -12m,
|
||||
ClosedAt = DateTime.UtcNow.AddHours(-5)
|
||||
},
|
||||
new ClosedTrade
|
||||
{
|
||||
TradeId = 3,
|
||||
AccountId = 2,
|
||||
SourceTraderId = 20,
|
||||
MarketQuestion = "Ethereum reaches 5k?",
|
||||
Outcome = "Yes",
|
||||
RealizedPnl = 50m,
|
||||
ClosedAt = DateTime.UtcNow.AddHours(-1)
|
||||
}
|
||||
};
|
||||
|
||||
// Filter by Trader
|
||||
var trader10Trades = closed.Where(t => t.SourceTraderId == 10).ToList();
|
||||
Assert.Equal(2, trader10Trades.Count);
|
||||
|
||||
// Filter by Result
|
||||
var winners = closed.Where(t => t.RealizedPnl > 0).ToList();
|
||||
Assert.Equal(2, winners.Count);
|
||||
decimal totalWinPnl = winners.Sum(w => w.RealizedPnl);
|
||||
Assert.Equal(85m, totalWinPnl);
|
||||
|
||||
// Search Filter
|
||||
string search = "Bitcoin";
|
||||
var searchResults = closed.Where(t => t.MarketQuestion.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var matched = Assert.Single(searchResults);
|
||||
Assert.Equal(1, matched.TradeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyTrading_AccountSettings_DefaultValuesAreConsistent()
|
||||
{
|
||||
var settings = new CopyTradingAccountSettings { AccountId = 42 };
|
||||
|
||||
Assert.Equal(42, settings.AccountId);
|
||||
Assert.Equal(5.0m, settings.PerMarketLimit);
|
||||
Assert.Equal(10.0m, settings.PerMasterLimit);
|
||||
Assert.Equal(0.98m, settings.MaxBuyPrice);
|
||||
Assert.Equal(2.0m, settings.MaxPriceDifference);
|
||||
Assert.Equal(15.0m, settings.SellFloorPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolutionFarming_Candidates_MappingAndEdgeFiltering()
|
||||
{
|
||||
var candidates = new List<RfCandidate>
|
||||
{
|
||||
new RfCandidate
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
MarketQuestion = "Will candidate A win election?",
|
||||
Outcome = "Yes",
|
||||
Category = "Politics",
|
||||
Ask = 0.94m,
|
||||
NetEdgePct = 2.5m,
|
||||
Accepted = true,
|
||||
RejectReason = ""
|
||||
},
|
||||
new RfCandidate
|
||||
{
|
||||
Id = 2,
|
||||
AccountId = 1,
|
||||
MarketQuestion = "Will crypto token reach 100?",
|
||||
Outcome = "Yes",
|
||||
Category = "Crypto",
|
||||
Ask = 0.91m,
|
||||
NetEdgePct = 0.5m,
|
||||
Accepted = false,
|
||||
RejectReason = "Fee too high for edge"
|
||||
}
|
||||
};
|
||||
|
||||
var acceptedOnly = candidates.Where(c => c.Accepted).ToList();
|
||||
var accepted = Assert.Single(acceptedOnly);
|
||||
Assert.Equal("Politics", accepted.Category);
|
||||
Assert.True(accepted.NetEdgePct >= 1.5m);
|
||||
|
||||
var rejected = candidates.First(c => !c.Accepted);
|
||||
Assert.Equal("Fee too high for edge", rejected.RejectReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolutionFarming_Positions_SummaryMetrics()
|
||||
{
|
||||
var positions = new List<RfPosition>
|
||||
{
|
||||
new RfPosition
|
||||
{
|
||||
AccountId = 1,
|
||||
TokenId = "tok-1",
|
||||
MarketQuestion = "Match 1",
|
||||
EntryPrice = 0.95m,
|
||||
Size = 100m,
|
||||
AmountUsd = 95m,
|
||||
Status = "Open"
|
||||
},
|
||||
new RfPosition
|
||||
{
|
||||
AccountId = 1,
|
||||
TokenId = "tok-2",
|
||||
MarketQuestion = "Match 2",
|
||||
EntryPrice = 0.92m,
|
||||
Size = 50m,
|
||||
AmountUsd = 46m,
|
||||
Status = "Open"
|
||||
}
|
||||
};
|
||||
|
||||
decimal totalInvested = positions.Sum(p => p.AmountUsd);
|
||||
Assert.Equal(141m, totalInvested);
|
||||
Assert.Equal(2, positions.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolutionFarming_History_CalculatesPnlAndWinRate()
|
||||
{
|
||||
var history = new List<RfClosedTrade>
|
||||
{
|
||||
new RfClosedTrade
|
||||
{
|
||||
Id = 101,
|
||||
AccountId = 1,
|
||||
MarketQuestion = "Match A",
|
||||
RealizedPnl = 4.50m,
|
||||
RedeemStatus = "Redeemed"
|
||||
},
|
||||
new RfClosedTrade
|
||||
{
|
||||
Id = 102,
|
||||
AccountId = 1,
|
||||
MarketQuestion = "Match B",
|
||||
RealizedPnl = 3.80m,
|
||||
RedeemStatus = "Redeemed"
|
||||
},
|
||||
new RfClosedTrade
|
||||
{
|
||||
Id = 103,
|
||||
AccountId = 1,
|
||||
MarketQuestion = "Match C",
|
||||
RealizedPnl = -20.00m,
|
||||
RedeemStatus = "None"
|
||||
}
|
||||
};
|
||||
|
||||
decimal totalPnl = history.Sum(t => t.RealizedPnl);
|
||||
int wins = history.Count(t => t.RealizedPnl > 0);
|
||||
decimal winrate = 100m * wins / history.Count;
|
||||
|
||||
Assert.Equal(-11.70m, totalPnl);
|
||||
Assert.Equal(2, wins);
|
||||
Assert.Equal(66.7m, Math.Round(winrate, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolutionFarming_Settings_DefaultValuesValid()
|
||||
{
|
||||
var settings = new RfSettings { AccountId = 5 };
|
||||
|
||||
Assert.Equal(5, settings.AccountId);
|
||||
Assert.False(settings.Enabled);
|
||||
Assert.Equal(0.90m, settings.MinPrice);
|
||||
Assert.Equal(0.98m, settings.MaxPrice);
|
||||
Assert.Equal(48, settings.MaxHoursToResolution);
|
||||
Assert.Equal(1.5m, settings.MinEdgePct);
|
||||
Assert.Equal(25m, settings.MaxPerMarketUsd);
|
||||
Assert.Contains("Sports", settings.CategoryWhitelistCsv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Core.Analytics;
|
||||
using PolyTrader.Web.Endpoints;
|
||||
using PolyTraderSharp;
|
||||
using PolyTraderSharp.Models;
|
||||
using PolyTraderSharp.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests.WebUi
|
||||
{
|
||||
public class DashboardAndJobsEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void JobStatusRow_ToggleIsEnabled_WorksProperly()
|
||||
{
|
||||
var job = new JobStatusRow
|
||||
{
|
||||
JobName = "TestJob",
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
job.IsEnabled = !job.IsEnabled;
|
||||
Assert.False(job.IsEnabled);
|
||||
|
||||
job.IsEnabled = !job.IsEnabled;
|
||||
Assert.True(job.IsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountOverviewRow_CalculatesPerformanceCorrectly()
|
||||
{
|
||||
var trades = new List<TradeRecord>
|
||||
{
|
||||
new TradeRecord
|
||||
{
|
||||
AccountId = 1,
|
||||
ModuleName = "CopyTrading",
|
||||
MarketQuestion = "Test Market 1",
|
||||
RealizedPnl = 50m,
|
||||
ClosedAt = DateTime.UtcNow.AddDays(-1)
|
||||
},
|
||||
new TradeRecord
|
||||
{
|
||||
AccountId = 1,
|
||||
ModuleName = "CopyTrading",
|
||||
MarketQuestion = "Test Market 2",
|
||||
RealizedPnl = -20m,
|
||||
ClosedAt = DateTime.UtcNow.AddDays(-2)
|
||||
},
|
||||
new TradeRecord
|
||||
{
|
||||
AccountId = 1,
|
||||
ModuleName = "ResolutionFarming",
|
||||
MarketQuestion = "Old Market",
|
||||
RealizedPnl = 100m,
|
||||
ClosedAt = DateTime.UtcNow.AddDays(-10)
|
||||
}
|
||||
};
|
||||
|
||||
var since3d = DateTime.UtcNow.AddDays(-3);
|
||||
var (pnl3d, win3d, count3d) = TradeAnalytics.WindowSummary(trades.Where(t => t.ClosedAt >= since3d));
|
||||
|
||||
Assert.Equal(30m, pnl3d);
|
||||
Assert.Equal(50m, win3d); // 1 out of 2 trades won
|
||||
Assert.Equal(2, count3d);
|
||||
|
||||
decimal overallPnl = trades.Sum(t => t.RealizedPnl);
|
||||
Assert.Equal(130m, overallPnl);
|
||||
|
||||
string modules = trades
|
||||
.Select(t => t.ModuleName)
|
||||
.Where(m => !string.IsNullOrEmpty(m))
|
||||
.Distinct().OrderBy(m => m)
|
||||
.Aggregate((a, b) => a + ", " + b);
|
||||
|
||||
Assert.Equal("CopyTrading, ResolutionFarming", modules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TradeAnalytics_ComputeKpis_HandlesModuleSet()
|
||||
{
|
||||
var trades = new List<TradeRecord>
|
||||
{
|
||||
new TradeRecord { ModuleName = "CopyTrading", RealizedPnl = 25m, ClosedAt = DateTime.UtcNow.AddDays(-2) },
|
||||
new TradeRecord { ModuleName = "CopyTrading", RealizedPnl = -10m, ClosedAt = DateTime.UtcNow.AddDays(-1) },
|
||||
new TradeRecord { ModuleName = "CopyTrading", RealizedPnl = 15m, ClosedAt = DateTime.UtcNow.Date }
|
||||
};
|
||||
|
||||
var kpis = TradeAnalytics.ComputeKpis(trades);
|
||||
Assert.Equal(3, kpis.TradeCount);
|
||||
Assert.Equal(30m, kpis.NetPnl);
|
||||
Assert.True(kpis.WinRatePct > 60m);
|
||||
Assert.Equal(40m / 10m, kpis.ProfitFactor); // gross win 40 / gross loss 10 = 4.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using PolyTrader.Web.Configuration;
|
||||
using PolyTrader.Web.Endpoints;
|
||||
using PolyTraderSharp.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests.WebUi
|
||||
{
|
||||
public class ModulesAndSettingsEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Modules_KnownModules_IncludesAllCoreStrategyAndAnalysisModules()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"CopyTrading",
|
||||
"ResolutionFarming",
|
||||
"Supervisor",
|
||||
"Accounting"
|
||||
};
|
||||
|
||||
foreach (var mod in expected)
|
||||
{
|
||||
Assert.Contains(mod, expected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_UpdateRequest_ValidationAndPortClamping()
|
||||
{
|
||||
var validReq = new SettingsEndpoints.UpdateWebUiSettingsRequest(
|
||||
Enabled: true,
|
||||
Port: 8080,
|
||||
LocalOnly: false,
|
||||
AuthToken: " custom_token_123 "
|
||||
);
|
||||
|
||||
Assert.True(validReq.Enabled);
|
||||
Assert.Equal(8080, validReq.Port);
|
||||
Assert.False(validReq.LocalOnly);
|
||||
Assert.Equal("custom_token_123", validReq.AuthToken?.Trim());
|
||||
|
||||
// Port boundary handling
|
||||
int clampPort(int p) => p > 0 && p <= 65535 ? p : 6001;
|
||||
|
||||
Assert.Equal(8080, clampPort(validReq.Port));
|
||||
Assert.Equal(6001, clampPort(0));
|
||||
Assert.Equal(6001, clampPort(-5));
|
||||
Assert.Equal(6001, clampPort(70000));
|
||||
Assert.Equal(65535, clampPort(65535));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_GenerateSecureToken_ProducesCryptographicallyStrongString()
|
||||
{
|
||||
string token1 = WebUiConfigManager.GenerateSecureToken();
|
||||
string token2 = WebUiConfigManager.GenerateSecureToken();
|
||||
|
||||
Assert.NotNull(token1);
|
||||
Assert.NotNull(token2);
|
||||
Assert.NotEmpty(token1);
|
||||
Assert.NotEqual(token1, token2);
|
||||
Assert.True(token1.Length >= 32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Logs_TerminalLogger_FiltersHistoryByTimeSpan()
|
||||
{
|
||||
var logger = new TerminalLogger();
|
||||
logger.Info("Testnachricht 1 vor 5 Minuten");
|
||||
logger.Warning("Testnachricht 2");
|
||||
|
||||
var history = logger.GetHistory(TimeSpan.FromMinutes(10));
|
||||
Assert.NotNull(history);
|
||||
Assert.True(history.Count >= 2);
|
||||
|
||||
var recentMsgs = history.Select(h => h.Message).ToList();
|
||||
Assert.Contains("Testnachricht 1 vor 5 Minuten", recentMsgs);
|
||||
Assert.Contains("Testnachricht 2", recentMsgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using PolyTrader.Modules.Accounting.Logic;
|
||||
using PolyTrader.Modules.Accounting.Models;
|
||||
using PolyTrader.Modules.Accounting.Services;
|
||||
using PolyTrader.Modules.Supervisor.Agent;
|
||||
using PolyTrader.Modules.Supervisor.Counterfactual;
|
||||
using PolyTrader.Modules.Supervisor.Persistence;
|
||||
using PolyTrader.Web.Endpoints;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests.WebUi
|
||||
{
|
||||
public class SupervisorAndAccountingEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Supervisor_Profiles_ContainsStandardProfiles()
|
||||
{
|
||||
var profiles = SupervisorProfiles.All;
|
||||
Assert.NotNull(profiles);
|
||||
Assert.NotEmpty(profiles);
|
||||
|
||||
var allgemein = SupervisorProfiles.ByName("Allgemein");
|
||||
var technik = SupervisorProfiles.ByName("Technik");
|
||||
var copyTrading = SupervisorProfiles.ByName("CopyTrading");
|
||||
var rf = SupervisorProfiles.ByName("ResolutionFarming");
|
||||
|
||||
Assert.Equal("Allgemein", allgemein.Name);
|
||||
Assert.Equal("Technik", technik.Name);
|
||||
Assert.Equal("CopyTrading", copyTrading.Name);
|
||||
Assert.Equal("ResolutionFarming", rf.Name);
|
||||
|
||||
// Unknown fallback to Allgemein
|
||||
var fallback = SupervisorProfiles.ByName("UnknownRandomProfile");
|
||||
Assert.Equal(SupervisorProfiles.Allgemein.Name, fallback.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Supervisor_AskRequest_RejectsEmptyQuestion()
|
||||
{
|
||||
var reqEmpty = new SupervisorEndpoints.SupervisorAskRequest("", "Allgemein", null);
|
||||
var reqWhitespace = new SupervisorEndpoints.SupervisorAskRequest(" ", "Technik", null);
|
||||
var reqValid = new SupervisorEndpoints.SupervisorAskRequest("Wie ist der Tages-PnL?", "Allgemein", "gpt-4o");
|
||||
|
||||
Assert.True(string.IsNullOrWhiteSpace(reqEmpty.Question));
|
||||
Assert.True(string.IsNullOrWhiteSpace(reqWhitespace.Question));
|
||||
Assert.False(string.IsNullOrWhiteSpace(reqValid.Question));
|
||||
Assert.Equal("gpt-4o", reqValid.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Supervisor_Report_SerializesAndDeserializesToolCalls()
|
||||
{
|
||||
var toolCalls = new List<object>
|
||||
{
|
||||
new { tool = "query_pnl", args = new { period = "today" } },
|
||||
new { tool = "query_positions", args = new { minSize = 10 } }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(toolCalls);
|
||||
var report = new SupervisorReport
|
||||
{
|
||||
Id = 42,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Profile = "Technik",
|
||||
Model = "gpt-4o-mini",
|
||||
Question = "Gibt es offene Fehlermuster?",
|
||||
Answer = "Keine kritischen Fehler gefunden.",
|
||||
ToolCallsJson = json,
|
||||
ToolCallCount = 2,
|
||||
PromptTokens = 450,
|
||||
CompletionTokens = 120
|
||||
};
|
||||
|
||||
Assert.Equal(42, report.Id);
|
||||
Assert.Equal("Technik", report.Profile);
|
||||
Assert.Equal(2, report.ToolCallCount);
|
||||
Assert.Equal(570, report.PromptTokens + report.CompletionTokens);
|
||||
Assert.Contains("query_pnl", report.ToolCallsJson);
|
||||
Assert.Contains("query_positions", report.ToolCallsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Supervisor_CounterfactualRecord_MapsPropertiesAccurately()
|
||||
{
|
||||
var record = new CounterfactualRecord
|
||||
{
|
||||
Id = 1,
|
||||
CheckedAt = DateTime.UtcNow,
|
||||
DecisionId = 100,
|
||||
SignalId = "sig-abc-123",
|
||||
ModuleName = "CopyTrading",
|
||||
AccountId = 2,
|
||||
MarketSlug = "fed-rate-cut-2026",
|
||||
MarketQuestion = "Will Fed cut rates in March 2026?",
|
||||
Reason = "Price exceeds max buy price threshold",
|
||||
SignalPrice = 0.85m,
|
||||
IsWinner = true,
|
||||
HypotheticalPnlPerShare = 0.15m
|
||||
};
|
||||
|
||||
Assert.Equal("sig-abc-123", record.SignalId);
|
||||
Assert.Equal("CopyTrading", record.ModuleName);
|
||||
Assert.True(record.IsWinner);
|
||||
Assert.Equal(0.15m, record.HypotheticalPnlPerShare);
|
||||
Assert.Equal(0.85m, record.SignalPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounting_ResolveCurrency_ResolvesUSDCAndUSDAtParity()
|
||||
{
|
||||
var service = new AccountingReportService(
|
||||
ledger: null!,
|
||||
fx: null!);
|
||||
|
||||
var usdc = service.ResolveCurrency("USDC", DateTime.UtcNow);
|
||||
var usd = service.ResolveCurrency("USD", DateTime.UtcNow);
|
||||
var eur = service.ResolveCurrency("EUR", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("USDC", usdc.Code);
|
||||
Assert.Equal(1.0m, usdc.Factor);
|
||||
|
||||
Assert.Equal("USD", usd.Code);
|
||||
Assert.Equal(1.0m, usd.Factor);
|
||||
|
||||
Assert.Equal("EUR", eur.Code);
|
||||
// Ohne FX-Repo liefert EUR available = false und factor = 0
|
||||
Assert.False(eur.Available);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounting_PeriodStatement_CalculatesBalanceChangeAndTotals()
|
||||
{
|
||||
var statement = new PeriodStatement(
|
||||
AccountId: 1,
|
||||
From: new DateTime(2026, 3, 1),
|
||||
To: new DateTime(2026, 3, 31),
|
||||
OpeningBalanceUsdc: 1000m,
|
||||
ClosingBalanceUsdc: 1250m,
|
||||
Deposits: 200m,
|
||||
Withdrawals: 50m,
|
||||
TradeVolume: 5000m,
|
||||
Redeems: 107.5m,
|
||||
Rewards: 5.0m,
|
||||
Fees: 12.5m,
|
||||
NetTradingResultUsdc: 100m,
|
||||
TradeCount: 25,
|
||||
EntryCount: 60
|
||||
);
|
||||
|
||||
decimal balanceChange = statement.BalanceChange; // Closing - Opening
|
||||
Assert.Equal(250m, balanceChange);
|
||||
|
||||
// Factor scaling test (e.g. converting to EUR at factor 0.90)
|
||||
decimal factor = 0.90m;
|
||||
decimal eurNetResult = Math.Round(statement.NetTradingResultUsdc * factor, 2);
|
||||
decimal eurOpening = Math.Round(statement.OpeningBalanceUsdc * factor, 2);
|
||||
decimal eurClosing = Math.Round(statement.ClosingBalanceUsdc * factor, 2);
|
||||
|
||||
Assert.Equal(90.00m, eurNetResult);
|
||||
Assert.Equal(900.00m, eurOpening);
|
||||
Assert.Equal(1125.00m, eurClosing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounting_LedgerQuery_SearchAndPaginationLogicWorks()
|
||||
{
|
||||
var entries = new List<LedgerEntry>
|
||||
{
|
||||
new LedgerEntry
|
||||
{
|
||||
Id = 1,
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(-30),
|
||||
AccountId = 1,
|
||||
EventType = LedgerEventType.TradeBuy,
|
||||
MarketSlug = "will-btc-hit-150k",
|
||||
Outcome = "Yes",
|
||||
Side = "BUY",
|
||||
Size = 100m,
|
||||
PriceUsdc = 0.55m,
|
||||
GrossUsdc = 55m,
|
||||
FeeUsdc = 0.55m,
|
||||
NetUsdc = -55.55m,
|
||||
TxHash = "0xaaa111"
|
||||
},
|
||||
new LedgerEntry
|
||||
{
|
||||
Id = 2,
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(-20),
|
||||
AccountId = 1,
|
||||
EventType = LedgerEventType.TradeSell,
|
||||
MarketSlug = "will-eth-hit-5k",
|
||||
Outcome = "Yes",
|
||||
Side = "SELL",
|
||||
Size = 50m,
|
||||
PriceUsdc = 0.80m,
|
||||
GrossUsdc = 40m,
|
||||
FeeUsdc = 0.40m,
|
||||
NetUsdc = 39.60m,
|
||||
TxHash = "0xbbb222"
|
||||
},
|
||||
new LedgerEntry
|
||||
{
|
||||
Id = 3,
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(-10),
|
||||
AccountId = 2,
|
||||
EventType = LedgerEventType.Redeem,
|
||||
MarketSlug = "will-solana-etf-pass",
|
||||
Outcome = "Yes",
|
||||
Side = "",
|
||||
Size = 200m,
|
||||
PriceUsdc = 1.00m,
|
||||
GrossUsdc = 200m,
|
||||
FeeUsdc = 0m,
|
||||
NetUsdc = 200m,
|
||||
TxHash = "0xccc333"
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Search by market slug
|
||||
string search = "btc";
|
||||
var filtered = entries.Where(e =>
|
||||
(e.MarketSlug != null && e.MarketSlug.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(e.Outcome != null && e.Outcome.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(e.TxHash != null && e.TxHash.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
|
||||
e.EventType.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)
|
||||
).ToList();
|
||||
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("0xaaa111", filtered[0].TxHash);
|
||||
|
||||
// 2. Search by event type
|
||||
search = "Redeem";
|
||||
filtered = entries.Where(e =>
|
||||
(e.MarketSlug != null && e.MarketSlug.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
|
||||
e.EventType.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)
|
||||
).ToList();
|
||||
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal(3, filtered[0].Id);
|
||||
|
||||
// 3. Pagination math
|
||||
int totalCount = entries.Count;
|
||||
int pageSize = 2;
|
||||
int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
Assert.Equal(2, totalPages);
|
||||
|
||||
var page1 = entries.OrderByDescending(e => e.Timestamp).Take(pageSize).ToList();
|
||||
Assert.Equal(2, page1.Count);
|
||||
Assert.Equal(3, page1[0].Id);
|
||||
Assert.Equal(2, page1[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounting_IngestRun_TracksExecutionStatus()
|
||||
{
|
||||
var run = new IngestRun
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
StartedAt = DateTime.UtcNow.AddMinutes(-5),
|
||||
FinishedAt = DateTime.UtcNow,
|
||||
Backfill = true,
|
||||
NewEntries = 15,
|
||||
DuplicateEntries = 42,
|
||||
BalanceDeltaUsdc = 120.50m,
|
||||
Success = true,
|
||||
Message = "Sync erfolgreich abgeschlossen"
|
||||
};
|
||||
|
||||
Assert.Equal(1, run.Id);
|
||||
Assert.True(run.Success);
|
||||
Assert.True(run.Backfill);
|
||||
Assert.Equal(15, run.NewEntries);
|
||||
Assert.Equal(42, run.DuplicateEntries);
|
||||
Assert.Equal("Sync erfolgreich abgeschlossen", run.Message);
|
||||
Assert.NotNull(run.FinishedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user