Files
PolyTraderSharp/src/PolyTrader.Web/Endpoints/DashboardEndpoints.cs
T
RichardandClaude Opus 5 d33d695db5 WebUI: Trading beenden, ohne die Anwendung zu beenden
Umsetzung von #563.

Entscheidung Richard, 13.09.2026: Beenden soll ueber das WebUI gehen, aber
beschraenkt auf das Trading. Ein Ende der ganzen Anwendung naehme den Webserver
mit; danach waere das WebUI nicht mehr erreichbar.

POST /api/dashboard/stop-trading setzt Live- und Demo-Modus auf Inaktiv und
schaltet die Pause ein. Damit halten CopyTrading und ResolutionFarming (#569)
an, wie beim Ende der Anwendung. Offene Orders an der Boerse werden bewusst
nicht storniert: Das tut auch das Beenden der App nicht, und ein Eingriff in
die CLOB-Integration gehoert nicht in einen Notschalter. Der Dialog sagt das
ausdruecklich.

Die Route verlangt {"confirmed": true}. Ein Aufruf ohne Absicht, etwa aus einem
alten Skript, beendet nichts. Der Vorgang wird als Warnung protokolliert, mit
den Modi vorher.

Im Dashboard steht unter der Notfall-Pause jetzt "Trading beenden ...". Der
Dialog ist wie im Desktop 10 Sekunden gesperrt, Abbrechen und Esc gehen
jederzeit. Wieder aufgenommen wird nur ueber die Modus-Schalter. Die Pause
aufzuheben genuegt nicht, das haelt ein Test fest.

Offen bis #570: Laufende Verkaufsleitern und der REST-Pre-Redeem pruefen die
Pause nicht. Der Inaktiv-Modus, den dieser Knopf setzt, sperrt den Pre-Redeem
bereits; die Leitern friert erst #570 ein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:51:24 +02:00

297 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Analytics;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
{
public static class DashboardEndpoints
{
public record SetTradingModeRequest(string ModeType, string TargetMode);
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/dashboard");
group.MapGet("/summary", (TradingState state, IServiceProvider sp) =>
{
var vpn = sp.GetService<MullvadVpnService>();
var watchdog = sp.GetService<WatchdogHeartbeatService>();
var accounts = state.Accounts.Values.ToList();
decimal totalAvailable = accounts.Sum(a => a.AvailableBalance);
decimal totalBalance = accounts.Sum(a => a.TotalBalance);
return Results.Ok(new
{
globalTradingPaused = state.GlobalTradingPaused,
liveTradingMode = state.LiveTradingMode.ToString(),
demoTradingMode = state.DemoTradingMode.ToString(),
alchemyHealthy = state.IsAlchemyHealthy,
globalPnl = state.GlobalPnl,
vpnConnected = vpn?.IsConnected ?? false,
vpnEnabled = vpn != null,
watchdogActive = watchdog != null,
accountCount = accounts.Count,
totalUsdcBalance = totalAvailable,
totalPortfolioValue = totalBalance
});
});
group.MapPost("/trading-mode", (SetTradingModeRequest req, TradingState state, TerminalLogger logger) =>
{
if (req.ModeType.Equals("live", System.StringComparison.OrdinalIgnoreCase))
{
if (System.Enum.TryParse<TradingMode>(req.TargetMode, true, out var parsed))
{
state.LiveTradingMode = parsed;
logger.Info($"🌐 WebUI: Live-Trading-Modus auf '{parsed}' geändert.");
return Results.Ok(new { success = true, currentMode = state.LiveTradingMode.ToString() });
}
}
else if (req.ModeType.Equals("demo", System.StringComparison.OrdinalIgnoreCase))
{
if (System.Enum.TryParse<TradingMode>(req.TargetMode, true, out var parsed))
{
state.DemoTradingMode = parsed;
logger.Info($"🌐 WebUI: Demo-Trading-Modus auf '{parsed}' geändert.");
return Results.Ok(new { success = true, currentMode = state.DemoTradingMode.ToString() });
}
}
else if (req.ModeType.Equals("pause", System.StringComparison.OrdinalIgnoreCase))
{
state.GlobalTradingPaused = !state.GlobalTradingPaused;
logger.Warning($"🌐 WebUI: GlobalTradingPaused auf '{state.GlobalTradingPaused}' umgeschaltet.");
return Results.Ok(new { success = true, isPaused = state.GlobalTradingPaused });
}
return Results.BadRequest(new { error = "Ungültiger Modus oder Parametertyp." });
});
// Trading beenden (#563) - das WebUI-Gegenstueck zu "Beenden" im Desktop, beschraenkt auf das
// Trading. Ein Ende der ganzen Anwendung naehme den Webserver mit; danach waere das WebUI nicht
// mehr erreichbar, und nur ein Zugang zur Maschine braechte es zurueck.
//
// Beide Modi auf Inaktiv und die Pause an: Damit halten CopyTrading (Engine, Monitor,
// Verkaufsleitern) und ResolutionFarming (#569) an, wie beim Ende der Anwendung. Offene Orders
// an der Boerse werden bewusst NICHT storniert. Das tut auch das Beenden der App nicht, und
// ein Eingriff in die CLOB-Integration gehoert nicht in einen Notschalter. Wieder
// aufgenommen wird nur ausdruecklich ueber die Modus-Schalter.
//
// "confirmed" ist Pflicht: Ein Aufruf ohne Absicht, etwa aus einem alten Skript oder einem
// doppelt abgeschickten Formular, soll das Trading nicht beenden.
group.MapPost("/stop-trading", (StopTradingRequest? req, TradingState state, TerminalLogger logger) =>
{
if (req?.Confirmed != true)
return Results.BadRequest(new { error = "Bestätigung fehlt – das Trading wurde nicht beendet." });
string liveBefore = state.LiveTradingMode.ToString();
string demoBefore = state.DemoTradingMode.ToString();
bool pausedBefore = state.GlobalTradingPaused;
state.LiveTradingMode = TradingMode.Inactive;
state.DemoTradingMode = TradingMode.Inactive;
state.GlobalTradingPaused = true;
logger.Warning($"🛑 WebUI: Trading beendet (Live {liveBefore} → Inactive, Demo {demoBefore} → Inactive, " +
$"Pause {(pausedBefore ? "war schon an" : "an")}). Anwendung und WebUI laufen weiter; " +
"offene Orders an der Börse bleiben stehen.");
return Results.Ok(new
{
success = true,
liveTradingMode = state.LiveTradingMode.ToString(),
demoTradingMode = state.DemoTradingMode.ToString(),
globalTradingPaused = state.GlobalTradingPaused,
stoppedAt = DateTime.UtcNow
});
});
group.MapGet("/overview-widgets", (IServiceProvider sp) =>
{
var tradeLog = sp.GetService<ITradeLogRepository>();
DateTime now = DateTime.UtcNow;
DateTime today = now.Date;
DateTime week = now.AddDays(-7);
DateTime month = now.AddDays(-30);
// 1. Modul-KPIs
List<TradeRecord> monthTrades;
try { monthTrades = tradeLog?.Find(t => t.ClosedAt >= month) ?? new List<TradeRecord>(); }
catch { monthTrades = new List<TradeRecord>(); }
var moduleNames = monthTrades.Select(t => t.ModuleName).Where(m => !string.IsNullOrEmpty(m)).Distinct().OrderBy(m => m).ToList();
var kpiTiles = new List<object>();
foreach (var module in moduleNames)
{
var moduleTrades = monthTrades.Where(t => t.ModuleName == module).ToList();
decimal todayPnl = moduleTrades.Where(t => t.ClosedAt >= today).Sum(t => t.RealizedPnl);
var k7 = TradeAnalytics.ComputeKpis(moduleTrades.Where(t => t.ClosedAt >= week));
var k30 = TradeAnalytics.ComputeKpis(moduleTrades);
kpiTiles.Add(new
{
moduleName = module,
todayPnl,
pnl7d = k7.NetPnl,
winRate7d = k7.WinRatePct,
count7d = k7.TradeCount,
pnl30d = k30.NetPnl,
winRate30d = k30.WinRatePct,
text = $"Heute: {todayPnl:+0.00;-0.00} USDC\n7T: {k7.NetPnl:+0.00;-0.00} · {k7.WinRatePct:0}% · {k7.TradeCount} Tr.\n30T: {k30.NetPnl:+0.00;-0.00} · {k30.WinRatePct:0}%",
isPositive = k30.NetPnl >= 0
});
}
decimal totalTodayPnl = monthTrades.Where(t => t.ClosedAt >= today).Sum(t => t.RealizedPnl);
var totalK7 = TradeAnalytics.ComputeKpis(monthTrades.Where(t => t.ClosedAt >= week));
var totalK30 = TradeAnalytics.ComputeKpis(monthTrades);
kpiTiles.Add(new
{
moduleName = "Gesamt",
todayPnl = totalTodayPnl,
pnl7d = totalK7.NetPnl,
winRate7d = totalK7.WinRatePct,
count7d = totalK7.TradeCount,
pnl30d = totalK30.NetPnl,
winRate30d = totalK30.WinRatePct,
text = $"Heute: {totalTodayPnl:+0.00;-0.00} USDC\n7T: {totalK7.NetPnl:+0.00;-0.00} · {totalK7.WinRatePct:0}% · {totalK7.TradeCount} Tr.\n30T: {totalK30.NetPnl:+0.00;-0.00} · {totalK30.WinRatePct:0}%",
isPositive = totalK30.NetPnl >= 0
});
// 2. Auffällige Trades (24h)
DateTime since24h = now.AddDays(-1);
List<TradeRecord> dayTrades;
try { dayTrades = tradeLog?.Find(t => t.ClosedAt >= since24h) ?? new List<TradeRecord>(); }
catch { dayTrades = new List<TradeRecord>(); }
var notable = dayTrades
.OrderByDescending(t => Math.Abs(t.RealizedPnl))
.Take(25)
.Select(t => new
{
module = t.ModuleName ?? "",
market = t.MarketQuestion ?? "",
pnl = t.RealizedPnl,
pnlPct = t.PnlPercent,
closedAt = t.ClosedAt
})
.ToList();
// 3. Warnungen & Fehler (heute)
var alerts = new List<object>();
try
{
string logsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
string path = Path.Combine(logsDir, $"{AppTimeZone.Today:yyyy-MM-dd}.jsonl");
if (File.Exists(path))
{
foreach (var line in File.ReadLines(path))
{
var p = LogJson.ParseLine(line);
if (p == null) continue;
if (p.Level != "Error" && p.Level != "Warning") continue;
alerts.Add(new
{
time = DateTimeOffset.TryParse(p.Time, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dt) ? dt.ToString("HH:mm:ss") : p.Time,
level = p.Level,
message = (p.Message ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim()
});
}
alerts.Reverse();
}
else
{
var logger = sp.GetService<TerminalLogger>();
if (logger != null)
{
var memAlerts = logger.GetHistory(TimeSpan.FromHours(24))
.Where(e => e.Level == LogLevel.Warning || e.Level == LogLevel.Error)
.Select(e => new
{
time = e.Timestamp.ToString("HH:mm:ss"),
level = e.Level.ToString(),
message = e.Message.Replace("\r", " ").Replace("\n", " ").Trim()
})
.Reverse()
.Take(100)
.ToList();
alerts.AddRange(memAlerts);
}
}
}
catch { /* stiller Rückzug */ }
// 4. Supervisor-KI (letzter Bericht)
var supervisorReports = sp.GetService<ISupervisorReportRepository>();
object? supervisorSnippet = null;
if (supervisorReports != null)
{
try
{
var last = supervisorReports.GetRecent(1).FirstOrDefault();
if (last != null)
{
supervisorSnippet = new
{
available = true,
createdAt = last.CreatedAt.ToString("dd.MM. HH:mm"),
profile = last.Profile,
model = last.Model,
answer = last.Answer
};
}
else
{
supervisorSnippet = new
{
available = false,
answer = "Noch kein Supervisor-Bericht vorhanden. (OpenRouter-Key setzen und im Supervisor-Fenster eine Analyse starten.)"
};
}
}
catch (Exception ex)
{
supervisorSnippet = new
{
available = false,
answer = $"Supervisor-Berichte nicht verfügbar: {ex.Message}"
};
}
}
else
{
supervisorSnippet = new
{
available = false,
answer = "Supervisor-Modul nicht geladen oder nicht lizenziert."
};
}
return Results.Ok(new
{
moduleKpis = kpiTiles,
notableTrades = notable,
alerts = alerts.Take(100),
supervisorSnippet
});
});
return routes;
}
public record StopTradingRequest(bool Confirmed);
}
}