Das Dashboard ist keine stumpfe Trade-Liste mehr, sondern ein Auswertungs-Fenster:
- ToolStrip (via Designer): Scope-Filter Konto/Modul/Live-Demo/Zeitraum + Aktualisieren.
- TabControl mit 2 Tabs:
- Dashboard: KPI-Kacheln (Netto-PnL/Winrate/Trades/O-PnL/Profit-Faktor) + 3 Charts
(Equity-Kurve, PnL je Modul, PnL je Tag) fuer den gewaehlten Scope.
- Tradehistorie: gefilterte Trade-Liste (Spalten via Designer) + Suche + Gewinner/Verlierer.
- Charts via ScottPlot CORE-Paket (nur SkiaSharp, .NET-nativ) -> als Bitmap in PictureBoxen
gerendert; KEINE OpenTK/.NET-Framework-Transitiven (bewusst nicht ScottPlot.WinForms).
- Auswertungslogik pur in TradeAnalytics (getestet). In-Memory-Filter auf gecachtem Recent-Set.
- Smoke-UI konstruiert die DashboardView jetzt direkt -> verifiziert das Chart-Rendering headless.
Build 0 Fehler, 331 Tests gruen, --smoke-ui: [OK] core.dashboard konstruiert (inkl. Charts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
474 lines
18 KiB
C#
474 lines
18 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Threading.Channels;
|
||
using PolyTrader.Core.Persistence;
|
||
using PolyTrader.Core.Security;
|
||
using System.Windows.Forms;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Options;
|
||
using PolyTrader.Core.Configuration;
|
||
using PolyTrader.Core.DependencyInjection;
|
||
using PolyTrader.Core.Modularity;
|
||
using PolyTrader.Modules.CopyTrading;
|
||
using PolyTrader.Modules.CopyTrading.Persistence;
|
||
using PolyTrader.Modules.ResolutionFarming;
|
||
using PolyTraderSharp.Models;
|
||
using PolyTraderSharp.Services;
|
||
|
||
namespace PolyTraderSharp;
|
||
|
||
internal static class Program
|
||
{
|
||
public static IHost? AppHost { get; private set; }
|
||
|
||
[STAThread]
|
||
private static void Main(string[] args)
|
||
{
|
||
// Config-Migration aus mongoexport-JSON (Ordner, Default "MongoDB") -> MySQL.
|
||
if (args.Length > 0 && string.Equals(args[0], "--migrate-json", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var folder = args.Length > 1 ? args[1] : "MongoDB";
|
||
RunConfigMigrationFromJson(folder);
|
||
return;
|
||
}
|
||
|
||
// Readback-Verifikation der migrierten Config aus MySQL. Kein UI-Start.
|
||
if (args.Length > 0 && string.Equals(args[0], "--verify-mysql", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
RunVerifyMySql();
|
||
return;
|
||
}
|
||
|
||
// Headless-Smoke-Test der UI: konstruiert jede View + den Launcher (ohne Message-Loop
|
||
// und ohne Trading-/WSS-Services). Fängt Laufzeit-Konstruktionsfehler ab. Kein Fenster.
|
||
if (args.Length > 0 && string.Equals(args[0], "--smoke-ui", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
Environment.ExitCode = RunSmokeUi();
|
||
return;
|
||
}
|
||
|
||
// Diagnose: gibt die MySQL-Serverversion aus (für das ServerVersion-Pinning). Kein UI.
|
||
if (args.Length > 0 && string.Equals(args[0], "--db-version", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
RunDbVersion();
|
||
return;
|
||
}
|
||
|
||
ApplicationConfiguration.Initialize();
|
||
|
||
var modules = new System.Collections.Generic.List<IPolyTraderModule>
|
||
{
|
||
new CopyTradingModule(),
|
||
new ResolutionFarmingModule()
|
||
};
|
||
|
||
AppHost = Host.CreateDefaultBuilder()
|
||
// Config immer neben der EXE suchen (nicht im Arbeitsverzeichnis), damit die App
|
||
// auch beim Start aus bin/ oder per Doppelklick ihre appsettings findet.
|
||
.UseContentRoot(AppContext.BaseDirectory)
|
||
// Host.CreateDefaultBuilder lädt appsettings.Local.json NICHT (nur appsettings.json
|
||
// + appsettings.{Environment}.json). Die gitignorierte Local-Datei hält aber die
|
||
// MySQL-Connection – daher hier explizit ergänzen, sonst bleibt sie leer.
|
||
.ConfigureAppConfiguration((context, config) =>
|
||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||
.ConfigureServices(delegate(HostBuilderContext context, IServiceCollection services)
|
||
{
|
||
var databaseOptions = new DatabaseOptions
|
||
{
|
||
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
|
||
};
|
||
services.Configure<DatabaseOptions>(context.Configuration.GetSection(DatabaseOptions.SectionName));
|
||
services.AddCorePersistence(databaseOptions);
|
||
services.AddSingleton((IServiceProvider sp) => ServerSettings.Load("server_settings.xml"));
|
||
services.AddSingleton<TradingState>();
|
||
services.AddSingleton(delegate(IServiceProvider sp)
|
||
{
|
||
TerminalLogger requiredService2 = sp.GetRequiredService<TerminalLogger>();
|
||
var httpHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2), MaxConnectionsPerServer = 100 };
|
||
return new PolymarketApiService(requiredService2, new HttpClient(httpHandler)
|
||
{
|
||
DefaultRequestHeaders =
|
||
{
|
||
{ "User-Agent", "py_clob_client" },
|
||
{ "Accept", "*/*" }
|
||
}
|
||
});
|
||
});
|
||
services.AddSingleton(delegate(IServiceProvider sp)
|
||
{
|
||
TerminalLogger requiredService2 = sp.GetRequiredService<TerminalLogger>();
|
||
var httpHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2), MaxConnectionsPerServer = 100 };
|
||
return new PolymarketClobClient(requiredService2, new HttpClient(httpHandler)
|
||
{
|
||
DefaultRequestHeaders =
|
||
{
|
||
{ "User-Agent", "py_clob_client" },
|
||
{ "Accept", "*/*" }
|
||
}
|
||
});
|
||
});
|
||
services.AddSingleton<TerminalLogger>();
|
||
services.AddSingleton<MullvadVpnService>();
|
||
services.AddSingleton<ThreemaService>();
|
||
services.AddSingleton<JobManager>();
|
||
|
||
// MUSS als erster HostedService laufen: hydriert den State, bevor die
|
||
// Trading-Services gegen einen leeren State anlaufen.
|
||
services.AddHostedService<StartupHydrationService>();
|
||
|
||
// Module registrieren ihre eigenen Services/Channels/State selbst.
|
||
foreach (var module in modules)
|
||
{
|
||
services.AddSingleton(module);
|
||
module.RegisterServices(services, context.Configuration);
|
||
}
|
||
|
||
services.AddHostedService<MarketSyncService>();
|
||
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<MullvadVpnService>());
|
||
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<ThreemaService>());
|
||
services.AddSingleton<PolyTraderSharp.Ui.ShellUiHost>();
|
||
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
||
}).Build();
|
||
|
||
// F1 (Sicherheit): Master-Key VOR jeder Credential-Entschlüsselung (Hydration) laden.
|
||
// Quelle: Umgebungsvariable POLYTRADER_MASTER_KEY, sonst gitignorierte master.key im App-Ordner.
|
||
ConfigureSecretProtection(AppHost.Services.GetRequiredService<TerminalLogger>());
|
||
|
||
// F5 (Sicherheit): warnen, wenn die (remote) DB-Verbindung keine TLS-Erzwingung hat.
|
||
WarnIfDbTlsNotEnforced(AppHost.Services);
|
||
|
||
try
|
||
{
|
||
// Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen
|
||
// (M3: kein Full-Table-Load mehr über Find(_=>true).Max()).
|
||
var copyState = AppHost.Services.GetRequiredService<CopyTradingState>();
|
||
var tradeLog = AppHost.Services.GetRequiredService<ICopyTradeLogRepository>();
|
||
copyState.TotalCopyTrades = tradeLog.GetMaxTradeId();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// M3: NICHT still schlucken. Bliebe der Zähler bei 0, kollidierten neue TradeIds mit
|
||
// historischen (PK ist ValueGeneratedNever) → stiller Verlust aller Session-Trades.
|
||
var log = AppHost.Services.GetRequiredService<TerminalLogger>();
|
||
log.Error($"❌ KRITISCH: TradeId-Fortsetzung konnte nicht initialisiert werden: {ex.Message}. " +
|
||
"Trade-Nummerierung ist NICHT sicher – neue Trades könnten mit bestehenden kollidieren. Bitte DB prüfen.");
|
||
}
|
||
|
||
AppHost.Start();
|
||
|
||
// F1: einmalige, idempotente Verschlüsselung evtl. vorhandener Klartext-Credentials in der DB.
|
||
ReencryptAccountCredentials(AppHost.Services);
|
||
|
||
// Shell-Views registrieren (Core-App-Views; Module folgen via module.RegisterUi).
|
||
var uiHost = AppHost.Services.GetRequiredService<PolyTraderSharp.Ui.ShellUiHost>();
|
||
var viewServices = AppHost.Services;
|
||
uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView
|
||
{
|
||
Id = "core.terminal",
|
||
Title = "Terminal",
|
||
Group = "Core",
|
||
Order = 10,
|
||
CreateForm = () =>
|
||
{
|
||
var view = new PolyTraderSharp.Ui.Views.TerminalView();
|
||
view.Initialize(viewServices.GetRequiredService<TerminalLogger>());
|
||
return view;
|
||
}
|
||
});
|
||
uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView
|
||
{
|
||
Id = "core.settings",
|
||
Title = "Server Settings",
|
||
Group = "Core",
|
||
Order = 20,
|
||
CreateForm = () =>
|
||
{
|
||
var view = new PolyTraderSharp.Ui.Views.SettingsView();
|
||
view.Initialize(
|
||
viewServices.GetRequiredService<ThreemaService>(),
|
||
viewServices.GetRequiredService<MullvadVpnService>(),
|
||
viewServices.GetRequiredService<TerminalLogger>(),
|
||
viewServices.GetRequiredService<PolyTrader.Core.Persistence.IAccountRepository>(),
|
||
viewServices.GetRequiredService<TradingState>());
|
||
return view;
|
||
}
|
||
});
|
||
uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView
|
||
{
|
||
Id = "core.jobs",
|
||
Title = "Server Jobs",
|
||
Group = "Core",
|
||
Order = 30,
|
||
CreateForm = () =>
|
||
{
|
||
var view = new PolyTraderSharp.Ui.Views.JobsView();
|
||
view.Initialize(viewServices.GetRequiredService<JobManager>());
|
||
return view;
|
||
}
|
||
});
|
||
uiHost.RegisterView(new PolyTrader.Core.Modularity.ModuleView
|
||
{
|
||
Id = "core.dashboard",
|
||
Title = "Dashboard",
|
||
Group = "Core",
|
||
Order = 5,
|
||
CreateForm = () =>
|
||
{
|
||
var view = new PolyTraderSharp.Ui.Views.DashboardView();
|
||
view.Initialize(
|
||
viewServices.GetRequiredService<PolyTrader.Core.Persistence.ITradeLogRepository>(),
|
||
viewServices.GetRequiredService<TradingState>());
|
||
return view;
|
||
}
|
||
});
|
||
// Modul-UI registrieren (Module steuern ihre Views selbst bei).
|
||
foreach (var module in modules)
|
||
{
|
||
module.RegisterUi(uiHost, viewServices);
|
||
}
|
||
|
||
var launcher = AppHost.Services.GetRequiredService<PolyTraderSharp.Ui.LauncherForm>();
|
||
Application.Run(launcher);
|
||
AppHost.StopAsync().GetAwaiter().GetResult();
|
||
}
|
||
|
||
private static void RunConfigMigrationFromJson(string folder)
|
||
{
|
||
var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
||
.SetBasePath(System.IO.Directory.GetCurrentDirectory())
|
||
.AddJsonFile("appsettings.json", optional: true)
|
||
.AddJsonFile("appsettings.Local.json", optional: true)
|
||
.AddEnvironmentVariables()
|
||
.Build();
|
||
|
||
var mySql = config["Database:MySqlConnectionString"] ?? string.Empty;
|
||
if (string.IsNullOrWhiteSpace(mySql))
|
||
{
|
||
Console.WriteLine("FEHLER: Database:MySqlConnectionString fehlt (appsettings.Local.json).");
|
||
return;
|
||
}
|
||
|
||
if (!System.IO.Path.IsPathRooted(folder))
|
||
folder = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), folder);
|
||
|
||
Services.ConfigMigrator.RunFromJson(folder, mySql);
|
||
}
|
||
|
||
private static void RunVerifyMySql()
|
||
{
|
||
var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
||
.SetBasePath(System.IO.Directory.GetCurrentDirectory())
|
||
.AddJsonFile("appsettings.json", optional: true)
|
||
.AddJsonFile("appsettings.Local.json", optional: true)
|
||
.AddEnvironmentVariables()
|
||
.Build();
|
||
|
||
var mySql = config["Database:MySqlConnectionString"] ?? string.Empty;
|
||
if (string.IsNullOrWhiteSpace(mySql))
|
||
{
|
||
Console.WriteLine("FEHLER: Database:MySqlConnectionString fehlt (appsettings.Local.json).");
|
||
return;
|
||
}
|
||
|
||
Services.ConfigMigrator.VerifyMySql(mySql);
|
||
}
|
||
|
||
private static void RunDbVersion()
|
||
{
|
||
var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
||
.SetBasePath(System.IO.Directory.GetCurrentDirectory())
|
||
.AddJsonFile("appsettings.json", optional: true)
|
||
.AddJsonFile("appsettings.Local.json", optional: true)
|
||
.AddEnvironmentVariables()
|
||
.Build();
|
||
|
||
var conn = config["Database:MySqlConnectionString"] ?? string.Empty;
|
||
if (string.IsNullOrWhiteSpace(conn))
|
||
{
|
||
Console.WriteLine("FEHLER: Database:MySqlConnectionString fehlt (appsettings.Local.json).");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
using var c = new MySqlConnector.MySqlConnection(conn);
|
||
c.Open();
|
||
Console.WriteLine($"ServerVersion: {c.ServerVersion}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"FEHLER: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// F1: Lädt den Master-Key (POLYTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert die
|
||
/// at-rest-Verschlüsselung. Ohne Key läuft die App wie bisher mit Klartext – mit deutlicher Warnung
|
||
/// (kein stiller Sicherheitsverlust). Muss VOR jeder Credential-Entschlüsselung laufen.
|
||
/// </summary>
|
||
private static void ConfigureSecretProtection(TerminalLogger logger)
|
||
{
|
||
string? masterKey = Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY");
|
||
if (string.IsNullOrWhiteSpace(masterKey))
|
||
{
|
||
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
|
||
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim();
|
||
}
|
||
SecretProtection.Configure(masterKey);
|
||
|
||
if (SecretProtection.IsConfigured)
|
||
logger.Info("🔐 Secret-Verschlüsselung aktiv – Account-Credentials werden at-rest verschlüsselt (AES-256-GCM).");
|
||
else
|
||
logger.Warning("⚠️ SICHERHEIT: Kein POLYTRADER_MASTER_KEY gesetzt – Account-Credentials liegen UNVERSCHLÜSSELT in der DB. " +
|
||
"Master-Key setzen (env POLYTRADER_MASTER_KEY oder master.key), siehe docs/sicherheit.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// F5: Warnt, wenn der DB-Connection-String keine TLS-Erzwingung (SslMode) enthält. Der String
|
||
/// selbst wird NICHT geloggt (enthält das Passwort) – nur das Fehlen der TLS-Option.
|
||
/// </summary>
|
||
private static void WarnIfDbTlsNotEnforced(IServiceProvider services)
|
||
{
|
||
try
|
||
{
|
||
var config = services.GetService<Microsoft.Extensions.Configuration.IConfiguration>();
|
||
string conn = config?["Database:MySqlConnectionString"] ?? string.Empty;
|
||
if (string.IsNullOrEmpty(conn)) return;
|
||
if (conn.IndexOf("sslmode", StringComparison.OrdinalIgnoreCase) < 0)
|
||
{
|
||
services.GetRequiredService<TerminalLogger>().Warning(
|
||
"⚠️ SICHERHEIT: DB-Verbindung ohne SslMode – Transportverschlüsselung zur (remote) MySQL nicht erzwungen. " +
|
||
"Im Connection-String 'SslMode=Required' (oder VerifyFull) setzen, siehe docs/sicherheit.");
|
||
}
|
||
}
|
||
catch { /* Warnung ist best-effort; darf den Start nie stören */ }
|
||
}
|
||
|
||
/// <summary>
|
||
/// F1: Verschlüsselt einmalig/idempotent evtl. vorhandene Klartext-Credentials in der DB (Alt-Bestand
|
||
/// wird beim Re-Save durch den EF-Converter verschlüsselt). Nur wenn ein Master-Key gesetzt ist.
|
||
/// Fehler blockieren den Start nicht (Log); künftige Saves verschlüsseln ohnehin.
|
||
/// </summary>
|
||
private static void ReencryptAccountCredentials(IServiceProvider services)
|
||
{
|
||
if (!SecretProtection.IsConfigured) return;
|
||
var logger = services.GetRequiredService<TerminalLogger>();
|
||
try
|
||
{
|
||
var accountRepo = services.GetRequiredService<IAccountRepository>();
|
||
var accounts = accountRepo.GetAll(); // Converter entschlüsselt/passt Klartext durch
|
||
foreach (var acc in accounts) accountRepo.Upsert(acc); // Re-Save → Converter verschlüsselt
|
||
logger.Info($"🔐 Account-Credentials at-rest gesichert ({accounts.Count} Account(s)).");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.Error($"Re-Encryption der Account-Credentials fehlgeschlagen: {ex.Message}. " +
|
||
"Stimmt der Master-Key mit den bereits verschlüsselten Daten überein?");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Headless-Smoke-Test: baut einen minimalen Host (Persistenz + State + Modul-Registrierung
|
||
/// + Shell/Launcher), hydriert den State aus MySQL und konstruiert jede registrierte View
|
||
/// sowie den Launcher – ohne Message-Loop und ohne Trading-/WSS-Services zu starten.
|
||
/// Gibt 0 zurück, wenn alles fehlerfrei konstruiert, sonst die Fehleranzahl.
|
||
/// </summary>
|
||
private static int RunSmokeUi()
|
||
{
|
||
ApplicationConfiguration.Initialize();
|
||
|
||
var modules = new System.Collections.Generic.List<IPolyTraderModule> { new CopyTradingModule(), new ResolutionFarmingModule() };
|
||
|
||
using var host = Host.CreateDefaultBuilder()
|
||
.UseContentRoot(AppContext.BaseDirectory)
|
||
.ConfigureAppConfiguration((context, config) =>
|
||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||
.ConfigureServices((context, services) =>
|
||
{
|
||
var databaseOptions = new DatabaseOptions
|
||
{
|
||
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
|
||
};
|
||
services.Configure<DatabaseOptions>(context.Configuration.GetSection(DatabaseOptions.SectionName));
|
||
services.AddCorePersistence(databaseOptions);
|
||
services.AddSingleton<TerminalLogger>();
|
||
services.AddSingleton<TradingState>();
|
||
services.AddSingleton<StartupHydrationService>();
|
||
|
||
foreach (var module in modules)
|
||
{
|
||
services.AddSingleton(module);
|
||
module.RegisterServices(services, context.Configuration);
|
||
}
|
||
|
||
services.AddSingleton<PolyTraderSharp.Ui.ShellUiHost>();
|
||
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
||
}).Build();
|
||
|
||
// F1: Master-Key laden, damit die Hydration verschlüsselte Credentials entschlüsseln kann.
|
||
ConfigureSecretProtection(host.Services.GetRequiredService<TerminalLogger>());
|
||
|
||
// State aus MySQL laden (Accounts/Trader/Settings), ohne die BackgroundServices zu starten.
|
||
try
|
||
{
|
||
host.Services.GetRequiredService<StartupHydrationService>()
|
||
.StartAsync(default).GetAwaiter().GetResult();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"[WARN] Hydration übersprungen: {ex.Message}");
|
||
}
|
||
|
||
var uiHost = host.Services.GetRequiredService<PolyTraderSharp.Ui.ShellUiHost>();
|
||
foreach (var module in modules)
|
||
module.RegisterUi(uiHost, host.Services);
|
||
|
||
int failures = 0;
|
||
Console.WriteLine("=== Smoke-UI: View-Konstruktion ===");
|
||
foreach (var view in uiHost.Views)
|
||
{
|
||
try
|
||
{
|
||
using var form = view.CreateForm();
|
||
Console.WriteLine($"[OK] {view.Id} ({view.Title})");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
using var launcher = host.Services.GetRequiredService<PolyTraderSharp.Ui.LauncherForm>();
|
||
Console.WriteLine("[OK] LauncherForm konstruiert");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] LauncherForm: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
|
||
// Core-Dashboard direkt konstruieren (im Smoke nicht via uiHost registriert) - prueft das ScottPlot-Rendering headless.
|
||
try
|
||
{
|
||
using var dash = new PolyTraderSharp.Ui.Views.DashboardView();
|
||
dash.Initialize(host.Services.GetRequiredService<ITradeLogRepository>(), host.Services.GetRequiredService<TradingState>());
|
||
Console.WriteLine("[OK] core.dashboard konstruiert (inkl. Charts)");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] core.dashboard: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
|
||
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
||
return failures;
|
||
}
|
||
}
|