Files
PolyTraderSharp/Program.cs
T
RichardandClaude Opus 4.8 bdcd3641e4 Slice 4 (Fable-Fixes): H4/M1/M2/M3-min/M4/M6 + Doku
H4 – Rundungs-Dust-Reject-Schleife:
- SellLogic.RoundToTick (0.001, AwayFromZero, wie CalculateExactOrderAmounts). Die
  Leiter platziert Preise gerundet -> usdc = size × Preis geht exakt auf (kein Dust).
- ProcessLadderAsync bricht ab, wenn pos.Size < MinShares (Dust-Rest gehalten,
  ExitPending=false), statt endlos Sub-Minimum-Orders zu schicken.

M1 – GlobalPnl-Doppelzaehlung: in beiden Live-Close-Bloecken jetzt NUR innerhalb des
_processedClosures-Dedup-Guards gebucht (API-Lag zaehlte sonst doppelt).
M2 – TokenId in beiden Live-Close-Records gesetzt (sonst greift die Dedup nach Neustart nicht).
M3 (Minimum) – TradeId-Init: serverseitiges GetMaxTradeId() statt Full-Table-Find(_=>true).Max();
  Fehlschlag wird laut geloggt statt still geschluckt. (Autoincrement-Migration bewusst als
  Follow-up aufgeschoben – Schema-Aenderung an der Trade-Persistenz erst im Zielland live verifizieren.)
M4 – MongoExportParser ProfitTarget-Default 50 -> 9999 (Take-Profit bleibt dormant).
M6 – Fee-Satz (real oder Kategorie-Fallback, FeeModel.ResolveBps) in alle signierten Orders:
  Engine-BUY, SELL-Leiter (Start/Step/Floor), PreRedeem (WSS + REST). API-gated, im Zielland verifizieren.
Doku – stale [Description]: ProfitTarget/SellFloorPct als IMPLEMENTIERT markiert.

Tests: +10 (RoundToTick, Dust-Abbruch, ResolveBps). Build 0 Fehler, 233 gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:03:00 +02:00

377 lines
13 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.Linq;
using System.Net.Http;
using System.Threading.Channels;
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 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()
};
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();
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();
// 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>
/// 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() };
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();
// 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}");
}
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
return failures;
}
}