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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user