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>
297 lines
14 KiB
C#
297 lines
14 KiB
C#
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);
|
||
}
|
||
}
|