Author SHA1 Message Date
RichardandClaude Fable 5 8a8a35384a Event-Strings beim Upsert kappen (Marktsync brach komplett ab)
Beim Event-Upsert wurden nur Slug und Title gekappt. Description,
ImageUrl und Tags haben in AppDbContext ebenfalls ein HasMaxLength,
wurden aber ungekuerzt geschrieben. Ein Polymarket-Event mit einer
Beschreibung ueber 4096 Zeichen liess MySQL deshalb den gesamten Batch
mit "Data too long for column 'Description'" ablehnen - damit brach
nicht nur dieses eine Event weg, sondern der komplette Marktsync.

Sichtbar in logs/error/error-20260804.log; in der Dev-DB liegt die
laengste gespeicherte Beschreibung bei 3605 Zeichen, weil alles
Laengere nie durchkam.

Ersetzt die zwei Inline-Kuerzungen durch TruncateEventStrings, analog
zum bereits vorhandenen TruncateMarketStrings.

Regressionstest deckt beide Zweige ab (neues und bestehendes Event).
SQLite erzwingt keine varchar-Laengen, der Test prueft daher die
gespeicherten Laengen statt auf eine ausbleibende Exception.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 12:29:56 +02:00
97 changed files with 5291 additions and 6054 deletions
-6
View File
@@ -1,6 +0,0 @@
# Shell-Skripte immer mit LF auschecken, auch unter Windows.
#
# run.sh wird per CopyToPublishDirectory in das Linux-Paket uebernommen. Mit
# core.autocrlf=true traegt die Arbeitskopie sonst CRLF, und die Zeile
# "#!/usr/bin/env bash\r" laesst den Start dort an "bad interpreter" scheitern.
*.sh text eol=lf
-120
View File
@@ -1,120 +0,0 @@
# Predictalytics
**Zuerst hier nachsehen:**
* [`STATUS.md`](STATUS.md) — wo das Projekt steht, was fertig ist.
* [`ROADMAP.md`](ROADMAP.md) — was ansteht, in welcher Reihenfolge, und welche Konzepte
bewusst zurückgestellt sind.
Die früheren Plandokumente liegen unter [`docs/archiv/`](docs/archiv/) und werden nicht
mehr gepflegt.
Betriebsdokumentation zur Anbindung an das Deployment Center (Lizenz, Watchdog,
Updates, Fehler-Stream): [`docs/BETRIEB-Deploymentcenter.md`](docs/BETRIEB-Deploymentcenter.md).
## Zentraler Bugtracker — Deployment Center
Erfasse unbehandelte Fehler, geplante Verbesserungen und Ideen im zentralen
Deployment Center. Basis-URL: `https://dc.mhdf.de`
### Zugang
Token steht in der Umgebungsvariable `DC_TOKEN`.
Header: `Authorization: Bearer $DC_TOKEN`
Ist `DC_TOKEN` nicht gesetzt, melde nichts und weise stattdessen darauf hin —
die Endpunkte antworten sonst mit `401 unauthorized`.
Die vollständige Schnittstellenbeschreibung liegt maschinenlesbar unter
`GET /api/openapi.json`, das Handbuch unter `/docs/`.
### Projekt
Dieses Projekt ist `predictalytics`.
Repo: `http://192.168.178.10:8418/Richard/Predictalytics`
Fällt dir ein Fehler im Deployment Center selbst auf, melde ihn unter
`deploymentcenter`.
### Etwas melden
`POST /api/bugtracker/v1/report`
```json
{
"project_slug": "predictalytics",
"type": "bug",
"title": "Kurze, aussagekräftige Zusammenfassung",
"description": "Unter welchen Bedingungen tritt es auf?",
"error_message": "Exakte Fehlermeldung",
"stack_trace": "Vollständiger Stacktrace",
"severity": "high",
"environment": "production",
"build_version": "v1.0.0",
"repo_url": "http://192.168.178.10:8418/Richard/Predictalytics",
"git_branch": "main",
"commit_sha": "a21536f",
"file_path": "src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs",
"line_no": 152,
"client_ref": "eindeutige-id-dieses-laufs"
}
```
**Setze immer `client_ref`** — ein wiederholter Aufruf mit demselben Wert legt
kein Duplikat an. Gib nach Möglichkeit `file_path` und `line_no` an; das spart
dem nächsten Agenten das Parsen des Stacktrace.
Schweregrade: `idea` (Gedanke für später), `wishlist` (Backlog),
`low`, `medium`, `high`, `critical`.
### Arbeit übernehmen
Bevor du an einem Item arbeitest, übernimm es — sonst arbeiten zwei Agenten
parallel am selben Problem:
```
POST /api/bugtracker/v1/manage?action=next
Body: {"project_slug": "predictalytics", "limit": 1}
```
Antwortet der Server mit `409 already_claimed`, nimm das nächste Item.
Die Reservierung läuft nach 30 Minuten ab; `action=claim` auf dieselbe ID
erneuert sie.
### Fortschritt festhalten
```
POST /api/bugtracker/v1/manage?action=comment&id=<ID>
Body: {"comment": "Was du herausgefunden hast", "action_taken": "investigated"}
```
`action_taken`: `investigated`, `fix_proposed`, `pr_opened`, `needs_human`,
`blocked`, `commented`.
### Abschließen
```
POST /api/bugtracker/v1/manage?action=resolve&id=<ID>
Body: {"resolved_in_build": "v1.0.1", "resolution_notes": "Was geändert wurde"}
```
Kommst du nicht weiter, gib das Item zurück statt es blockieren zu lassen:
```
POST /api/bugtracker/v1/manage?action=release&id=<ID>
Body: {"note": "Grund"}
```
### Laufzeitfehler
Die Anwendung meldet Error/Fatal selbst über `POST /api/errors/v1/report`
(`Services/DcErrorReporter.cs`) — dort nichts von Hand nachreichen.
### Release melden
Nach einem Release schließen sich Items mit passendem `resolved_in_build`
automatisch:
```
POST /api/updateservice/v1/publish
Body: {"product_slug": "predictalytics", "version": "1.0.1",
"download_url": "...", "sha256_hash": "...", "git_commit": "..."}
```
Die Version in `Directory.Build.props` (`<Version>`) muss dazu passen — sie ist
es, die der Client als installierte Version meldet.
### Fehlerbehandlung
Antworten haben die Form `{"status":"error","error":{"code":"…"}}`.
Reagiere auf `code`, nicht auf den Text:
- `401 unauthorized` — Token prüfen, nicht wiederholen
- `409 already_claimed` — nächstes Item nehmen
- `429 rate_limited` — Intervall verdoppeln, später erneut
-9
View File
@@ -1,17 +1,8 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<!-- Version der Anwendung. Wird von Deploymentcenter.BuildInfo.targets,
dem UpdateService, Fehler-Stream und Heartbeat genutzt. -->
<Version>1.0.0</Version>
<!-- Zielframework zentral fuer alle Projekte. Seit die Avalonia-Shell den
WinForms-Host abgeloest hat, gibt es keine windows-spezifische Ausnahme mehr. -->
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<!-- Die Anwendung wird nicht lokalisiert; lokalisierte Satelliten-Assemblies
der Abhaengigkeiten blaehen nur das Publish-Output auf. -->
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
-82
View File
@@ -1,82 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Erlaubt, transitive Abhaengigkeiten hochzuziehen (siehe Sicherheits-Pins unten). -->
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<!-- Sicherheits-Pins fuer transitive Abhaengigkeiten.
SQLitePCLRaw kommt ueber Microsoft.EntityFrameworkCore.Sqlite (nur Testprojekt).
EF Core 9.0.18 zieht 2.1.10, das GHSA-2m69-gcr7-jv3q (High) aufweist. -->
<ItemGroup>
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
<PackageVersion Include="SQLitePCLRaw.core" Version="2.1.12" />
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="2.1.12" />
</ItemGroup>
<!-- ══════════════════════════════════════════════════════════════════════
Entity Framework Core — bewusst auf 9.x festgehalten.
Pomelo.EntityFrameworkCore.MySql hat keine EF-Core-10-Version; die
neueste stabile 9.0.0 pinnt Microsoft.EntityFrameworkCore.Relational
auf [9.0.0, 9.0.999] — eine harte Obergrenze. Die EF-9-Pakete zielen
auf net8.0 und laufen problemlos auf der net10.0-Runtime.
Vor einem Upgrade auf EF Core 10 zuerst pruefen, ob Pomelo nachgezogen
hat. Siehe docs/PLAN-Linux-Portierung.md, Abschnitt 0.1.
══════════════════════════════════════════════════════════════════════ -->
<ItemGroup>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.18" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.18" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.18" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.18" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.18" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<!-- Microsoft.Extensions — passend zur net10.0-Runtime -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
</ItemGroup>
<!-- Serilog -->
<ItemGroup>
<PackageVersion Include="Serilog" Version="4.4.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
</ItemGroup>
<!-- API-Dokumentation -->
<ItemGroup>
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<!-- Avalonia — Bedienhuelle fuer Windows und Linux.
12.1.1 liefert native net10.0-Assemblies.
Avalonia.Diagnostics (DevTools) gibt es noch nicht fuer 12.x; die
Debug-Referenz bleibt daher auf der 11er-Linie und ist optional. -->
<ItemGroup>
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<!-- Mitgelieferte Schrift: Linux-Distributionen bringen sehr unterschiedliche
UI-Fonts mit, damit sieht die Oberflaeche ueberall gleich aus. -->
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<!-- Test -->
<ItemGroup>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
</ItemGroup>
</Project>
@@ -1,21 +1,5 @@
# FIXPLAN Teil G — Speicher-Budget & Kategorie-Edge (für Gemini 3.5 Flash) # FIXPLAN Teil G — Speicher-Budget & Kategorie-Edge (für Gemini 3.5 Flash)
> **✅ ABGESCHLOSSEN — G1 bis G4 sind umgesetzt.** Am 2026-08-23 gegen den Code geprüft:
> * **G1** Speicher-Governor — `Application/Services/StorageGovernor.cs`, angewendet in
> `TradeRetentionWorker.cs:89`; `MaxDatabaseSizeGb`/`MinRetentionDays` werden gelesen
> (`:74,75`). Tests in `StorageGovernorTests.cs` und `TradeRetentionWorkerTests.cs`.
> * **G2** Retention räumt auch `Aggregated`-Trader — `TradeRetentionWorker.cs`, Test vorhanden.
> * **G3** `UsdcSize` auf `Trade`, `OutcomeIndex` auf `MarketOutcome` — beide persistiert,
> Test in `TradeRepositoryTests.cs`.
> * **G4** Per-Kategorie-Edge — `AvgReturnPct` bis in die Trader-Detailansicht
> (`TraderEndpoints.cs:80`, `app.js:913`).
>
> Der **Anhang** („Einmalige DB-Optimierungen") bleibt offen: das sind SQL-Schritte, die
> laut Absprache der Nutzer selbst gegen die Datenbank fährt, kein Code.
>
> Die unten genannte Test-Baseline (39/1) ist überholt — Stand 2026-08-23 sind es
> **126 grün + 1 übersprungen**.
> Eigenständiger Plan. Von oben nach unten abarbeiten. Nach **jeder** Aufgabe bauen + testen + committen. > Eigenständiger Plan. Von oben nach unten abarbeiten. Nach **jeder** Aufgabe bauen + testen + committen.
--- ---
@@ -147,7 +131,7 @@ In `src/Predictalytics.Worker/Services/TradeRetentionWorker.cs`, Methode `RunOpt
### G1.4 — Config-Defaults ### G1.4 — Config-Defaults
In `src/Predictalytics.Api/appsettings.json` unter `RetentionSettings` (Objekt anlegen falls In `src/Predictalytics.WinFormsHost/appsettings.json` unter `RetentionSettings` (Objekt anlegen falls
fehlt) ergänzen: `"MaxDatabaseSizeGb": 90, "MinRetentionDays": 30`. (Nur Defaults; nichts Bestehendes löschen.) fehlt) ergänzen: `"MaxDatabaseSizeGb": 90, "MinRetentionDays": 30`. (Nur Defaults; nichts Bestehendes löschen.)
**G1 fertig, wenn:** neue Tests grün, alte grün, Build grün. Commit: „G1: storage governor (budget-based retention)". **G1 fertig, wenn:** neue Tests grün, alte grün, Build grün. Commit: „G1: storage governor (budget-based retention)".
+15 -33
View File
@@ -1,27 +1,9 @@
# Fix- und Datenreparatur-Plan (Stand 2026-07-09, Übergabe an Gemini) # Fix- und Datenreparatur-Plan (Stand 2026-07-09, Übergabe an Gemini)
> **Status 2026-08-23: Teil D und Teil F sind umgesetzt**, nachgeprüft gegen den Code. > **Abnahmekriterium für alle Code-Änderungen:** `dotnet test src/Predictalytics.Application.Tests` muss
> Offen sind nur noch drei Detailpunkte, jeweils unten als `- [ ]` stehengeblieben: > **16 grün + 1 übersprungen** liefern (der Skip `CheckpointResetAndReplay_DoesNotDoubleCountBalance` ist eine
> der gezielte Nachlade-Schritt in F5 und die zwei Tests aus F6 (Event-Tag-Vererbung, > dokumentierte, bewusste Entscheidung). Die Assertions der Invarianten-Tests dürfen **nicht** verändert werden —
> Backfill). Belege: > sie definieren das Soll-Verhalten. Wenn ein Test rot wird, ist der Code falsch, nicht der Test.
> * **D3** — `IngestMode` (`Domain/Enums/IngestMode.cs`), Einstufung in
> `TradeHistoryWorker.cs:174-177` inklusive der geforderten Hysterese
> (Rückstufung erst unter halber Schwelle, Richtung `Full` zusätzlich erst nach
> 7 Tagen), wöchentliche Biopsie in `TradeHistoryWorker.cs:127`,
> Aggregations-Buckets in `TradeAggregation.cs`, Trait `not_copyable_hf` in
> `TraderTraitCalculator.cs`. Tests: `IngestModeTests.cs`.
> * **F1/F2** — `CanonicalTags` und `BlacklistTags`/`IsNoiseTag` in
> `Infrastructure/Helpers/MarketCategoryMapper.cs`.
> * **F3/F4** — Tag-Nachladen in `PolymarketProvider.cs:187-196`, Offline-Backfill
> aus Event-Tags in `PredictalyticsHost.cs:114-135`.
>
> **Abnahmekriterium für alle Code-Änderungen:** `dotnet test` muss grün bleiben.
> Die damals notierte Zahl (16 grün + 1 übersprungen) ist überholt — Stand
> 2026-08-23 sind es **126 grün + 1 übersprungen** (der Skip
> `CheckpointResetAndReplay_DoesNotDoubleCountBalance` ist weiterhin eine
> dokumentierte, bewusste Entscheidung). Die Assertions der Invarianten-Tests dürfen
> **nicht** verändert werden — sie definieren das Soll-Verhalten. Wenn ein Test rot
> wird, ist der Code falsch, nicht der Test.
## Hintergrund ## Hintergrund
@@ -113,35 +95,35 @@ Reihenfolge: **D1 → D2/D2b/D2c → D3** (D2c ist klein und gehört in denselbe
### F1. Mapper: kanonischen Tag zuerst, dann Heuristik ### F1. Mapper: kanonischen Tag zuerst, dann Heuristik
- [x] In `MarketCategoryMapper.Map`: **zuerst** prüfen, ob **irgendein Tag exakt** einer bekannten - [ ] In `MarketCategoryMapper.Map`: **zuerst** prüfen, ob **irgendein Tag exakt** einer bekannten
Kategorie entspricht (Polymarkets Tag-Vokabular enthält fast immer den kanonischen Top-Level-Tag: Kategorie entspricht (Polymarkets Tag-Vokabular enthält fast immer den kanonischen Top-Level-Tag:
`Sports`, `Politics`, `Crypto`, `Business`/`Economy`, `Pop Culture`, `Science`, ...). Mapping-Tabelle `Sports`, `Politics`, `Crypto`, `Business`/`Economy`, `Pop Culture`, `Science`, ...). Mapping-Tabelle
Tag→`MarketCategory` (inkl. Synonyme: `Finance`/`Business`→Economy, `Pop Culture`→PopCulture). Tag→`MarketCategory` (inkl. Synonyme: `Finance`/`Business`→Economy, `Pop Culture`→PopCulture).
Erst wenn **kein** kanonischer Tag matcht, die bestehende Keyword-Heuristik auf Frage+Tags anwenden. Erst wenn **kein** kanonischer Tag matcht, die bestehende Keyword-Heuristik auf Frage+Tags anwenden.
- [x] **Kategorie über die gesamte Tag-Menge** bestimmen, nie über `tags[0]`. - [ ] **Kategorie über die gesamte Tag-Menge** bestimmen, nie über `tags[0]`.
### F2. Subcategory: Noise filtern, sinnvoll wählen, leer normalisieren ### F2. Subcategory: Noise filtern, sinnvoll wählen, leer normalisieren
- [x] Organisations-/Müll-Tags herausfiltern (Blacklist: `Hide From New`, `Tournament Futures`, - [ ] Organisations-/Müll-Tags herausfiltern (Blacklist: `Hide From New`, `Tournament Futures`,
`Main Election`, `Recurring`, Jahres-Tags wie `2025 Predictions`, `2026 FIFA World Cup`→ok als Sub?, …). `Main Election`, `Recurring`, Jahres-Tags wie `2025 Predictions`, `2026 FIFA World Cup`→ok als Sub?, …).
- [x] Subcategory = spezifischster **verbleibender** Tag, der **nicht** die Kategorie selbst ist - [ ] Subcategory = spezifischster **verbleibender** Tag, der **nicht** die Kategorie selbst ist
(bei World-Cup-Tags → `Soccer`, nicht `Sports`). Kein passender → leerer String. (bei World-Cup-Tags → `Soccer`, nicht `Sports`). Kein passender → leerer String.
- [x] **NULL/`""` einheitlich als `""`** speichern (behebt die doppelten „Sports/-"-Zeilen: heute - [ ] **NULL/`""` einheitlich als `""`** speichern (behebt die doppelten „Sports/-"-Zeilen: heute
entstehen zwei Gruppen-Keys aus NULL vs. ""). entstehen zwei Gruppen-Keys aus NULL vs. "").
### F3. On-Demand-Markt: Tags nachladen statt `Other` zu speichern ### F3. On-Demand-Markt: Tags nachladen statt `Other` zu speichern
- [x] In `GetMarketAsync`: wenn `parentTags` leer ist, aber ein Event mit Id vorhanden ist → - [ ] In `GetMarketAsync`: wenn `parentTags` leer ist, aber ein Event mit Id vorhanden ist →
**`/events?id=<eventId>` nachladen** (liefert Tags, 1 Extra-Call pro neuem Markt, cachebar) und die **`/events?id=<eventId>` nachladen** (liefert Tags, 1 Extra-Call pro neuem Markt, cachebar) und die
Tags fürs Mapping verwenden. Über den `IRateLimiter` drosseln. Tags fürs Mapping verwenden. Über den `IRateLimiter` drosseln.
- [x] Alternativ/zusätzlich: existiert das Parent-Event bereits in unserer DB (aus dem Events-Sync, - [ ] Alternativ/zusätzlich: existiert das Parent-Event bereits in unserer DB (aus dem Events-Sync,
`Event.Tags` gefüllt) → **Tags von dort erben**, ganz ohne API-Call. `Event.Tags` gefüllt) → **Tags von dort erben**, ganz ohne API-Call.
### F4. Kategorie aus Event-Tags ableiten + Offline-Backfill (der große Hebel) ### F4. Kategorie aus Event-Tags ableiten + Offline-Backfill (der große Hebel)
- [x] Markt-Kategorie primär aus den **Event-Tags** (`market.Event.Tags`) ableiten, nicht aus den - [ ] Markt-Kategorie primär aus den **Event-Tags** (`market.Event.Tags`) ableiten, nicht aus den
(leeren) Markt-Feldern. On-Demand-Märkte erben so die Kategorie ihres Events. (leeren) Markt-Feldern. On-Demand-Märkte erben so die Kategorie ihres Events.
- [x] **Einmaliger Offline-Backfill** (keine API-Calls!): über alle Märkte iterieren, deren Event - [ ] **Einmaliger Offline-Backfill** (keine API-Calls!): über alle Märkte iterieren, deren Event
Tags hat, und Kategorie/Subcategory aus `Event.Tags` neu ableiten (mit F1/F2). Als Methode in Tags hat, und Kategorie/Subcategory aus `Event.Tags` neu ableiten (mit F1/F2). Als Methode in
`RunRecalculateAllTradersAsync` einhängen ODER eigener Dev-Endpoint. Danach `TraderCategoryPerformance` `RunRecalculateAllTradersAsync` einhängen ODER eigener Dev-Endpoint. Danach `TraderCategoryPerformance`
neu rechnen (passiert durch die ohnehin folgende Trader-Neuberechnung). neu rechnen (passiert durch die ohnehin folgende Trader-Neuberechnung).
@@ -154,9 +136,9 @@ Reihenfolge: **D1 → D2/D2b/D2c → D3** (D2c ist klein und gehört in denselbe
### F6. Tests ### F6. Tests
- [x] Mapper: kanonischer Tag gewinnt über Reihenfolge (`"Ethiopia, Elections, ..., Politics"` → Politics; - [ ] Mapper: kanonischer Tag gewinnt über Reihenfolge (`"Ethiopia, Elections, ..., Politics"` → Politics;
`"Sports, Soccer, ..."` → Sports, Subcategory `Soccer`). `"Sports, Soccer, ..."` → Sports, Subcategory `Soccer`).
- [x] Subcategory: Müll-Tags werden gefiltert; NULL und `""` erzeugen denselben Gruppen-Key - [ ] Subcategory: Müll-Tags werden gefiltert; NULL und `""` erzeugen denselben Gruppen-Key
(kein Duplikat mehr). (kein Duplikat mehr).
- [ ] Event-Tag-Vererbung: Markt ohne eigene Tags, Event mit `['Crypto',...]` → Markt wird Crypto. - [ ] Event-Tag-Vererbung: Markt ohne eigene Tags, Event mit `['Crypto',...]` → Markt wird Crypto.
- [ ] Backfill: Markt in DB als `Other`, Event.Tags = `['Politics',...]` → nach Backfill Politics, - [ ] Backfill: Markt in DB als `Other`, Event.Tags = `['Politics',...]` → nach Backfill Politics,
@@ -1,20 +1,5 @@
# FIXPLAN Teil UI — Ranglisten & Showcases sichtbar/korrekt machen # FIXPLAN Teil UI — Ranglisten & Showcases sichtbar/korrekt machen
> **✅ ABGESCHLOSSEN.** Umgesetzt in den Commits *„UI-U3: server-side
> min-winrate/min-copyability filters"* und *„UI-U1/U2/U4/U5: dashboard showcases +
> server-side leaderboard sort"*, beide in `feature/linux-net10` enthalten.
> Am 2026-08-23 Punkt für Punkt gegen den Code nachgeprüft: `loadShowcases()`
> (`app.js:393`) wird aus `loadDashboard()` heraus aufgerufen (`app.js:307`),
> Red Flags heben sich über `showcase-card--warn` ab, `take` steht auf 200 mit
> serverseitigem `sort`-Parameter (`app.js:466,470`), `minWinRate`/`minCopyability`
> gehen bis in `TraderEndpoints.cs:16` durch (Test in `AnalyticsServiceTests.cs:135`),
> `#tradersSort` führt alle sieben Metriken, und der Empty-State steht auf
> `colspan="10"`.
>
> **Offen bleibt nur der als „Optional" markierte Punkt in U5** (Pfeilrichtung
> ▲/▼ in der aktiven Sortierspalte) — die Tabellenköpfe zeigen weiterhin
> statisches ↕. Reine Politur, kein Fehlverhalten.
> Eigenständiger Plan für die Web-UI (`src/Predictalytics.Api/wwwroot/`). Von oben nach unten > Eigenständiger Plan für die Web-UI (`src/Predictalytics.Api/wwwroot/`). Von oben nach unten
> abarbeiten. Nach **jeder** Aufgabe im Browser gegen die laufende API prüfen und committen. > abarbeiten. Nach **jeder** Aufgabe im Browser gegen die laufende API prüfen und committen.
@@ -61,10 +46,10 @@ aber das **Frontend nicht nachgezogen**:
**Ziel:** Die 7 kuratierten Sektionen sichtbar machen — je Sektion Titel, Beschreibung und die Top-Trader **Ziel:** Die 7 kuratierten Sektionen sichtbar machen — je Sektion Titel, Beschreibung und die Top-Trader
als klickbare Kacheln/Zeilen (Klick → `viewTrader(id)`). als klickbare Kacheln/Zeilen (Klick → `viewTrader(id)`).
- [x] **HTML:** In `index.html` im `#page-dashboard` einen Container `<div id="showcaseSections"></div>` - [ ] **HTML:** In `index.html` im `#page-dashboard` einen Container `<div id="showcaseSections"></div>`
ergänzen (unter den bestehenden Dashboard-Karten, vor/nach „Top Traders" — Platzierung so, dass es ergänzen (unter den bestehenden Dashboard-Karten, vor/nach „Top Traders" — Platzierung so, dass es
nicht mit den bestehenden Kacheln kollidiert). nicht mit den bestehenden Kacheln kollidiert).
- [x] **JS:** Neue Funktion `loadShowcases()` in `app.js`: - [ ] **JS:** Neue Funktion `loadShowcases()` in `app.js`:
- `const sections = await api('/api/traders/showcases');` - `const sections = await api('/api/traders/showcases');`
- Response-Shape (camelCase): `[{ key, title, description, traders: [TraderDto, ...] }]`. - Response-Shape (camelCase): `[{ key, title, description, traders: [TraderDto, ...] }]`.
- Pro Sektion eine `card` rendern: `title` als Überschrift, `description` als `page-subtitle`-artiger - Pro Sektion eine `card` rendern: `title` als Überschrift, `description` als `page-subtitle`-artiger
@@ -74,8 +59,8 @@ als klickbare Kacheln/Zeilen (Klick → `viewTrader(id)`).
- Leere Sektionen liefert das Backend gar nicht erst — kein Sonderfall nötig; aber wenn die ganze - Leere Sektionen liefert das Backend gar nicht erst — kein Sonderfall nötig; aber wenn die ganze
Antwort leer/`null` ist, freundlicher Empty-State. Antwort leer/`null` ist, freundlicher Empty-State.
- Jede Trader-Zeile: `onclick="viewTrader(${t.id})"`. - Jede Trader-Zeile: `onclick="viewTrader(${t.id})"`.
- [x] `loadShowcases()` in `loadDashboard()` aufrufen (bzw. beim Aktivieren von `page-dashboard`). - [ ] `loadShowcases()` in `loadDashboard()` aufrufen (bzw. beim Aktivieren von `page-dashboard`).
- [x] **Optik:** `insider_watch` und `red_flags` optisch abheben (z. B. Warn-Akzent für Red Flags über - [ ] **Optik:** `insider_watch` und `red_flags` optisch abheben (z. B. Warn-Akzent für Red Flags über
vorhandene CSS-Variablen `--pnl-negative`), aber im bestehenden Kartenstil bleiben. vorhandene CSS-Variablen `--pnl-negative`), aber im bestehenden Kartenstil bleiben.
**U1 fertig, wenn:** Dashboard zeigt die vom Backend gelieferten Sektionen; Klick auf einen Trader öffnet **U1 fertig, wenn:** Dashboard zeigt die vom Backend gelieferten Sektionen; Klick auf einen Trader öffnet
@@ -88,16 +73,16 @@ die Detailseite; keine Konsolenfehler. Commit: „UI-U1: render dashboard showca
**Ziel:** Die Rangliste zeigt die **echten** Top-N nach der gewählten Metrik, nicht nur eine Umsortierung **Ziel:** Die Rangliste zeigt die **echten** Top-N nach der gewählten Metrik, nicht nur eine Umsortierung
der ersten 100. der ersten 100.
- [x] **JS:** In `loadTraders()` den gewählten Sort als Query-Param an die API hängen: - [ ] **JS:** In `loadTraders()` den gewählten Sort als Query-Param an die API hängen:
`url += '&sort=' + encodeURIComponent(serverSortKey)`. Mapping UI→Server: `url += '&sort=' + encodeURIComponent(serverSortKey)`. Mapping UI→Server:
`score→` (leer/default), `winrate→winrate`, `copyability→copytrading`, `pnl→pnl`, `score→` (leer/default), `winrate→winrate`, `copyability→copytrading`, `pnl→pnl`,
`pnl30d→pnl30d`, `calmar→calmar`, `conviction→conviction`, `profitfactor→profitfactor`. `pnl30d→pnl30d`, `calmar→calmar`, `conviction→conviction`, `profitfactor→profitfactor`.
(Der Server ordnet absteigend — das ist für alle diese Metriken die sinnvolle Richtung.) (Der Server ordnet absteigend — das ist für alle diese Metriken die sinnvolle Richtung.)
- [x] **JS:** Den clientseitigen `data.sort(...)`-Block in `loadTraders()` entfernen bzw. nur noch als - [ ] **JS:** Den clientseitigen `data.sort(...)`-Block in `loadTraders()` entfernen bzw. nur noch als
Fallback für die rein clientseitigen Keys `name`/`platform` behalten (die kennt der Server nicht). Fallback für die rein clientseitigen Keys `name`/`platform` behalten (die kennt der Server nicht).
- [x] **take** von 100 auf einen sinnvollen Wert erhöhen (z. B. 200) — die serverseitige Sortierung liefert - [ ] **take** von 100 auf einen sinnvollen Wert erhöhen (z. B. 200) — die serverseitige Sortierung liefert
jetzt die richtige Reihenfolge, die Tabelle kann eine echte Bestenliste zeigen. jetzt die richtige Reihenfolge, die Tabelle kann eine echte Bestenliste zeigen.
- [x] Die Klick-Sortierung der Tabellenköpfe (`setTraderSort`, `app.js:376`) auf denselben Pfad umstellen: - [ ] Die Klick-Sortierung der Tabellenköpfe (`setTraderSort`, `app.js:376`) auf denselben Pfad umstellen:
Sort setzen → `loadTraders()` (das nun serverseitig sortiert). Die `↕`-Header behalten. Sort setzen → `loadTraders()` (das nun serverseitig sortiert). Die `↕`-Header behalten.
**U2 fertig, wenn:** Auswahl „Calmar/Conviction/Profit-Faktor" (nach U4) verändert die Reihenfolge sichtbar **U2 fertig, wenn:** Auswahl „Calmar/Conviction/Profit-Faktor" (nach U4) verändert die Reihenfolge sichtbar
@@ -109,16 +94,16 @@ und zeigt Trader, die vorher nicht in den ersten 100 waren. Commit: „UI-U2: se
**Ziel:** Die Kennzahl-Mindestfilter dürfen nicht an der 100/200-Grenze abschneiden. **Ziel:** Die Kennzahl-Mindestfilter dürfen nicht an der 100/200-Grenze abschneiden.
- [x] **API:** `IAnalyticsService.GetTradersAsync` und die Implementierung (`AnalyticsService.cs:165`) um - [ ] **API:** `IAnalyticsService.GetTradersAsync` und die Implementierung (`AnalyticsService.cs:165`) um
zwei **optionale nullable** Parameter erweitern: `decimal? minWinRate = null, zwei **optionale nullable** Parameter erweitern: `decimal? minWinRate = null,
decimal? minCopyability = null`. In der In-Memory-Filterkette (dort wird ohnehin schon `highlyCopyable` decimal? minCopyability = null`. In der In-Memory-Filterkette (dort wird ohnehin schon `highlyCopyable`
und `traitFilter` gefiltert) anwenden: und `traitFilter` gefiltert) anwenden:
`if (minWinRate is > 0) traders = traders.Where(t => t.WinRate >= minWinRate).ToList();` und analog `if (minWinRate is > 0) traders = traders.Where(t => t.WinRate >= minWinRate).ToList();` und analog
`t.Analytics?.CopytradingCopyabilityScore >= minCopyability`. `t.Analytics?.CopytradingCopyabilityScore >= minCopyability`.
- [x] **Endpoint:** In `TraderEndpoints.cs` die `/api/traders`-Route um die zwei Query-Parameter durchreichen. - [ ] **Endpoint:** In `TraderEndpoints.cs` die `/api/traders`-Route um die zwei Query-Parameter durchreichen.
- [x] **JS:** In `loadTraders()` `filterWinrateMin`/`filterCopyabilityMin` als Query-Param senden statt - [ ] **JS:** In `loadTraders()` `filterWinrateMin`/`filterCopyabilityMin` als Query-Param senden statt
clientseitig zu filtern; den clientseitigen Filter-Block entfernen. clientseitig zu filtern; den clientseitigen Filter-Block entfernen.
- [x] **Test:** Kleiner Service-Test (SQLite-Muster wie vorhandene `AnalyticsService`/Repository-Tests): - [ ] **Test:** Kleiner Service-Test (SQLite-Muster wie vorhandene `AnalyticsService`/Repository-Tests):
drei Trader mit WinRate 40/60/80, `minWinRate=55` → nur zwei zurück. Baseline bleibt grün. drei Trader mit WinRate 40/60/80, `minWinRate=55` → nur zwei zurück. Baseline bleibt grün.
**U3 fertig, wenn:** Ein hoher Mindest-Copyability-Wert zeigt auch Trader, die in der Default-Order weit **U3 fertig, wenn:** Ein hoher Mindest-Copyability-Wert zeigt auch Trader, die in der Default-Order weit
@@ -130,13 +115,13 @@ hinten stehen. Commit: „UI-U3: server-side min-winrate/min-copyability filters
**Ziel:** Ein einziges, vollständiges Sort-Steuerelement für die Rangliste. **Ziel:** Ein einziges, vollständiges Sort-Steuerelement für die Rangliste.
- [x] **HTML:** `#tradersSort` (`index.html:209`) um die aussagekräftigen Metriken erweitern: - [ ] **HTML:** `#tradersSort` (`index.html:209`) um die aussagekräftigen Metriken erweitern:
`pnl30d` (PnL 30 T), `calmar` (Rendite/Drawdown), `conviction` (Conviction-Edge), `pnl30d` (PnL 30 T), `calmar` (Rendite/Drawdown), `conviction` (Conviction-Edge),
`profitfactor` (Profit-Faktor), `copyability` (bereits da), `score`/`winrate`/`pnl`/`name` behalten. `profitfactor` (Profit-Faktor), `copyability` (bereits da), `score`/`winrate`/`pnl`/`name` behalten.
- [x] **JS:** Klarstellen, welches Dropdown die Traders-Seite steuert. `#tradersSort` bleibt maßgeblich für - [ ] **JS:** Klarstellen, welches Dropdown die Traders-Seite steuert. `#tradersSort` bleibt maßgeblich für
`page-traders`; das globale `#sortSelect` (falls es die Traders-Seite mitsteuert) entkoppeln oder `page-traders`; das globale `#sortSelect` (falls es die Traders-Seite mitsteuert) entkoppeln oder
auf denselben State spiegeln, sodass es **kein** widersprüchliches `currentSort` mehr gibt. auf denselben State spiegeln, sodass es **kein** widersprüchliches `currentSort` mehr gibt.
- [x] Sicherstellen, dass Header-Klick (`setTraderSort`) und Dropdown denselben `currentSort` schreiben und - [ ] Sicherstellen, dass Header-Klick (`setTraderSort`) und Dropdown denselben `currentSort` schreiben und
beide `loadTraders()` (serverseitig, U2) auslösen. beide `loadTraders()` (serverseitig, U2) auslösen.
**U4 fertig, wenn:** Alle Dropdown-Optionen sortieren korrekt (serverseitig), Header-Klick und Dropdown **U4 fertig, wenn:** Alle Dropdown-Optionen sortieren korrekt (serverseitig), Header-Klick und Dropdown
@@ -146,9 +131,9 @@ bleiben synchron. Commit: „UI-U4: unify + extend leaderboard sort control".
## Aufgabe U5 — Kleinkram / Konsistenz ## Aufgabe U5 — Kleinkram / Konsistenz
- [x] **colspan-Fix:** Empty-State in `loadTraders()` (`app.js:407`) von `colspan="11"` auf die echte - [ ] **colspan-Fix:** Empty-State in `loadTraders()` (`app.js:407`) von `colspan="11"` auf die echte
Spaltenzahl **10** korrigieren. Spaltenzahl **10** korrigieren.
- [x] **Spalten-Parität:** Prüfen, dass Tabellenkopf (`index.html:270`) und Zeilen-Template - [ ] **Spalten-Parität:** Prüfen, dass Tabellenkopf (`index.html:270`) und Zeilen-Template
(`app.js:435`) dieselbe Spaltenzahl/-reihenfolge haben. (`app.js:435`) dieselbe Spaltenzahl/-reihenfolge haben.
- [ ] **Optional:** Aktive Sortierspalte im Header visuell markieren (Pfeilrichtung ▲/▼ statt neutralem ↕). - [ ] **Optional:** Aktive Sortierspalte im Header visuell markieren (Pfeilrichtung ▲/▼ statt neutralem ↕).
BIN
View File
Binary file not shown.
+1 -2
View File
@@ -4,9 +4,8 @@
<Project Path="src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj" /> <Project Path="src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj" />
<Project Path="src/Predictalytics.Application/Predictalytics.Application.csproj" /> <Project Path="src/Predictalytics.Application/Predictalytics.Application.csproj" />
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" /> <Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" /> <Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
<Project Path="src/Predictalytics.Shell/Predictalytics.Shell.csproj" /> <Project Path="src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" /> <Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
</Folder> </Folder>
</Solution> </Solution>
-345
View File
@@ -1,345 +0,0 @@
# Predictalytics — Roadmap
> Angelegt am **2026-08-23**. Führt zusammen, was vorher über neun Plandokumente verstreut
> lag. Jeder Punkt hier ist gegen den Code geprüft — was schon umgesetzt war, steht nicht
> mehr drin.
>
> **Ist-Stand des Projekts:** [`STATUS.md`](STATUS.md).
> **Die alten Dokumente** liegen vollständig unter [`docs/archiv/`](docs/archiv/) und
> werden nicht mehr gepflegt. Sie bleiben als Begründungs- und Detailquelle: wo ein Punkt
> unten knapp bleibt, steht die ausführliche Herleitung dort. Die Verweise sind jeweils
> angegeben.
## Fortschritt
| Stufe | Stand |
|---|---|
| 0 — Sofort | ✅ erledigt (2026-08-24) |
| 1 — Aufräumen abschließen | ✅ erledigt (2026-08-24) |
| 2 — Portierung abschließen | offen — **braucht ein Linux-System**, sonst nicht prüfbar |
| 3 — Analytik schärfen | offen, 3.1 ist der nächste inhaltliche Schritt |
| 4 — Datenhaushalt | offen, liegt beim Nutzer (SQL) |
| 5 — Ingest skalieren | offen, erst bei Bedarf |
| 6 — Monetarisierung | Konzept |
## Legende
| | Bedeutung |
|---|---|
| 🔴 | Dringend — sollte vor allem anderen passieren |
| 🟢 | Eingeplant — wird als Nächstes abgearbeitet |
| 🟡 | Backlog — gewollt, aber noch nicht terminiert |
| 💡 | **Konzept — bewusst nicht eingeplant.** Idee ist durchdacht und aufgehoben, die Umsetzung ist nicht beschlossen |
| 👤 | Liegt beim Nutzer, nicht am Code (SQL, Kontoverwaltung, Entscheidungen) |
| ❌ | Bewusst verworfen — nicht wieder aufgreifen ohne neuen Anlass |
Innerhalb der Stufen ist die Reihenfolge gedacht, aber nicht bindend. Zwischen den Stufen
schon: jede baut auf der vorigen auf.
---
## Stufe 0 — Sofort
### 0.1 ✅ OpenRouter-API-Key aus dem Repository entfernt
*Erledigt am 2026-08-24.* Der Schlüssel war laut Nutzer bereits inaktiv und wurde deshalb
ersatzlos aus `src/Predictalytics.Api/appsettings.json` entfernt — eine Rotation war nicht
mehr nötig.
**Für den Ersatz:** den neuen Schlüssel **nicht** in die Datei schreiben, sondern als
Umgebungsvariable `OpenRouter__ApiKey` setzen. `WebApplication.CreateBuilder` liest
Umgebungsvariablen mit Vorrang vor `appsettings.json`, eine Codeänderung braucht es nicht.
Die Datei trägt jetzt einen entsprechenden Hinweis neben dem leeren Feld.
Ohne Schlüssel läuft die Anwendung sauber weiter: `OpenRouterApiClient` sendet gar nicht
erst und meldet den Zustand einmalig als Warnung. Vorher hätte jeder Analyseversuch einen
401 erzeugt, der als Fehler im Deployment Center gelandet wäre und dessen Fehlertext der
Aufrufer anschließend als JSON zu lesen versucht hätte.
> Der alte Schlüssel bleibt im Git-Verlauf (ab Commit `7045002`). Das ist hier folgenlos,
> weil er inaktiv ist — bei einem aktiven Schlüssel wäre die Rotation beim Anbieter der
> einzige wirksame Schritt gewesen.
>
> Herkunft: [`archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) B2 — dort noch als
> „kein akuter Risikofall" eingestuft, was sich als falsch herausgestellt hat.
---
## Stufe 1 — Aufräumen abschließen
### 1.1 ✅ Nach `main` gebracht, Feature-Zweige entfernt
*Erledigt am 2026-08-24.* `main` war als Fast-Forward auf `feature/linux-net10` zu ziehen,
beide stehen jetzt auf `402c372`. Vor dem Push gegen einen vollständigen Rebuild geprüft:
warnungsfrei, 126 Tests grün.
Die zehn Feature-Zweige sind lokal und auf origin gelöscht. Neun waren restlos in `main`
enthalten. Der zehnte, `fix-event-description-truncation`, war es nur inhaltlich — sein Fix
und sein Regressionstest stecken seit `cb58e09` in `main`, der Commit selbst wurde nie
gemergt. Er ist als Tag **`archiv/fix-event-description-truncation`** (`8a8a353`)
festgehalten, damit das Löschen nichts vernichtet.
`feature/linux-net10` bleibt vorerst bestehen und ist mit `main` identisch. Sobald klar
ist, dass die Arbeit auf `main` weiterläuft, kann auch dieser Zweig weg.
### 1.2 ✅ Nullable-Warnungen behoben — der Build ist warnungsfrei
*Erledigt am 2026-08-24.* Alle acht waren Annotationsfragen, keine echten Fehler; das
Laufzeitverhalten ist unverändert:
* `TradeRepository.cs` (6×) — die Parameterliste nimmt bewusst `null` auf, damit nullable
Spalten als SQL-NULL geschrieben werden. Jetzt als `List<object?>` deklariert, statt an
sechs Stellen zu unterdrücken.
* `TraderAnalyticsWorker.cs:152``ThenInclude(o => o!.Market)`. Der Lambda ist ein
Ausdrucksbaum für EF und wird nie ausgeführt.
* `PolymarketProvider.cs:357``raw.Question ?? ""`. Das Feld wurde ohnehin einen Schritt
später von `TruncateMarketStrings` auf `""` normalisiert.
**Der Build sollte warnungsfrei bleiben.** Eine neue Warnung fällt nur auf, solange es
keine alten gibt.
### 1.3 ✅ Kategorie-Backfill lädt nicht mehr den ganzen Marktbestand
*Erledigt am 2026-08-24.* Der Backfill in `RunRecalculateAllTradersAsync` holte alle Märkte
samt Event in den Speicher und verwarf den Großteil sofort wieder. Die Einschränkung auf
Märkte mit gefüllten Event-Tags steht jetzt in der Abfrage statt in der Schleife; das
Ergebnis ist unverändert.
> **Korrektur zur ersten Fassung dieser Roadmap:** dort stand, das laufe „bei jedem Start".
> Das war falsch — es ist die manuelle Wartungsaktion *Recalculate All Traders*, die nur
> auf Knopfdruck in der Shell läuft. Die Startzeit war nie betroffen, das Speicherrisiko
> bei der Aktion selbst schon.
---
## Stufe 2 — Portierung abschließen
Der Code ist plattformneutral gebaut, aber **auf keinem Linux-System je ausgeführt worden**.
Bis das nachgeholt ist, ist „läuft unter Linux" eine Annahme.
> Details zu allen Punkten: [`docs/archiv/PLAN-Linux-Portierung.md`](docs/archiv/PLAN-Linux-Portierung.md),
> Abschnitte 7 und 8.
### 2.1 🟢 Erstlauf und Verifikation unter Linux
Die Reihenfolge ist bewusst so: erst beweisen, dass es läuft, dann Deployment bauen.
- [ ] Shell auf einem Linux-Desktop starten — alle Schaltflächen, Menüs, Persistenz der Einstellungen
- [ ] Derselbe Build mit `--headless` auf einem Server ohne X11: Start, Lizenz aus der Konfiguration, sauberer Stopp per SIGTERM
- [ ] Worker-Dauerlauf ≥ 24 h gegen die **Dev-DB**, Logvergleich mit einem Windows-Lauf
- [ ] Lauf unter `LANG=de_DE.UTF-8` — Volumina, Preise, PnL gegen die Windows-Referenz prüfen (Kultur-Fallen)
- [ ] Web-UI vollständig durchklicken — case-sensitive Assets sind unter Linux ein echtes Risiko
- [ ] Lizenz: Erstaktivierung, Neustart aus dem Cache, Offline-Gnadenfrist, Revalidierung
- [ ] Watchdog-Heartbeats erscheinen am Deployment Center
- [ ] EF-Migrationen von Null gegen die Dev-DB
- [ ] Egress-Kanäle, falls genutzt (`SourceIp` verhält sich unter Linux anders)
### 2.2 🟢 Deployment und CI
- [ ] `dotnet publish -r linux-x64` und `-r win-x64`; bei self-contained das Trimming-Verhalten von Avalonia beachten
- [ ] Linux-Systemabhängigkeiten dokumentieren: `libx11-6`, `libice6`, `libsm6`, `libfontconfig1`, `libharfbuzz0b`, Monospace-Schriften
- [ ] systemd-Unit bzw. Dockerfile für den Headless-Betrieb — es existiert bereits `J:\Softwareprojekte\dockerCompose`, vermutlich anknüpfbar
- [ ] Reverse Proxy (nginx/Caddy) mit TLS vor Kestrel
- [ ] Secrets unter Linux: EnvironmentFile mit `0600` statt Klartext in `settings.json`
- [ ] CI: Build + Test auf Linux **und** Windows
- [ ] `docs/BETRIEB-Deploymentcenter.md` um den Linux-Betrieb ergänzen
---
## Stufe 3 — Analytik schärfen
### 3.1 🟢 Strategie-Klassifikation reparieren (ehemals A4)
Der größte fachliche Rückstand. `StrategyType` entsteht aus genau zwei Signalen —
`avgSize > 10000` ergibt `Whale`, `hedgingRate > 30` ergibt `Hedger`, sonst `Bot` oder
`Unknown`. Deshalb landet der **Großteil der Trader strukturell auf `Unknown`**, was den
Wert der Einstufung gegen null gehen lässt.
Erschwerend: die Logik steht **doppelt** da und die beiden Schreiber überschreiben einander
`AnalyticsService.cs:418` und `ScoringService.cs:89`.
- [ ] Zuerst die Doppelung auflösen: **ein** Ort, der `StrategyType` bestimmt
- [ ] Vorhandene, bereits berechnete Signale einspeisen statt neue zu erheben:
`StrategyMetricsCalculator` (Kategorie-Konzentration, Conviction-Edge),
`TraderTraitCalculator` (Traits), `FingerprintSnapshotService` (Kategorie-Mix über Zeit)
- [ ] Ergänzend, falls nötig: Verteilung der Haltedauern, Anteil später vs. früher Einstiege
im Marktleben, Varianz der Ordergrößen
- [ ] Prüfen, ob ein Einzel-Enum reicht oder ob Mehrfachsignale danebengehören
(Enum als Primär-Tag, Zusatzsignale als eigene Felder)
- [ ] Abnahme: der Anteil `Unknown` ist deutlich gesunken
> Herkunft: [`docs/archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) A4.
### 3.2 🟡 `CopytradingBacktestHarness` anschließen oder verwerfen
123 Zeilen fertig implementierter Latenz-Sweep (0/10/30/60 s) samt `BacktestResult`, per DI
registriert — und **von niemandem aufgerufen**. Der Sweep würde beziffern, wie viel Alpha
ein Nachahmer bei welcher Verzögerung verliert; das passt genau zum Zweck des Projekts.
Entscheidung nötig: an einen Endpunkt und die Trader-Detailseite hängen, oder ersatzlos
löschen. Der jetzige Zustand — gepflegt, aber wirkungslos — ist die schlechteste Variante.
### 3.3 🟡 Track-Record-Länge in den Copyability-Score
Der Score besteht heute aus Alpha-Verfall (70 %) und Sizing-Konsistenz (30 %). Die
ursprünglich geplante vierte Komponente — Länge und Konsistenz des Track Records — fehlt.
Ein Trader mit drei glücklichen Märkten bekommt denselben Score wie einer mit dreihundert.
> Herkunft: [`archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) A5, letzter Unterpunkt.
### 3.4 🟡 Edge-Freshness und Strategie-Drift ins Ranking
Beide Kennzahlen existieren, erscheinen aber nur auf der Detailseite und in Alarmen. In der
Rangliste — dort, wo ausgewählt wird — spielen sie keine Rolle. Ein Master mit abgestandener
Edge steht weiterhin oben.
### 3.5 🟡 Co-Movement-Graph vorberechnen
`GET /api/co-movement/graph` rechnet bei jedem Aufruf. Für die Netzwerkansicht ist das
träge und belastet die DB unnötig. In den `ScoringAndAlertsWorker` verlagern und das
Ergebnis ablegen.
### 3.6 🟡 Restpunkte aus der Kategorie-Erkennung
- [ ] Gezielt fehlende Event-Tags nachladen für Märkte, die getrackte Trader gehandelt haben
([`archiv/FIXPLAN-TODO.md`](docs/archiv/FIXPLAN-TODO.md) F5) — der tägliche Sync geschlossener Märkte deckt den Fall
inzwischen weitgehend ab, die Lücke ist klein
- [ ] Zwei fehlende Tests (F6): Event-Tag-Vererbung und Offline-Backfill
### 3.7 🟡 Sortierpfeile in der Trader-Tabelle
Die Tabellenköpfe zeigen statisch `↕`, auch in der aktiven Sortierspalte. Reine Politik,
war schon im UI-Plan als „Optional" markiert.
---
## Stufe 4 — Datenhaushalt
Alles hier läuft über SQL, das **der Nutzer selbst** gegen die Datenbank fährt. Wir liefern
die Anweisungen, führen sie aber nicht aus.
> Details: [`docs/archiv/FIXPLAN-G-Speicher.md`](docs/archiv/FIXPLAN-G-Speicher.md) (Anhang)
> und [`docs/archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) C3.
### 4.1 👤 🟡 Einmalige DB-Optimierungen
- [ ] Spaltentypen verkleinern: `TransactionHash`, `MarketId`, `AssetId` sind Hex-/Dezimalstrings in überbreiten Feldern
- [ ] InnoDB-Kompression (`ROW_FORMAT=COMPRESSED`) für `Trades` — schneller Zugewinn, geringes Risiko
- [ ] Partitionierung von `Trades` nach Monat (`ExecutedAt`) prüfen — erlaubt späteres Verwerfen ganzer Partitionen
### 4.2 👤 💡 Materialitätsschwelle für Mikro-Trades
Trades unterhalb eines Betrags (Vorschlag: $0,50) unabhängig vom Retention-Fenster
verwerfen. Durchdacht, aber nicht beschlossen — die Schwelle verändert die PnL-Rechnung
leicht, und der Gewinn ist ungewiss, bis 4.1 gemessen ist.
### 4.3 👤 💡 Frischer Datenbank-Neustart
Die alte Überlegung, die Produktivdatenbank leer neu aufzusetzen und über die Worker neu zu
befüllen (Rohdaten sind über `/activity` jederzeit nachladbar). **Nicht beschlossen.** Falls
er kommt, gehört er ans Ende von Stufe 3, nicht davor — sonst laufen Analytik-Änderungen und
Datenumzug gleichzeitig.
Vorgelagerter Bestätigungs-Check: stichprobenartig prüfen, wie weit `/activity` für ein
lange aktives Wallet tatsächlich zurückreicht.
### 4.4 💡 65 Migrationsdateien zusammenfassen
32 Migrationen ergeben 65 Dateien und rund 37.000 Zeilen. Eine neue Baseline wäre
übersichtlicher, kostet aber die Schemahistorie und ist gegen eine laufende Produktivdatenbank
heikel. Nur sinnvoll im Zuge von 4.3.
---
## Stufe 5 — Ingest skalieren
Erst angehen, wenn Rate-Limits tatsächlich drücken. Die wirksamsten Maßnahmen sind bereits
umgesetzt (Ingest-Tiering, Egress-Kanäle, Kurzzeit-Cache).
> Details: [`docs/archiv/PLAN-DatenIngest-Skalierung.md`](docs/archiv/PLAN-DatenIngest-Skalierung.md).
### 5.1 💡 Historische Massendaten aus der Blockchain statt über REST
Der größte ungenutzte Hebel. Polymarket-Trades sind On-Chain-Events auf Polygon; für
Backfill und Deep-Resync — die teuersten REST-Verbraucher — ist `/activity` die falsche
Quelle. Ein GraphQL-Aufruf gegen einen Subgraph (The Graph / Goldsky) liefert tausende Fills
eines Wallets auf einmal.
Skizze: `IHistoricalTradeSource` neben den REST-Provider stellen, Deep-Resync zieht darüber.
**Nicht eingeplant**, weil erst zu evaluieren ist, ob ein brauchbarer Subgraph existiert und
wie vollständig er ist.
### 5.2 🟡 Markt-Stammdaten aggressiv cachen
Metadaten (Frage, Kategorie, Outcomes, `ConditionId``TokenId`) ändern sich nie — einmal
ziehen, lang halten; nur Volumen, Liquidität und Auflösung periodisch aktualisieren.
Teilweise erledigt: geschlossene Märkte werden nur noch einmal täglich synchronisiert.
### 5.3 🟡 Egress-Kanäle: Betriebsreste
Die Mechanik steht (Round-Robin, Cooldown nach drei Fehlschlägen, Limiter je Kanal). Was
im Ingest-Plan noch danebensteht:
- [ ] Health-Statistik je Kanal — Erfolgsquote, 429-Rate, Latenz. Heute gibt es nur einen
Fehlerzähler: tote Kanäle werden pausiert, aber nicht ausgewertet
- [ ] Bei eigenen IPs prüfen, ob es echte getrennte Egress-IPs sind (multi-homed) und nicht
NAT hinter derselben öffentlichen Adresse — sonst bringt die Aufteilung nichts
- [ ] Regressionsschutz: die MySQL-Verbindung darf **nie** über den Egress-Pool oder einen
Proxy laufen
- [ ] Grob unplausible Provider-Antworten abfangen (z. B. Preis außerhalb 01)
### 5.4 ❌ Header- und User-Agent-Rotation
Im Ingest-Plan bewusst als „standardmäßig AUS" eingeordnet. Rotation zur Verschleierung
gegenüber Polymarket ist gegen deren Interesse gerichtet und riskiert die Sperrung genau des
Zugangs, auf dem das Projekt aufsetzt. Der ehrliche Weg ist, die Nachfrage zu senken (5.1,
5.2) und legitime Kanäle zu erhöhen (bereits umgesetzt).
---
## Stufe 6 — Monetarisierung vorbereiten
Nur relevant, falls aus dem internen Werkzeug ein Produkt werden soll. **Bislang eine Idee,
keine Entscheidung.** Die Vorarbeit (Read/Control-Trennung, `/api/capabilities`,
CORS-Whitelist) ist im Zuge der Portierung ohnehin schon passiert.
> Details: [`docs/archiv/PLAN-Architektur-WebUI-Backend.md`](docs/archiv/PLAN-Architektur-WebUI-Backend.md).
### 6.1 💡 Datenbank für eine öffentliche Schicht vorbereiten
- **Read-Endpunkte auf versteckte Writes und Provider-Aufrufe prüfen.** Der Read/Control-Split
steht, aber ob jeder einzelne Read-Endpunkt frei von Schreibzugriffen ist, wurde nie
systematisch geprüft — besonders der Deep-Dive-Pfad. Vor einem öffentlichen Host
nachzuholen, sonst wird die read-only Verbindung im Betrieb Fehler werfen
- Read-only DB-Benutzer auf der Analyse-DB (nur `SELECT`) — 👤 der Nutzer führt die Grants aus
- Schema-Kopplung dokumentieren: liest eine öffentliche Schicht direkt mit, wird das
DB-Schema zur **Vertragsfläche**. Views als Puffer erwägen, damit interne Umbauten die
öffentliche Sicht nicht brechen.
Billig und jederzeit vorziehbar, auch wenn 6.2 nie kommt.
### 6.2 💡 Öffentlicher Host
Eigenes Projekt `Predictalytics.PublicApi`: registriert nur die Read-Endpunkte, dazu
Konten/Authentifizierung in einer **getrennten** User-DB, Rate-Limits und Kontingente pro
Konto, Mandantensicht (Kunde sieht seine Watchlist, nicht das ganze Universum), liefert
dasselbe SPA aus.
**Nicht-Ziele, die dabei gelten** — ❌ kein Zugriff der öffentlichen Schicht auf Core-Prozess,
lokale API oder Steuerendpunkte; ❌ kein Schreibzugriff auf die Analyse-DB; ❌ keine
Kunden-Authentifizierung nachträglich in die lokale Vollschicht bauen; ❌ Read-Endpunkte
machen keine Provider-Aufrufe und keine Writes.
---
## Anhang — bewusst nicht auf der Roadmap
Geprüft und mit Absicht ausgelassen. Nicht versehentlich wieder aufnehmen:
| Was | Warum |
|---|---|
| `AzuroProvider` löschen | Platzhalter mit `IsImplemented => false`, wird sauber übersprungen. Kostet nichts, markiert den Ausbaupfad. |
| `Providers/Limitless/` löschen | Voll implementierter zweiter Marktplatz, `IsImplemented => true`. Wird nur nicht bespielt — Löschen wäre Funktionsverlust. |
| `wwwroot/landing.html`, `docs.html` | Marketing-Seiten mit Preis-Abschnitt, vom Dashboard nicht verlinkt. Gehören zu Stufe 6. |
| Kaltarchiv außerhalb der DB | Verworfen: Rohdaten sind über die Polymarket-API jederzeit nachladbar, ein Archiv wäre reine Zusatzlast. |
| EF Core auf 10 heben | Blockiert: Pomelo hat keinen EF-10-Provider und pinnt Relational hart auf `[9.0.0, 9.0.999]`. Vor einem Versuch prüfen, ob Pomelo nachgezogen hat. |
-127
View File
@@ -1,127 +0,0 @@
# Predictalytics — Stand des Projekts
> Bestandsaufnahme vom **2026-08-23**, erstellt beim Frühjahrsputz. Jede Aussage hier ist
> gegen den Code geprüft, nicht aus den Plandokumenten übernommen — die waren zu diesem
> Zeitpunkt durchweg veraltet.
>
> Dieses Dokument beschreibt den **Ist-Stand**. Was ansteht, steht in
> [`ROADMAP.md`](ROADMAP.md); die abgelösten Plandokumente liegen unter
> [`docs/archiv/`](docs/archiv/).
---
## 1. Wo wir stehen
Predictalytics analysiert Polymarket-Trader auf ihre Eignung fürs Copytrading. Die
Anwendung ist **funktional vollständig und läuft im Betrieb**: 151.094 Trader und
38,2 Mio. Trades in der Produktivdatenbank (Stand 2026-08-08).
Der Hauptzweig ist `main`; die Portierung auf .NET 10 und Avalonia ist dort seit dem
2026-08-24 angekommen (`402c372`). `feature/linux-net10` steht auf demselben Stand.
| | |
|---|---|
| Zielframework | net10.0, ein einziges für alle Projekte |
| Oberfläche | Avalonia 12.1.1 (Windows + Linux), zusätzlich ein Headless-Modus |
| Datenzugriff | EF Core **9** (bewusst; Pomelo hat keinen EF-10-Provider), 32 Migrationen |
| Tests | **126 grün, 1 bewusst übersprungen** |
| Build | fehlerfrei und **warnungsfrei** |
### Aufbau
```
Predictalytics.Domain 1.321 Z. Entities, Enums, Repository-Interfaces
Predictalytics.Application 3.036 Z. Analytik, Scoring, Kalkulatoren
Predictalytics.Infrastructure 4.966 Z. EF-Kontext, Repositories, Provider (+65 Migrationsdateien)
Predictalytics.Worker 2.449 Z. 13 Hintergrunddienste
Predictalytics.Api 896 Z. Minimal-API-Endpunkte + WebUI (3.247 Z. HTML/JS/CSS)
Predictalytics.Hosting 1.972 Z. Plattformneutraler Kern: Kestrel, Worker, Lizenz, Heartbeat
Predictalytics.Shell 1.305 Z. Avalonia-Bedienhülle
Predictalytics.Application.Tests 3.216 Z. 126 Tests
```
`Predictalytics.Hosting` hängt an einem **Schwester-Repo**: `Deploymentcenter` muss neben
dem Predictalytics-Checkout liegen, sonst bricht der Build mit einer erklärenden Meldung ab.
---
## 2. Was fertig ist
Alles Folgende ist im Code belegt; die Belegstellen stehen in den archivierten Plandokumenten
unter [`docs/archiv/`](docs/archiv/).
* **Kernanalytik** ([`archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md), Phase 1): positionsbasierte PnL-Engine nach
Average-Cost mit sauberer Behandlung jeder `TradeSide`, marktbasierte Win-Rate,
Deep-Dive-Kennzahlen aus echter Preishistorie, Copytrading-Score aus gemessenem
Alpha-Verfall gegen das Markt-Tape.
* **Robustheit** (Phase 2): EF-Migrationen statt handgeschriebener `ALTER TABLE`,
entkoppelte Scoring-Pipeline, Log-Retention, Testprojekt. Alle fünf Bugs aus dem
Review-Pass vom 2026-07-01 sind behoben.
* **Speicher-Haushalt** ([`archiv/FIXPLAN-G-Speicher.md`](docs/archiv/FIXPLAN-G-Speicher.md)): budgetabhängiges Retention-Fenster,
Burst-Kompaktierung, Per-Kategorie-Edge.
* **Kategorie-Erkennung** ([`archiv/FIXPLAN-TODO.md`](docs/archiv/FIXPLAN-TODO.md), Teil F): kanonische Tag-Tabelle,
Rausch-Filter, Tag-Nachladen für On-Demand-Märkte, Offline-Backfill beim Start.
* **HF-Trader-Tiering** (Teil D): `IngestMode` mit Hysterese, wöchentliche Biopsie,
Aggregations-Buckets.
* **Web-UI** ([`archiv/FIXPLAN-UI-Ranglisten.md`](docs/archiv/FIXPLAN-UI-Ranglisten.md)): Showcase-Sektionen, serverseitige Sortierung
und Filterung.
* **Copytrading-Analytik**: Co-Movement samt Clusterung, Strategie-Drift, Edge-Freshness,
Copy-Portfolio, Insider-Feed — alle fünf Vorschläge der Roadmap sind umgesetzt und
in diesem Zweig enthalten.
* **Portierung** ([`archiv/PLAN-Linux-Portierung.md`](docs/archiv/PLAN-Linux-Portierung.md), Phasen 04): .NET 10, extrahierter
Hosting-Kern, Avalonia-Shell, abgesicherte Steuerendpunkte, plattformfeste Kultur- und
Log-Pfade. Der WinForms-Host ist entfernt.
* **Betriebsanbindung**: Lizenz, Watchdog, UpdateService, Fehler-Stream und Bugtracker
laufen alle über das Deployment Center.
---
## 3. Was offen ist
Die offenen Punkte stehen vollständig und nach Reihenfolge sortiert in
**[`ROADMAP.md`](ROADMAP.md)** — hier nur die drei, die den Blick aufs Ganze bestimmen:
1. **Die Portierung ist auf keinem Linux-System je gelaufen.** Der Code ist plattformneutral
gebaut, die Ende-zu-Ende-Prüfung vom 2026-08-08 lief aber unter Windows. Solange das
aussteht, ist „läuft unter Linux" eine Annahme. Roadmap Stufe 2 — der größte offene Block.
2. **Die Strategie-Klassifikation ist faktisch wirkungslos** — zwei Signale, der Großteil
der Trader landet auf `Unknown`, und die Logik steht doppelt da. Roadmap 3.1.
3. **Die KI-Analyse ist ohne Schlüssel abgeschaltet.** Der alte OpenRouter-Schlüssel ist
entfernt (er war inaktiv); ein neuer gehört in die Umgebungsvariable `OpenRouter__ApiKey`,
nicht in `appsettings.json`. Bis dahin überspringt die Anwendung die Analyse mit einer
Warnung, alles andere läuft normal. Roadmap 0.1.
---
## 4. Zweige
Aufgeräumt am 2026-08-24. Es gibt nur noch zwei, beide auf `402c372`:
* **`main`** — der Hauptzweig, aktuell.
* **`feature/linux-net10`** — identisch mit `main`, bleibt vorerst stehen.
Die zehn Feature-Zweige der Analytik- und Portierungsarbeit sind lokal und auf origin
gelöscht. `fix-event-description-truncation` war der einzige, dessen Commit nie gemergt
wurde (nur sein Inhalt, seit `cb58e09`); er ist als Tag
`archiv/fix-event-description-truncation` festgehalten.
---
## 5. Bewusst stehengelassen
Beim Aufräumen geprüft und **nicht** entfernt, weil Löschen hier Funktionsverlust wäre und
nicht Entrümpelung. Dieselbe Liste steht im Anhang der [`ROADMAP.md`](ROADMAP.md), damit sie
nicht versehentlich als Aufgabe wieder auftaucht:
| Was | Warum es bleibt |
|---|---|
| `Providers/Azuro/AzuroProvider.cs` | Platzhalter mit `IsImplemented => false`. Die Worker überspringen ihn sauber; er kostet nichts und markiert den Ausbaupfad. |
| `Providers/Limitless/` (3 Dateien) | **Voll implementierter** zweiter Marktplatz mit `IsImplemented => true`. Wird produktiv nur nicht bespielt. |
| `Services/CopytradingBacktestHarness.cs` (123 Z.) | Fertig implementierter Latenz-Sweep, per DI registriert, aber **von niemandem aufgerufen**. Entscheidung steht aus, siehe Roadmap 3.2. |
| `wwwroot/landing.html`, `wwwroot/docs.html` | Marketing-Seiten mit Preis-Abschnitt, vom Dashboard aus nicht verlinkt. Passen zur später angedachten Monetarisierung. |
| 65 Migrationsdateien | Die Schemahistorie. Zusammenfassen ginge, ist aber riskant — siehe Roadmap 4.4. |
*(Ein hier zuvor notierter Nebenbefund — der Kategorie-Backfill lade beim Start alle Märkte
in den Speicher — war in zwei Punkten falsch: er läuft nicht beim Start, sondern nur in der
manuellen Aktion „Recalculate All Traders", und die Abfrage ist seit dem 2026-08-24
eingeschränkt. Siehe Roadmap 1.3.)*
+8
View File
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,51 +1,5 @@
# Predictalytics Umsetzungsplan (Phase 1 & 2 + Storage-Optimierung) # Predictalytics Umsetzungsplan (Phase 1 & 2 + Storage-Optimierung)
> ## Stand 2026-08-23 — Phase 1 und 2 sind bis auf zwei Punkte umgesetzt
>
> Die Häkchen waren bis dahin nie nachgezogen worden: 85 Punkte standen auf offen,
> obwohl der Code sie längst enthielt. Am 2026-08-23 abschnittsweise gegen den Code
> geprüft und gesetzt. **Belege:**
>
> | | Belegt durch |
> |---|---|
> | **A1** PnL-Engine | `Infrastructure/Services/PositionPnLEngine.cs` — alle `TradeSide`-Zweige einzeln behandelt (`:157-217`), `Split`/`Merge` neutral, unrealisiert aus `CurrentPrice AvgCost` (`:266`), Gesamt-PnL `:290`. Entity `Domain/Entities/TraderPosition.cs`. Tests: `PositionPnLEngineTests.cs` |
> | **A2** Win-Rate | `CalculateMarketWinRates` (`PositionPnLEngine.cs:381`) — marktbasiert, kein `return 0` mehr |
> | **A3** Deep-Dive | `MarketOutcomePriceSnapshot` existiert und wird ausgewertet (`AnalyticsService.cs:88,390`). Die `50` ist jetzt **Rückfallwert bei fehlender Preishistorie** (`:456,457,474`), kein fester Neutralwert mehr |
> | **A5** Copytrading-Score | `Infrastructure/Services/CopytradingEstimator.cs`**abweichend umgesetzt und besser als geplant**: statt geschätztem Liquiditäts-Fit misst der Score den echten Alpha-Verfall gegen das Markt-Tape (Slippage bei 10 s Folgelatenz, 70 %) plus Sizing-Konsistenz (30 %). Frequenz/Konzentration deckt stattdessen `IngestMode` + Trait `not_copyable_hf` ab. **Track-Record-Länge fließt nicht in den Score ein** |
> | **B1/B4** | 32 EF-Migrationen; Testprojekt existiert, **126 grün + 1 Skip** |
> | **B3** | `ScoringService.RecalculateAllScoresAsync:127` rechnet nur bei `CalculatedAt < LastTradesUpdatedAt` neu, Rank-Update gebatcht; eigener `ScoringAndAlertsWorker` mit 15-Minuten-Takt |
> | **B5** | `LoggingSetup.cs:90-133``retainedFileCountLimit` und `fileSizeLimitBytes` gesetzt |
> | **B6** | Alle fünf Bugs behoben: geschlossene Märkte nur noch **1×/Tag** (`MarketSyncWorker.cs:52`), Watchlist von der Löschung ausgenommen (`TraderRepository.cs:137`), `GetKnownPlatformTradeIdsAsync` filtert auf die Batch-IDs (`TradeRepository.cs:158`), `_syncSemaphore` in `AddOrUpdateAsync` (`MarketRepository.cs:31`), `break` durch einen Filter ersetzt (`TradeHistoryWorker.cs:200`) |
> | **C1/C2** | `TradeRetentionWorker` + `Application/Services/StorageGovernor.cs` (budgetabhängiges Fenster), Burst-Kompaktierung ab `TradeRetentionWorker.cs:148` |
>
> ### Was wirklich offen ist
>
> 1. **A4 — Strategie-Klassifikation** (Zeilen 4754). Der einzige größere unerledigte
> Block aus Phase 1. `StrategyType` entsteht weiterhin aus genau zwei Signalen
> (`AnalyticsService.cs:418` und, **dupliziert**, `ScoringService.cs:89`):
> `avgSize > 10000 → Whale`, `hedgingRate > 30 → Hedger`, sonst `Bot`/`Unknown`.
> Genau der Zustand, den A4 beheben sollte — der Großteil der Trader landet
> strukturell auf `Unknown`. Die Bausteine dafür liegen bereits fertig herum
> (`StrategyMetricsCalculator`, `TraderTraitCalculator`, `FingerprintSnapshotService`),
> sie speisen die Klassifikation nur nicht. Die Doppelung gehört beim Anfassen
> zusammengeführt.
> 2. **B2 — Secrets** (Zeilen 8486). Nicht mehr „kein akuter Risikofall": in
> `src/Predictalytics.Api/appsettings.json` steht ein **echter OpenRouter-API-Key im
> Klartext**, versioniert seit Commit `7045002`. Siehe Warnung unten.
> 3. **C3** (148153) und **D1/D2** (165173) sind DB-Arbeiten auf SQL-Ebene bzw. die
> Entscheidung über den Datenbank-Neustart — beides liegt beim Nutzer, nicht im Code.
> 4. **C1**, Zeile 137: Stichprobentest, wie weit `/activity` zurückreicht. Eine manuelle
> Prüfung, die sich im Code nicht belegen lässt — bewusst offen gelassen.
>
> ### ⚠️ Sicherheit — Handlungsbedarf
>
> `src/Predictalytics.Api/appsettings.json` enthält den OpenRouter-API-Key im Klartext
> und liegt so im Git-Verlauf und auf dem Gitea-Server. **Den Schlüssel bei OpenRouter
> zurückziehen und neu ausstellen** — ihn nur aus der Datei zu löschen hilft nicht, die
> Historie behält ihn. Danach über die Umgebungsvariable `OpenRouter__ApiKey` setzen;
> `WebApplication.CreateBuilder` liest Umgebungsvariablen bereits mit Vorrang vor
> `appsettings.json`, dafür ist keine Codeänderung nötig.
> **Wie wir das nutzen:** Wir arbeiten diese Liste Stück für Stück ab. Erledigte Punkte werden von `[ ]` auf `[x]` gesetzt. Reihenfolge ist absichtlich so gewählt, dass spätere Schritte auf früheren aufbauen — nicht einfach querbeet abhaken, siehe Abschnitt "Empfohlene Reihenfolge" ganz unten für die Kurzfassung. > **Wie wir das nutzen:** Wir arbeiten diese Liste Stück für Stück ab. Erledigte Punkte werden von `[ ]` auf `[x]` gesetzt. Reihenfolge ist absichtlich so gewählt, dass spätere Schritte auf früheren aufbauen — nicht einfach querbeet abhaken, siehe Abschnitt "Empfohlene Reihenfolge" ganz unten für die Kurzfassung.
> >
> Phase 3 (Monetarisierung: Auth, Multi-Tenant, Billing) ist bewusst **nicht** Teil dieses Plans. > Phase 3 (Monetarisierung: Auth, Multi-Tenant, Billing) ist bewusst **nicht** Teil dieses Plans.
@@ -61,33 +15,33 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
## Phase 1 Kernanalytik korrigieren ## Phase 1 Kernanalytik korrigieren
### A1. Positionsbasierte PnL-Engine ### A1. Positionsbasierte PnL-Engine
- [x] Entscheidung dokumentieren: **Average-Cost-Methode** statt FIFO (einfacher, und wichtig kompatibel mit späterer Trade-Kompaktierung in Abschnitt C, weil Average-Cost nur Gesamtstückzahl & Gesamtkosten braucht, keine Einzel-Trade-Reihenfolge) - [ ] Entscheidung dokumentieren: **Average-Cost-Methode** statt FIFO (einfacher, und wichtig kompatibel mit späterer Trade-Kompaktierung in Abschnitt C, weil Average-Cost nur Gesamtstückzahl & Gesamtkosten braucht, keine Einzel-Trade-Reihenfolge)
- [x] Neue Domain-Struktur `TraderPosition` (TraderId, MarketOutcomeId, SharesHeld, AvgCost, RealizedPnl, zuletzt aktualisiert) — inkrementell fortschreibbar statt bei jeder Berechnung die komplette Trade-Historie neu zu scannen - [ ] Neue Domain-Struktur `TraderPosition` (TraderId, MarketOutcomeId, SharesHeld, AvgCost, RealizedPnl, zuletzt aktualisiert) — inkrementell fortschreibbar statt bei jeder Berechnung die komplette Trade-Historie neu zu scannen
- [x] Buchungslogik je `TradeSide` sauber definieren: - [ ] Buchungslogik je `TradeSide` sauber definieren:
- [x] `Buy`: Shares += Size, AvgCost neu gewichten - [ ] `Buy`: Shares += Size, AvgCost neu gewichten
- [x] `Sell`: RealizedPnl += Size × (Price AvgCost), Shares = Size - [ ] `Sell`: RealizedPnl += Size × (Price AvgCost), Shares = Size
- [x] `Redeem` (Marktauflösung): RealizedPnl += verbleibende Shares × (1 oder 0 je nach Gewinn-Outcome AvgCost), Shares = 0 - [ ] `Redeem` (Marktauflösung): RealizedPnl += verbleibende Shares × (1 oder 0 je nach Gewinn-Outcome AvgCost), Shares = 0
- [x] `Split` / `Merge`: als neutrale Positionsumwandlung behandeln (kein PnL-Effekt), nicht wie `Sell` - [ ] `Split` / `Merge`: als neutrale Positionsumwandlung behandeln (kein PnL-Effekt), nicht wie `Sell`
- [x] `AddLiquidity` / `RemoveLiquidity`: getrennt von Trading-PnL betrachten (eigene Kategorie, fließt nicht in "Trading-Skill"-Bewertung ein) - [ ] `AddLiquidity` / `RemoveLiquidity`: getrennt von Trading-PnL betrachten (eigene Kategorie, fließt nicht in "Trading-Skill"-Bewertung ein)
- [x] Unrealisierten PnL für offene Positionen berechnen: `Shares × (MarketOutcome.CurrentPrice AvgCost)` - [ ] Unrealisierten PnL für offene Positionen berechnen: `Shares × (MarketOutcome.CurrentPrice AvgCost)`
- [x] Gesamt-PnL = realisiert + unrealisiert (ersetzt `OverallPnL`, `PnL30d/7d/24h` Felder in `TraderAnalytics`) - [ ] Gesamt-PnL = realisiert + unrealisiert (ersetzt `OverallPnL`, `PnL30d/7d/24h` Felder in `TraderAnalytics`)
- [x] Prüfen, ob wir zusätzlich/alternativ Polymarkets eigenen `/positions`-Endpoint (`GetTraderPositionsAsync`, liefert `CurrentValue`/`PercentPnl`) als Plausibilitäts-Check oder sogar als primäre Quelle für **offene** Positionen nutzen (aktuell komplett ungenutzt) - [ ] Prüfen, ob wir zusätzlich/alternativ Polymarkets eigenen `/positions`-Endpoint (`GetTraderPositionsAsync`, liefert `CurrentValue`/`PercentPnl`) als Plausibilitäts-Check oder sogar als primäre Quelle für **offene** Positionen nutzen (aktuell komplett ungenutzt)
- [x] `CalculatePnL`-Bug aus Abschnitt 0 im Zuge dessen mit erledigen - [ ] `CalculatePnL`-Bug aus Abschnitt 0 im Zuge dessen mit erledigen
### A2. Win-Rate korrekt berechnen ### A2. Win-Rate korrekt berechnen
- [x] Win/Loss ist **pro Markt**, nicht pro Trade, definiert: ein Markt zählt als "Win", wenn der realisierte PnL aus diesem Markt (nach Redeem/vollständigem Exit) positiv ist - [ ] Win/Loss ist **pro Markt**, nicht pro Trade, definiert: ein Markt zählt als "Win", wenn der realisierte PnL aus diesem Markt (nach Redeem/vollständigem Exit) positiv ist
- [x] `WinRate = Anzahl gewonnener Märkte / Anzahl abgeschlossener Märkte` (offene Positionen zählen nicht mit) - [ ] `WinRate = Anzahl gewonnener Märkte / Anzahl abgeschlossener Märkte` (offene Positionen zählen nicht mit)
- [x] Placeholder `return 0;` in `TraderAnalyticsWorker.CalculateWinRate` ersetzen - [ ] Placeholder `return 0;` in `TraderAnalyticsWorker.CalculateWinRate` ersetzen
### A3. Deep-Dive-Kennzahlen mit echten Werten füllen ### A3. Deep-Dive-Kennzahlen mit echten Werten füllen
- [x] **Voraussetzung klären:** Für Entry/Exit-Qualität und Timing-Accuracy brauchen wir eine **Preis-Historie** pro `MarketOutcome`, nicht nur den aktuellen Preis (`CurrentPrice`). Aktuell existiert keine Historisierung. - [ ] **Voraussetzung klären:** Für Entry/Exit-Qualität und Timing-Accuracy brauchen wir eine **Preis-Historie** pro `MarketOutcome`, nicht nur den aktuellen Preis (`CurrentPrice`). Aktuell existiert keine Historisierung.
- [x] Prüfen, ob Polymarkets CLOB-API einen Preishistorie-Endpoint (`/prices-history`) hergibt, den wir zum Backfill nutzen können - [ ] Prüfen, ob Polymarkets CLOB-API einen Preishistorie-Endpoint (`/prices-history`) hergibt, den wir zum Backfill nutzen können
- [x] Falls ja: neue Tabelle `MarketOutcomePriceSnapshot` (MarketOutcomeId, Timestamp, Price) einführen, periodisch befüllt (z.B. durch bestehenden `MarketSyncWorker`/`MarketHistoryWorker` erweitern) - [ ] Falls ja: neue Tabelle `MarketOutcomePriceSnapshot` (MarketOutcomeId, Timestamp, Price) einführen, periodisch befüllt (z.B. durch bestehenden `MarketSyncWorker`/`MarketHistoryWorker` erweitern)
- [x] `AvgHoldDurationHours` echt berechnen: gewichtete Haltedauer zwischen Einstieg (Buy-Zeitpunkte, gewichtet nach Größe) und Ausstieg (Sell/Redeem) pro Position - [ ] `AvgHoldDurationHours` echt berechnen: gewichtete Haltedauer zwischen Einstieg (Buy-Zeitpunkte, gewichtet nach Größe) und Ausstieg (Sell/Redeem) pro Position
- [x] `EntryQuality`: Einstiegspreis im Vergleich zur nachfolgenden Preisentwicklung (z.B. Perzentil des Einstiegspreises innerhalb eines Zeitfensters danach) - [ ] `EntryQuality`: Einstiegspreis im Vergleich zur nachfolgenden Preisentwicklung (z.B. Perzentil des Einstiegspreises innerhalb eines Zeitfensters danach)
- [x] `ExitQuality`: analog für Ausstiegspreis - [ ] `ExitQuality`: analog für Ausstiegspreis
- [x] `TimingAccuracy`: z.B. Anteil der Trades, die kurz vor einer für den Trader günstigen Preisbewegung platziert wurden - [ ] `TimingAccuracy`: z.B. Anteil der Trades, die kurz vor einer für den Trader günstigen Preisbewegung platziert wurden
- [x] Hardcodierte `50`-Neutralwerte in `AnalyticsService.PerformDeepDive` entfernen - [ ] Hardcodierte `50`-Neutralwerte in `AnalyticsService.PerformDeepDive` entfernen
### A4. Strategie-Klassifikation verbessern ### A4. Strategie-Klassifikation verbessern
- [ ] Zusätzliche Signale einbeziehen statt nur "Ø-Größe" und "Hedging-Rate": - [ ] Zusätzliche Signale einbeziehen statt nur "Ø-Größe" und "Hedging-Rate":
@@ -100,17 +54,17 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
- [ ] Sicherstellen, dass nicht der Großteil der Trader dauerhaft bei "Unknown" landet (aktuell strukturell der Fall) - [ ] Sicherstellen, dass nicht der Großteil der Trader dauerhaft bei "Unknown" landet (aktuell strukturell der Fall)
### A5. Copytrading-Eignungs-Score (neu) ### A5. Copytrading-Eignungs-Score (neu)
- [x] Neue Metrik-Dimension definieren, unabhängig vom bestehenden `PriorityScore`: - [ ] Neue Metrik-Dimension definieren, unabhängig vom bestehenden `PriorityScore`:
- [x] **Liquiditäts-Fit**: durchschnittliche Positionsgröße im Verhältnis zur Marktliquidität/zum Volumen zum Handelszeitpunkt (Slippage-Risiko für Nachahmer) - [ ] **Liquiditäts-Fit**: durchschnittliche Positionsgröße im Verhältnis zur Marktliquidität/zum Volumen zum Handelszeitpunkt (Slippage-Risiko für Nachahmer)
- [x] **Reaktionsfenster**: wie viel Zeit bliebe einem Copytrader realistisch zum Nachziehen (Trader mit Sekunden/Millisekunden-Kadenz sind nicht kopierbar) - [ ] **Reaktionsfenster**: wie viel Zeit bliebe einem Copytrader realistisch zum Nachziehen (Trader mit Sekunden/Millisekunden-Kadenz sind nicht kopierbar)
- [x] **Frequenz/Konzentration**: sehr hochfrequente/bot-artige Trader senken den Score automatisch - [ ] **Frequenz/Konzentration**: sehr hochfrequente/bot-artige Trader senken den Score automatisch
- [x] **Track-Record-Länge & Konsistenz**: mehr abgeschlossene Märkte mit konsistent positivem PnL = höheres Vertrauen - [ ] **Track-Record-Länge & Konsistenz**: mehr abgeschlossene Märkte mit konsistent positivem PnL = höheres Vertrauen
- [x] Kombinierten `CopytradingScore` (0100) berechnen und persistieren (neues Feld auf `TraderScore` oder eigene Entity `TraderCopytradingProfile`) - [ ] Kombinierten `CopytradingScore` (0100) berechnen und persistieren (neues Feld auf `TraderScore` oder eigene Entity `TraderCopytradingProfile`)
- [x] Im Dashboard/API sichtbar machen (getrennt von der bisherigen "Priorität", da unterschiedliche Fragestellung: *gut* vs. *kopierbar*) - [ ] Im Dashboard/API sichtbar machen (getrennt von der bisherigen "Priorität", da unterschiedliche Fragestellung: *gut* vs. *kopierbar*)
### A6. (siehe Abschnitt 0) TradeSide-Bugfix in PnL-Berechnung ### A6. (siehe Abschnitt 0) TradeSide-Bugfix in PnL-Berechnung
- [x] Erledigt sich durch A1, hier nur als Häkchen zur Nachverfolgung - [ ] Erledigt sich durch A1, hier nur als Häkchen zur Nachverfolgung
- [x] Kurzer Änderungsvermerk/Commit-Hinweis, damit klar ist, dass dieser Bug bewusst behoben wurde - [ ] Kurzer Änderungsvermerk/Commit-Hinweis, damit klar ist, dass dieser Bug bewusst behoben wurde
--- ---
@@ -123,7 +77,7 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
- [x] Migration end-to-end gegen eine echte, frische DEV-Datenbank getestet (`lqf7.your-database.de`/`bergisnu_db0`, vom Nutzer bereitgestellt) — `dotnet ef database update` lief fehlerfrei durch, `dotnet ef migrations list` bestätigt sie als angewendet - [x] Migration end-to-end gegen eine echte, frische DEV-Datenbank getestet (`lqf7.your-database.de`/`bergisnu_db0`, vom Nutzer bereitgestellt) — `dotnet ef database update` lief fehlerfrei durch, `dotnet ef migrations list` bestätigt sie als angewendet
- [x] `AppDbContextFactory` (Design-Time-Factory für `dotnet ef`) liest Zielverbindung jetzt aus Env-Vars (`PREDICTALYTICS_DB_SERVER/_NAME/_USER/_PASSWORD`, via `MySqlConnectionStringBuilder` statt roher String-Konkatenation, da Passwörter Sonderzeichen wie `;`/`{}` enthalten können) statt fest codiert — kein Secret mehr im Repo nötig, um Migrationen gegen eine beliebige DB zu fahren - [x] `AppDbContextFactory` (Design-Time-Factory für `dotnet ef`) liest Zielverbindung jetzt aus Env-Vars (`PREDICTALYTICS_DB_SERVER/_NAME/_USER/_PASSWORD`, via `MySqlConnectionStringBuilder` statt roher String-Konkatenation, da Passwörter Sonderzeichen wie `;`/`{}` enthalten können) statt fest codiert — kein Secret mehr im Repo nötig, um Migrationen gegen eine beliebige DB zu fahren
- [x] ~~Live-Produktions-DB manuell als "bereits migriert" markieren~~ — vorerst zurückgestellt: Da wir laut Abschnitt D ohnehin einen kompletten Neustart der Produktions-DB planen (Altdaten sind jederzeit nachladbar, siehe C0), richten wir die neue Produktions-DB am Ende genauso ein wie die DEV-DB (leer anlegen + `dotnet ef database update`) — der fummelige Stamp-Schritt auf die bestehende Live-DB entfällt dann komplett. Nur falls wir uns doch gegen den Neustart entscheiden, müsste dieser Schritt nachgeholt werden - [x] ~~Live-Produktions-DB manuell als "bereits migriert" markieren~~ — vorerst zurückgestellt: Da wir laut Abschnitt D ohnehin einen kompletten Neustart der Produktions-DB planen (Altdaten sind jederzeit nachladbar, siehe C0), richten wir die neue Produktions-DB am Ende genauso ein wie die DEV-DB (leer anlegen + `dotnet ef database update`) — der fummelige Stamp-Schritt auf die bestehende Live-DB entfällt dann komplett. Nur falls wir uns doch gegen den Neustart entscheiden, müsste dieser Schritt nachgeholt werden
- [x] Alle künftigen Schemaänderungen aus Phase 1 (z.B. `TraderPosition`, `MarketOutcomePriceSnapshot`, `TraderCopytradingProfile`) als reguläre Migrationen anlegen — direkt gegen die DEV-DB entwickeln/testen, da wir dort laut Nutzer frei experimentieren dürfen - [ ] Alle künftigen Schemaänderungen aus Phase 1 (z.B. `TraderPosition`, `MarketOutcomePriceSnapshot`, `TraderCopytradingProfile`) als reguläre Migrationen anlegen — direkt gegen die DEV-DB entwickeln/testen, da wir dort laut Nutzer frei experimentieren dürfen
### B2. Secrets-Management (Release-Vorbereitung, kein akuter Risikofall) ### B2. Secrets-Management (Release-Vorbereitung, kein akuter Risikofall)
> Software läuft aktuell nur lokal, kein Fremdzugriff — Passwort bleibt vorerst wie es ist, keine Rotation nötig. Dieser Punkt ist reine **Vorbereitung**, damit das Projekt bei Bedarf später auch ohne die aktuellen Secrets veröffentlicht/geteilt werden könnte, ohne den Code nochmal anfassen zu müssen. Dadurch niedrigere Priorität als vorher angenommen — kann später in der Reihenfolge stehen. > Software läuft aktuell nur lokal, kein Fremdzugriff — Passwort bleibt vorerst wie es ist, keine Rotation nötig. Dieser Punkt ist reine **Vorbereitung**, damit das Projekt bei Bedarf später auch ohne die aktuellen Secrets veröffentlicht/geteilt werden könnte, ohne den Code nochmal anfassen zu müssen. Dadurch niedrigere Priorität als vorher angenommen — kann später in der Reihenfolge stehen.
@@ -132,27 +86,27 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
- [ ] Keine Passwort-Rotation nötig, solange rein lokale Nutzung - [ ] Keine Passwort-Rotation nötig, solange rein lokale Nutzung
### B3. Scoring-Pipeline entkoppeln ### B3. Scoring-Pipeline entkoppeln
- [x] `PollingWorker` soll **nicht** bei jedem 60-Sekunden-Zyklus alle Trader neu bewerten - [ ] `PollingWorker` soll **nicht** bei jedem 60-Sekunden-Zyklus alle Trader neu bewerten
- [x] Nur Trader neu bewerten, die seit letzter Berechnung neue Trades bekommen haben (Dirty-Flag oder Vergleich `LastTradesUpdatedAt` vs. `TraderScore.CalculatedAt`) - [ ] Nur Trader neu bewerten, die seit letzter Berechnung neue Trades bekommen haben (Dirty-Flag oder Vergleich `LastTradesUpdatedAt` vs. `TraderScore.CalculatedAt`)
- [x] N+1-Datenbankzugriffe in `ScoringService.RecalculateAllScoresAsync` durch Batch-Queries ersetzen - [ ] N+1-Datenbankzugriffe in `ScoringService.RecalculateAllScoresAsync` durch Batch-Queries ersetzen
- [x] Eigenes Intervall für volle Neuberechnung/Ranking (z.B. alle 15 Min) getrennt vom reinen Trade-Polling (60s) - [ ] Eigenes Intervall für volle Neuberechnung/Ranking (z.B. alle 15 Min) getrennt vom reinen Trade-Polling (60s)
### B6. Weitere gefundene Bugs/Performance-Probleme (Review-Pass 2026-07-01) ### B6. Weitere gefundene Bugs/Performance-Probleme (Review-Pass 2026-07-01)
- [x] **`MarketSyncWorker` re-synct alle 30 Minuten ALLE Märkte, inkl. `includeClosed=true`, von Offset 0** ([MarketSyncWorker.cs](../../src/Predictalytics.Worker/Services/MarketSyncWorker.cs)) — holt damit bei jedem Zyklus jeden jemals geschlossenen Polymarket-Markt erneut komplett durch. Wächst unbegrenzt mit der Zeit, echtes Risiko für Rate-Limiting/Sperrung. Sollte auf: aktive Märkte häufig, geschlossene Märkte selten/inkrementell (z.B. nur kürzlich geschlossene, nicht der komplette Bestand) umgestellt werden - [ ] **`MarketSyncWorker` re-synct alle 30 Minuten ALLE Märkte, inkl. `includeClosed=true`, von Offset 0** ([MarketSyncWorker.cs](src/Predictalytics.Worker/Services/MarketSyncWorker.cs)) — holt damit bei jedem Zyklus jeden jemals geschlossenen Polymarket-Markt erneut komplett durch. Wächst unbegrenzt mit der Zeit, echtes Risiko für Rate-Limiting/Sperrung. Sollte auf: aktive Märkte häufig, geschlossene Märkte selten/inkrementell (z.B. nur kürzlich geschlossene, nicht der komplette Bestand) umgestellt werden
- [x] **Watchlisted Trader nicht von Auto-Löschung ausgeschlossen** (`TraderRepository.GetTradersForCleanupAsync` / `TraderCleanupWorker`) — da `Trades`/`TraderScore`/`WatchlistEntries` per Cascade am Trader hängen, könnte ein manuell beobachteter Trader nach 1 Jahr Inaktivität (oder bei kurzzeitigem API-Fehler) unbemerkt komplett gelöscht werden. Watchlist-Einträge sollten von der Cleanup-Query ausgenommen werden - [ ] **Watchlisted Trader nicht von Auto-Löschung ausgeschlossen** (`TraderRepository.GetTradersForCleanupAsync` / `TraderCleanupWorker`) — da `Trades`/`TraderScore`/`WatchlistEntries` per Cascade am Trader hängen, könnte ein manuell beobachteter Trader nach 1 Jahr Inaktivität (oder bei kurzzeitigem API-Fehler) unbemerkt komplett gelöscht werden. Watchlist-Einträge sollten von der Cleanup-Query ausgenommen werden
- [x] **`TradeRepository.GetKnownPlatformTradeIdsAsync` lädt die komplette Trade-ID-Historie eines Traders ins RAM**, nur um eine kleine neu geholte Charge (~100-1000 Trades) zu deduplizieren — aufgerufen alle 60s (`PollingWorker`) bzw. alle 12h (`TradeHistoryWorker`) pro Trader. Wird mit wachsender Trade-Zahl immer teurer. Fix: nur `WHERE PlatformTradeId IN (<geholte Batch-IDs>)` abfragen statt der gesamten Historie - [ ] **`TradeRepository.GetKnownPlatformTradeIdsAsync` lädt die komplette Trade-ID-Historie eines Traders ins RAM**, nur um eine kleine neu geholte Charge (~100-1000 Trades) zu deduplizieren — aufgerufen alle 60s (`PollingWorker`) bzw. alle 12h (`TradeHistoryWorker`) pro Trader. Wird mit wachsender Trade-Zahl immer teurer. Fix: nur `WHERE PlatformTradeId IN (<geholte Batch-IDs>)` abfragen statt der gesamten Historie
- [x] **Race Condition in `MarketRepository.AddOrUpdateAsync`** (im Gegensatz zu `AddOrUpdateRangeAsync` ohne Locking) — `TradeHistoryWorker` verarbeitet bis zu 5 Trader parallel (`Parallel.ForEachAsync`); referenzieren zwei gleichzeitig denselben noch unbekannten Markt, prüfen beide unabhängig "existiert nicht" und einer crasht beim `Add` mit Unique-Constraint-Verletzung (wird geloggt, Trader-Sync für den Zyklus bricht ab, nächster Zyklus heilt es meist). Fix: gleiches Locking-Muster wie `AddOrUpdateRangeAsync` verwenden, oder Insert-Konflikt sauber abfangen/retry - [ ] **Race Condition in `MarketRepository.AddOrUpdateAsync`** (im Gegensatz zu `AddOrUpdateRangeAsync` ohne Locking) — `TradeHistoryWorker` verarbeitet bis zu 5 Trader parallel (`Parallel.ForEachAsync`); referenzieren zwei gleichzeitig denselben noch unbekannten Markt, prüfen beide unabhängig "existiert nicht" und einer crasht beim `Add` mit Unique-Constraint-Verletzung (wird geloggt, Trader-Sync für den Zyklus bricht ab, nächster Zyklus heilt es meist). Fix: gleiches Locking-Muster wie `AddOrUpdateRangeAsync` verwenden, oder Insert-Konflikt sauber abfangen/retry
- [x] **`TradeHistoryWorker` bricht die Trade-Verarbeitung beim ersten bekannten Trade ab** (`if (!isInitial) break;`), verlässt sich also darauf, dass die API immer streng neueste-zuerst liefert. `PollingWorker`s äquivalente Schleife macht das NICHT. Sollte angeglichen werden — der Performance-Gewinn ist gering gegenüber dem Risiko einer stillen Datenlücke, falls die Annahme mal nicht stimmt - [ ] **`TradeHistoryWorker` bricht die Trade-Verarbeitung beim ersten bekannten Trade ab** (`if (!isInitial) break;`), verlässt sich also darauf, dass die API immer streng neueste-zuerst liefert. `PollingWorker`s äquivalente Schleife macht das NICHT. Sollte angeglichen werden — der Performance-Gewinn ist gering gegenüber dem Risiko einer stillen Datenlücke, falls die Annahme mal nicht stimmt
### B4. Testabdeckung für die kritische Logik ### B4. Testabdeckung für die kritische Logik
- [x] Neues Testprojekt (z.B. `Predictalytics.Application.Tests`) anlegen — aktuell existiert **kein einziges** Testprojekt - [ ] Neues Testprojekt (z.B. `Predictalytics.Application.Tests`) anlegen — aktuell existiert **kein einziges** Testprojekt
- [x] Unit-Tests für die neue PnL-Engine (A1) — insbesondere Grenzfälle: nur offene Position, nur geschlossene Position, Split/Merge, Redeem-Verlust vs. -Gewinn - [ ] Unit-Tests für die neue PnL-Engine (A1) — insbesondere Grenzfälle: nur offene Position, nur geschlossene Position, Split/Merge, Redeem-Verlust vs. -Gewinn
- [x] Unit-Tests für Win-Rate (A2) - [ ] Unit-Tests für Win-Rate (A2)
- [x] Unit-Tests für Deep-Dive-Kennzahlen (A3) und Copytrading-Score (A5) - [ ] Unit-Tests für Deep-Dive-Kennzahlen (A3) und Copytrading-Score (A5)
- [x] Diese Tests idealerweise **parallel zu A1A5** schreiben, nicht erst am Ende nachziehen - [ ] Diese Tests idealerweise **parallel zu A1A5** schreiben, nicht erst am Ende nachziehen
### B5. Kleine verwandte Aufräumarbeit ### B5. Kleine verwandte Aufräumarbeit
- [x] Log-Rotation/Retention der Serilog-Datei-Sinks (`WinFormsHost/logs`) prüfen — wächst potenziell unbegrenzt, ähnliches Prinzip wie die DB-Speicherplatzfrage unten - [ ] Log-Rotation/Retention der Serilog-Datei-Sinks (`WinFormsHost/logs`) prüfen — wächst potenziell unbegrenzt, ähnliches Prinzip wie die DB-Speicherplatzfrage unten
--- ---
@@ -175,20 +129,20 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
→ Vorschlag: **Burst-Erkennung statt starrer Zeit-Buckets** (nur tatsächlich dichte Trade-Sequenzen kompaktieren) und **Aggregation getrennt nach Trader+Markt/Outcome+Seite** mit **VWAP** (mengengewichteter Durchschnittspreis) statt einfachem Durchschnitt — bleibt unter der Average-Cost-Methode aus A1 nahezu verlustfrei für die PnL-Berechnung. → Vorschlag: **Burst-Erkennung statt starrer Zeit-Buckets** (nur tatsächlich dichte Trade-Sequenzen kompaktieren) und **Aggregation getrennt nach Trader+Markt/Outcome+Seite** mit **VWAP** (mengengewichteter Durchschnittspreis) statt einfachem Durchschnitt — bleibt unter der Average-Cost-Methode aus A1 nahezu verlustfrei für die PnL-Berechnung.
### C1. Rollierendes Zeitfenster statt Archivierung (ersetzt die alte "Idee 1") ### C1. Rollierendes Zeitfenster statt Archivierung (ersetzt die alte "Idee 1")
- [x] Konfigurierbares Retention-Fenster einführen (Default-Vorschlag: 36 Monate) — gilt **für alle Trader gleichermaßen**, nicht nur für inaktive. Länge sollte sich daran orientieren, wie weit die Deep-Dive-/Strategie-Analyse (A3/A4) tatsächlich zurückschaut, um "aktuelle Strategie" zu charakterisieren - [ ] Konfigurierbares Retention-Fenster einführen (Default-Vorschlag: 36 Monate) — gilt **für alle Trader gleichermaßen**, nicht nur für inaktive. Länge sollte sich daran orientieren, wie weit die Deep-Dive-/Strategie-Analyse (A3/A4) tatsächlich zurückschaut, um "aktuelle Strategie" zu charakterisieren
- [x] Reihenfolge pro Trade zwingend einhalten: **erst** in `TraderPosition`/Monats-Aggregat (s.u.) einrechnen und das sicher persistieren, **dann erst** die Rohzeile löschen — rein zeitbasiert, unabhängig davon ob die betroffene Position noch offen oder schon geschlossen ist - [ ] Reihenfolge pro Trade zwingend einhalten: **erst** in `TraderPosition`/Monats-Aggregat (s.u.) einrechnen und das sicher persistieren, **dann erst** die Rohzeile löschen — rein zeitbasiert, unabhängig davon ob die betroffene Position noch offen oder schon geschlossen ist
- [x] Kein Export/Cold-Storage nötig, da jederzeit über die Polymarket-API bzw. im Zweifel über die Blockchain nachladbar — vereinfacht C1 gegenüber der ursprünglichen Idee erheblich (keine Baseline-Felder, keine Reaktivierungs-Sonderfälle) - [ ] Kein Export/Cold-Storage nötig, da jederzeit über die Polymarket-API bzw. im Zweifel über die Blockchain nachladbar — vereinfacht C1 gegenüber der ursprünglichen Idee erheblich (keine Baseline-Felder, keine Reaktivierungs-Sonderfälle)
- [x] Optionales, leichtgewichtiges Langzeit-Signal (nice-to-have, niedrige Priorität): ein grobes Monats-Aggregat pro Trader (Monat, realisierter PnL, Trade-Anzahl, Volumen) für einen "war er über Monate hinweg konsistent profitabel"-Trend im Copytrading-Score (A5) — **ohne** Trade-Detailtiefe, nur wenige Kennzahlen pro Monat - [ ] Optionales, leichtgewichtiges Langzeit-Signal (nice-to-have, niedrige Priorität): ein grobes Monats-Aggregat pro Trader (Monat, realisierter PnL, Trade-Anzahl, Volumen) für einen "war er über Monate hinweg konsistent profitabel"-Trend im Copytrading-Score (A5) — **ohne** Trade-Detailtiefe, nur wenige Kennzahlen pro Monat
- [x] Neuer periodischer Cleanup-Job (`TradeRetentionWorker`), der Trades außerhalb des Fensters findet und löscht, nachdem die Voraussetzung (Position/Aggregat aktuell) erfüllt ist - [ ] Neuer periodischer Cleanup-Job (`TradeRetentionWorker`), der Trades außerhalb des Fensters findet und löscht, nachdem die Voraussetzung (Position/Aggregat aktuell) erfüllt ist
- [ ] Kurzer Stichprobentest, wie weit `/activity` pro Wallet tatsächlich zurückreicht (bestätigt/verifiziert nur die schon vorliegende Doku-Aussage, geringe Priorität da schon durch Nutzer-Recherche plausibilisiert) - [ ] Kurzer Stichprobentest, wie weit `/activity` pro Wallet tatsächlich zurückreicht (bestätigt/verifiziert nur die schon vorliegende Doku-Aussage, geringe Priorität da schon durch Nutzer-Recherche plausibilisiert)
### C2. Kompaktierung von Hochfrequenz-Tradern innerhalb des Zeitfensters (überarbeitete "Idee 2") ### C2. Kompaktierung von Hochfrequenz-Tradern innerhalb des Zeitfensters (überarbeitete "Idee 2")
- [x] Burst-Erkennung statt globalem Zeitraster: Sequenz von Trades mit Abstand kleiner als Schwellwert (z.B. 60s, konfigurierbar) zwischen aufeinanderfolgenden Trades **desselben Traders, Outcomes und derselben Seite (Buy/Sell)** gilt als "Burst" - [ ] Burst-Erkennung statt globalem Zeitraster: Sequenz von Trades mit Abstand kleiner als Schwellwert (z.B. 60s, konfigurierbar) zwischen aufeinanderfolgenden Trades **desselben Traders, Outcomes und derselben Seite (Buy/Sell)** gilt als "Burst"
- [x] Mindestlänge für Kompaktierung festlegen (z.B. erst ab 20+ Trades im Burst lohnt sich das) - [ ] Mindestlänge für Kompaktierung festlegen (z.B. erst ab 20+ Trades im Burst lohnt sich das)
- [x] Bestehende Bot-Heuristik (`intervals.Average() < 10` in `AnalyticsService`) als Ausgangspunkt wiederverwenden/verallgemeinern statt eine zweite, unabhängige Definition einzuführen - [ ] Bestehende Bot-Heuristik (`intervals.Average() < 10` in `AnalyticsService`) als Ausgangspunkt wiederverwenden/verallgemeinern statt eine zweite, unabhängige Definition einzuführen
- [x] Aggregat-Datensatz pro Burst: Anzahl Trades, Summe Size, Summe Amount, Min-Preis, Max-Preis, **VWAP** (nicht einfacher Durchschnitt) - [ ] Aggregat-Datensatz pro Burst: Anzahl Trades, Summe Size, Summe Amount, Min-Preis, Max-Preis, **VWAP** (nicht einfacher Durchschnitt)
- [x] Design-Entscheidung: Aggregat als zusätzliche nullable Spalten auf der bestehenden `Trade`-Tabelle (`AggregateCount`, `AggregateMinPrice`, `AggregateMaxPrice`) statt separater Tabelle — bestehender Code (PnL, Deep-Dive) muss dadurch kaum angepasst werden, ein Aggregat-Datensatz ist einfach ein "Trade" mit `Size = Summe`, `Price = VWAP` - [ ] Design-Entscheidung: Aggregat als zusätzliche nullable Spalten auf der bestehenden `Trade`-Tabelle (`AggregateCount`, `AggregateMinPrice`, `AggregateMaxPrice`) statt separater Tabelle — bestehender Code (PnL, Deep-Dive) muss dadurch kaum angepasst werden, ein Aggregat-Datensatz ist einfach ein "Trade" mit `Size = Summe`, `Price = VWAP`
- [x] **Reihenfolge beachten:** Diese Kompaktierung erst implementieren, nachdem A1 (neue PnL-Engine) steht und validiert ist — sonst kompaktieren wir Daten weg, bevor wir wissen, was die neue Engine wirklich braucht - [ ] **Reihenfolge beachten:** Diese Kompaktierung erst implementieren, nachdem A1 (neue PnL-Engine) steht und validiert ist — sonst kompaktieren wir Daten weg, bevor wir wissen, was die neue Engine wirklich braucht
### C3. Weitere eigene Vorschläge ### C3. Weitere eigene Vorschläge
- [ ] **Spaltentypen verkleinern**: `TransactionHash`, `MarketId`, `AssetId` sind Hex-/Dezimal-Strings fester Länge (z.B. `0x` + 64 Hex-Zeichen) — als `BINARY(32)` statt `VARCHAR(66/80)` speichern spart ca. 3050% Platz auf diesen stark indizierten Spalten und ist schneller - [ ] **Spaltentypen verkleinern**: `TransactionHash`, `MarketId`, `AssetId` sind Hex-/Dezimal-Strings fester Länge (z.B. `0x` + 64 Hex-Zeichen) — als `BINARY(32)` statt `VARCHAR(66/80)` speichern spart ca. 3050% Platz auf diesen stark indizierten Spalten und ist schneller
@@ -199,7 +153,7 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
- [ ] `Split`/`Merge`/`AddLiquidity`/`RemoveLiquidity`-Ereignisse (siehe A1) ggf. separat und kompakter ablegen, da sie für die Trader-Bewertung meist weniger relevant sind als `Buy`/`Sell` - [ ] `Split`/`Merge`/`AddLiquidity`/`RemoveLiquidity`-Ereignisse (siehe A1) ggf. separat und kompakter ablegen, da sie für die Trader-Bewertung meist weniger relevant sind als `Buy`/`Sell`
### C4. Abhängigkeit zu Phase 1 ### C4. Abhängigkeit zu Phase 1
- [x] Merksatz: **Erst A1 (neue PnL-Engine) fertigstellen, dann C1/C2 umsetzen.** Sonst laufen wir Gefahr, Rohdaten wegzuoptimieren, die die neue Engine noch gebraucht hätte. - [ ] Merksatz: **Erst A1 (neue PnL-Engine) fertigstellen, dann C1/C2 umsetzen.** Sonst laufen wir Gefahr, Rohdaten wegzuoptimieren, die die neue Engine noch gebraucht hätte.
--- ---
+7 -9
View File
@@ -1,12 +1,10 @@
# Predictalytics REST API — Referenz # Predictalytics REST API — Referenz
> Stand: 2026-08-23 · Interne API, nur lokal betreiben. > Stand: 2026-07-11 · Interne API ohne Authentifizierung (nur lokal betreiben!).
> **Lesende** Endpunkte sind ungeschützt, **steuernde** verlangen seit Phase 4 den > Interaktive Doku: **`/swagger`** auf dem laufenden Server (Standalone-API und
> Header `X-Predictalytics-Key`, sobald in den Einstellungen ein Token hinterlegt ist > WinForms-Embedded-Server registrieren identische Endpoints über
> (siehe `ApiTokenFilter`; `GET /api/capabilities` meldet der WebUI den Ist-Zustand). > `ApiConfiguration.MapPredictalyticsEndpoints()` — dort ist die einzige Stelle,
> Interaktive Doku: **`/swagger`** auf dem laufenden Server. Endpunkte werden > an der neue Endpoints registriert werden dürfen).
> ausschliesslich in `ApiConfiguration.MapPredictalyticsReadEndpoints()` bzw.
> `MapPredictalyticsControlEndpoints()` registriert — nur dort neue ergänzen.
Konventionen: JSON mit camelCase-Feldnamen · Zeiten in UTC (ISO 8601) · Konventionen: JSON mit camelCase-Feldnamen · Zeiten in UTC (ISO 8601) ·
Prozente auf 0100-Skala · Beträge in USD · `WinRate` ist marktbasiert Prozente auf 0100-Skala · Beträge in USD · `WinRate` ist marktbasiert
@@ -142,8 +140,8 @@ Jobs werden von den Workern abgearbeitet (Worker müssen laufen!). Status:
| `GET /api/dev/verify-positions/{traderId}` | Vergleicht unsere `TraderPositions` mit Polymarkets `/positions`-API; Antwort: `mismatches[]`, `match`, `dbPositionsCount`, `apiPositionsCount` | | `GET /api/dev/verify-positions/{traderId}` | Vergleicht unsere `TraderPositions` mit Polymarkets `/positions`-API; Antwort: `mismatches[]`, `match`, `dbPositionsCount`, `apiPositionsCount` |
> Ein früherer `POST /api/dev/repair-db` wurde entfernt (fehlerhaftes SQL, > Ein früherer `POST /api/dev/repair-db` wurde entfernt (fehlerhaftes SQL,
> destruktiv). Reparatur läuft über die Schaltfläche **Recalculate All Traders** > destruktiv). Reparatur läuft über den WinForms-Menüpunkt **Development →
> in der Avalonia-Shell. > Recalculate All Traders**.
--- ---
-232
View File
@@ -1,232 +0,0 @@
# Betrieb: Deployment Center
Das **Deployment Center** (`https://dc.mhdf.de`, Schwester-Repo
`J:\Softwareprojekte\Deploymentcenter`) hat die beiden früheren Einzelserver abgelöst:
| Vorher | Jetzt |
|---|---|
| WatchDog auf `watchdog.mhdf.de`, `POST /api/heartbeat`, Header `X-Watchdog-Key` | Deployment Center, `POST /api/watchdog/v1/ping`, `Authorization: Bearer` |
| LicenseLabrador auf `license.mhdf.de`, Ed25519-signierte Envelopes | Deployment Center, `POST /api/license/v1/validate`, Hardware-ID v2 |
| — | UpdateService, Fehler-Stream und Bugtracker (neu) |
Die Anbindung sitzt in `Predictalytics.Hosting` und referenziert das
C#-SDK `Deploymentcenter.Client` aus dem Schwester-Repo:
```
..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj
```
Das Repo muss also neben dem Predictalytics-Checkout liegen. `LicenseLabrador`
und `WatchDog` werden nicht mehr gebraucht.
---
## 0. Was einmalig zu tun ist
1. Im Deployment Center unter **Token-Verwaltung → Master-Token erstellen** ein
Token mit den Rechten **`watchdog:ping`** und **`bugtracker:report`** anlegen.
2. Im Host unter **Settings → Deployment Center → API Token** eintragen.
Der alte `X-Watchdog-Key` funktioniert nicht mehr — es ist ein anderer Server.
3. Beim ersten Start erscheint einmalig der Lizenzdialog: der Lizenzschlüssel muss
erneut eingegeben werden, weil die Aktivierung jetzt am Deployment Center hängt.
4. Serverseitig muss der **Evaluator-Cron** laufen — ohne ihn bleibt ein
abgestürzter Dienst dauerhaft grün:
```
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
```
Der Token landet in der `settings.json` neben der Exe (nicht im Git).
---
## 1. Konfiguration (PropertyGrid, Kategorie „Deployment Center")
| Feld | Default | Bedeutung |
|---|---|---|
| `Server URL` | `https://dc.mhdf.de` | **schreibgeschützt**, siehe unten |
| `API Token` | *(leer)* | `Authorization: Bearer`. Ohne Token: keine Heartbeats, keine Fehlermeldungen. |
| `Heartbeat aktiv` | `true` | Dead-Man's-Switch an/aus |
| `Monitor Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Ping) |
| `Monitor Instance` | `default` | falls mehrere Instanzen laufen |
| `Heartbeat-Intervall (Sekunden)` | `60` | `warning` nach 2×, `down` nach 4× |
| `Fehler melden` | `true` | Error/Fatal an den Fehler-Stream |
| `Update-Prüfung beim Start` | `true` | prüft nur, installiert nichts von selbst |
| `Update-Kanal` | `prod` | `prod`, `beta` oder `dev` |
**Warum ist die Server-URL nicht einstellbar?** Sie entscheidet, wohin die
Lizenzprüfung geht und woher Update-Pakete kommen. Ein einstellbarer Endpoint
würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu
zeigen. Sie steht als Konstante in `Services/DcConfig.cs`.
---
## 2. Watchdog (Dead-Man's-Switch)
`Services/DcHeartbeatService.cs` sendet alle *n* Sekunden einen
`POST /api/watchdog/v1/ping`. Beim regulären Schließen geht ein Ping mit
`status: "stopped"` raus — der Evaluator lässt einen so beendeten Monitor in
Ruhe, bis wieder ein normaler Heartbeat eintrifft. Ohne das folgt wenige Minuten
nach jedem geordneten Beenden ein Fehlalarm.
**Ein Ausfall des Deployment Centers darf Predictalytics nie beeinträchtigen.**
Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt,
Folgefehler nur noch auf Debug-Level (keine Log-Flut). Wird das Token abgelehnt
(`401`/`403`), stellt der Dienst die Versuche ganz ein — Wiederholen kann daran
nichts ändern.
### Mitgesendeter Gesundheitszustand
Ein Heartbeat beweist nur, dass ein Timer läuft. Deshalb schickt die App ihren
selbst ermittelten Zustand mit:
| `checks` | Bedeutung |
|---|---|
| `db` | `SELECT 1` gegen die konfigurierte MySQL, gedeckelt auf 5 s. Schlägt sie fehl, stuft der Server den Heartbeat auf `warning`. |
| `metrics` | Bedeutung |
|---|---|
| `uptime_sec` | Laufzeit des Prozesses |
| `workers_running` | 0/1 — Hintergrund-Worker gestartet |
| `webserver_running` | 0/1 — eingebetteter Kestrel gestartet |
| `memory_mb` | verwalteter Heap |
| `db_size_mb` | zuletzt ermittelte Datenbankgröße |
Zusätzlich geht seit Server 2.1 das Feld `version` mit — im Dashboard ist damit
sichtbar, welcher Build läuft, und ein Ausfall lässt sich einem Rollout zuordnen.
Gestoppte Worker sind **bewusst kein** fehlgeschlagener Check: das ist ein
gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning`
halten. Der Zustand steckt als Metrik drin und lässt sich dort auswerten.
Metriken werden serverseitig 14 Tage lang mit Verlauf gehalten
(`GET /api/watchdog/v1/metrics?source=Predictalytics`).
### Maschine mit überwachen
Der Heartbeat deckt nur *diesen* Prozess ab. Für die Maschine selbst gehört
zusätzlich ein OS-Agent auf den Host (siehe
`Deploymentcenter/docs/WATCHDOG_INTEGRATION_GUIDE.md`). Läuft beides, sollte im
WebUI unter **WatchDog → System-Hierarchie** die *Übergeordnete Entität* des
Predictalytics-Monitors auf den Host gesetzt werden — dann erzeugt ein
Maschinenausfall eine Meldung statt zwei.
---
## 3. Lizenzierung
`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt;
`Program.Main` bricht sonst ab, bevor die MainForm entsteht. Ohne gültige Lizenz
erscheint `Services/LicenseDialog.cs` zur Key-Eingabe.
- **Produkt-Slug:** `predictalytics`
- **Hardware-ID v2:** `2:win:<sha256>` aus `HKLM\...\Cryptography\MachineGuid`.
Der Rechnername steckt **nicht** im Hash — Umbenennen kostet keinen
Aktivierungsplatz.
- **Offline-Cache:** AES-256-GCM + DPAPI unter `%AppData%\predictalytics\license\state.dat`
(Schema 3 seit SDK 2.1, Schema 2 wird noch gelesen). Dort liegt auch der
Lizenzschlüssel — `LicenseClient.TryGetCachedKey()` holt ihn beim Start, damit
ohne Dialog revalidiert werden kann.
- **Revalidierung zur Laufzeit:** alle 12 h.
- **Gemeldete Version:** `LicenseClient.DefaultAppVersion` wird in `Program.Main`
auf `BuildInfo.Version` gesetzt; ohne das trüge jede Installation in der
Aktivierungsliste dieselbe „1.0.0".
### Offline-Gnadenfrist
Seit Server 2.1 ist sie **echt begrenzt**: der Server liefert `cache_ttl_hours`
(je Projekt im WebUI, Standard 168 h = 7 Tage). Vorher galt faktisch das
Lizenz-Ablaufdatum, bei einer Lizenz bis 2040 also unbegrenzt.
Läuft die Frist in weniger als 48 h ab, warnt der Host beim Start und bei jeder
Revalidierung. Die verbleibende Frist zeigt **Deployment Center → Lizenzstatus
anzeigen**. Falls Predictalytics planmäßig länger offline laufen soll, muss die
TTL im WebUI für das Projekt `predictalytics` heraufgesetzt werden.
### Wann die App sich beendet — und wann nicht
Maßgeblich ist `LicenseValidationResult.IsTransient`:
| | Zustände | Reaktion |
|---|---|---|
| `IsValid` | `valid`, `valid_offline` | weiter |
| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur eine gescheiterte Verbindung** — weiterlaufen, beim nächsten Durchlauf erneut versuchen |
| sonst | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Meldung und Beenden |
`cache_expired` beendet die laufende Sitzung also nicht — der **nächste Start**
bleibt aber am Lizenzdialog hängen, weil `IsValid` dann falsch ist. Der Host
loggt diesen Fall deshalb als Error, nicht bloß als Warnung.
### Deaktivierung bei PC-Wechsel
`POST /api/license/v1/deactivate` verlangt den `shared_key` aus der
Server-Konfiguration. Der gehört **nicht** in die ausgelieferte Anwendung,
deshalb ist die Funktion hier nicht verdrahtet. Übliche Route: im WebUI unter
**Lizenzen → Hardware-Liste → „Freigeben"**. Die Hardware-ID zeigt der Host unter
**Deployment Center → Lizenzstatus anzeigen**.
---
## 4. Fehler-Stream
`Services/DcErrorReporter.cs` meldet Laufzeitfehler an
`POST /api/errors/v1/report`. Zwei Wege führen dorthin:
1. **Globale Handler** — `Application.ThreadException` (UI-Thread),
`AppDomain.UnhandledException` (Level `fatal`, wird blockierend gesendet, weil
der Prozess gleich weg ist) und `TaskScheduler.UnobservedTaskException`.
2. **Serilog-Sink** (`Services/DcErrorSink.cs`) — jedes `Log.Error`/`Log.Fatal`,
auch aus Worker und Kestrel.
Lokale Bremse, damit das Rate-Limit (60/min und IP) nicht verbrannt wird:
höchstens 20 Meldungen pro Minute, derselbe Fehler höchstens alle 5 Minuten.
Serverseitig werden gleiche Fehler ohnehin gruppiert und hochgezählt;
betriebsbedingtes Rauschen gehört in die **Ignore-Regeln** des WebUI — ein
Treffer zählt weiter, meldet aber nicht.
> Der Serilog-Filter in `Program.cs` wirft `Duplicate entry`-Ausnahmen schon
> vorher weg; die erreichen den Stream also gar nicht.
---
## 5. Updates
`Services/DcUpdateService.cs` fragt beim Start
`GET /api/updateservice/v1/check` ab (ohne Token). Gefunden wird nur — installiert
wird nichts von selbst:
- Kein Update: Info ins Log.
- Update verfügbar: Warnung ins Log, Hinweis in der Statusleiste.
- **Kritisches** Update: zusätzlich ein Dialog.
- Menü **Deployment Center → Nach Updates suchen** prüft jederzeit von Hand.
Liegt `update-agent.exe` neben der Anwendung, bietet der Dialog an, ihn zu
starten; die Anwendung meldet dann vorher `stopped` und beendet sich. Fehlt der
Agent, weist der Dialog auf die manuelle Installation hin.
Seit SDK 2.1 liefert auch der API-Zweig vollständige Release-Daten
(Download-Adresse, Prüfsumme, Changelog, Kritikalität) — der Dialog zeigt die
Release Notes also auch dann, wenn die statische `latest.json` fehlt.
### Versionsstand
`Predictalytics.Hosting.csproj` importiert `Deploymentcenter.BuildInfo.targets`
und erzeugt daraus zur Übersetzungszeit `Predictalytics.Hosting.BuildInfo`
mit `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`, `Channel` und
`Summary`. Quelle ist `<Version>` in `Directory.Build.props` — beim Release dort
hochziehen und mit
```
pack-and-deploy --project predictalytics --version <x> --channel prod
```
veröffentlichen. Dieselbe Version geht an die Lizenz-Aktivierungsliste, an den
Heartbeat und als `build` an den Fehler-Stream; `GitCommitShort` reist im
`context` jeder Fehlermeldung mit.
---
## 6. Bugtracker
Der Agenten-Workflow ist in [`../CLAUDE.md`](../CLAUDE.md) beschrieben.
Projekt-Slug: `predictalytics`. Items, deren `resolved_in_build` einer
veröffentlichten Version entspricht, schließen sich beim Publish von selbst.
+82
View File
@@ -0,0 +1,82 @@
# Betrieb: Watchdog-Überwachung & LicenseLabrador-Lizenzierung
Beide Integrationen sitzen im `Predictalytics.WinFormsHost` (dem Produktiv-Host) und
binden zwei eigenständige Schwester-Projekte an:
| Projekt | Pfad | Rolle |
|---|---|---|
| Watchdog | `J:\Softwareprojekte\WatchDog` | PHP/MySQL-Server auf `watchdog.mhdf.de`, empfängt Heartbeats |
| LicenseLabrador | `J:\Softwareprojekte\LicenseLabrador` | PHP-Lizenzserver auf `license.mhdf.de` + C#-SDK |
---
## 1. Watchdog (Dead-Man's-Switch)
`Services/WatchdogHeartbeatService.cs` sendet alle *n* Sekunden einen
`POST /api/heartbeat` an den Watchdog. Bleiben die Heartbeats aus — weil die App
abgestürzt ist oder die ganze Maschine weg ist — schlägt der Watchdog Alarm.
Beim regulären Schließen geht ein `POST /api/event` mit `kind=stopped_graceful`
raus, damit ein geplantes Beenden nicht als Crash alarmiert wird.
> **Nicht auf `kind=stopping` ändern.** Der Watchdog akzeptiert den Wert im
> Router, aber `event_log.kind` ist ein ENUM ohne `stopping`. Der Server setzt
> dann zwar noch den Zustand auf `stopped`, scheitert aber am Insert und liefert
> HTTP 500 — das Event fehlt in der Historie. Verifiziert am 2026-07-30.
**Wichtig:** Ein Ausfall des Watchdogs darf Predictalytics nie beeinträchtigen.
Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt,
Folgefehler nur noch auf Debug-Level (keine Log-Flut).
### Konfiguration (PropertyGrid im Host, Kategorie „Watchdog")
| Feld | Default | Bedeutung |
|---|---|---|
| `Enabled` | `true` | Heartbeats an/aus |
| `Server URL` | `https://watchdog.mhdf.de` | Basis-URL |
| `API Key` | *(leer)* | `X-Watchdog-Key` — Shared Key **oder** Agent-Token. Ohne Key passiert nichts. |
| `Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Beat) |
| `Instance` | `default` | falls mehrere Instanzen laufen |
| `Interval (Sekunden)` | `60` | Sende-Takt; Alarm nach ca. `Intervall × 1,5 + 30 s` |
Der Key landet in der `settings.json` neben der Exe (nicht im Git).
### PolyTrader-Maschine mit überwachen
Der Heartbeat aus Predictalytics deckt nur *diesen* Prozess ab. Damit auch die
Maschine überwacht wird, auf der PolyTrader läuft, gehört dort zusätzlich der
OS-Agent hin: `WatchDog\agents\windows\watchdog-agent.ps1` als Scheduled Task
(inkl. Shutdown-Hook), bzw. `agents/linux/watchdog-agent.sh` per systemd-Timer.
---
## 2. LicenseLabrador (Kopierschutz)
`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt
(`Program.Main` bricht sonst ab, bevor die MainForm überhaupt entsteht).
Ohne gültige Lizenz erscheint `Services/LicenseDialog.cs` zur Key-Eingabe.
Produkt-Slug, Endpoint und der Ed25519-Public-Key sind **bewusst einkompiliert**
und nicht konfigurierbar — ein einstellbarer Endpoint würde erlauben, die App auf
einen gefälschten Lizenzserver zu zeigen.
- **Produkt-Slug:** `predictalytics`
- **Offline-Gnadenfrist:** 168 h (7 Tage) — danach ist Serverkontakt nötig
- **Revalidierung zur Laufzeit:** alle 12 h; bei Widerruf/Ablauf beendet sich die App
- **Härtung:** `VerifyChecksum` (HMAC über State + Key + Hardware-ID) gegen Memory-Patches;
Nonce-Reflexion und Signaturprüfung übernimmt das SDK
### Status
Produkt `predictalytics` und Lizenzschlüssel sind angelegt; die Aktivierung wurde
am 2026-07-30 gegen den Produktivserver verifiziert (`Valid`, gültig bis
2040-12-31, Checksum-Prüfung bestanden). Der einkompilierte Public Key passt zum
`signing.pub` des Servers — sonst käme `TamperSuspected` statt `Valid`.
Die Aktivierung ist an die Hardware-ID der jeweiligen Maschine gebunden. Auf
einem neuen Rechner erscheint einmalig der Dialog; der Cache liegt danach unter
`%AppData%\predictalytics\license`.
### Deaktivierung bei PC-Wechsel
`LicenseClient.DeactivateAsync()` gibt die Aktivierung wieder frei. Aktuell nicht
in der UI verdrahtet — bei Bedarf als Menüpunkt ergänzen.
@@ -1,27 +1,5 @@
# Plan: Drei-Schichten-Architektur (Core / Lokal / Public) # Plan: Drei-Schichten-Architektur (Core / Lokal / Public)
> **Archiviert am 2026-08-23** — offene Punkte siehe [`ROADMAP.md`](../../ROADMAP.md),
> Stufe 6. Dieses Dokument wird nicht mehr gepflegt.
>
> **Phase 0 bis 2 sind erledigt**, überwiegend als Nebenprodukt der Linux-Portierung, und
> hier nachträglich abgehakt:
> * Read/Control-Split (`ApiConfiguration.MapPredictalyticsReadEndpoints()` bzw.
> `MapPredictalyticsControlEndpoints()`), `/api/dev/*` und die Job-Trigger liegen auf der
> Control-Seite.
> * `GET /api/capabilities` existiert und meldet den tatsächlichen Zustand; die WebUI
> koppelt ihre Admin-Aktionen daran (`app.js:880,1132`).
> * `API_BASE` ist im SPA konfigurierbar (`app.js:2`), Trait-Chips sind da.
> * CORS läuft über eine Origin-Whitelist, die Bind-Adresse ist explizit
> (`PredictalyticsHost.cs:177,198-201`) — kein `0.0.0.0`.
>
> **Nicht abgehakt** ist der Deep-Dive-Audit auf versteckte Writes in Read-Endpunkten
> (Phase 0, dritter Punkt): die Trennung steht, ob jeder Read-Endpunkt einzeln daraufhin
> geprüft wurde, lässt sich im Nachhinein nicht belegen. Vor einem öffentlichen Host
> nachholen.
>
> **Phase 3 und 4 sind offen** und bilden Roadmap-Stufe 6 — dort als Konzept markiert,
> weil über eine Monetarisierung nicht entschieden ist.
> Stand: 2026-07-12 · Status: **Architektur bestätigt** (Nutzer-Entscheidung), Implementierung > Stand: 2026-07-12 · Status: **Architektur bestätigt** (Nutzer-Entscheidung), Implementierung
> schrittweise. Kein Code in diesem Schritt. > schrittweise. Kein Code in diesem Schritt.
> Kontext: Neues WebUI-Design (mit Claude Design vorbereitet) wird nach der Schicht-Trennung > Kontext: Neues WebUI-Design (mit Claude Design vorbereitet) wird nach der Schicht-Trennung
@@ -110,24 +88,24 @@ und von beiden Hosts wiederverwendet — kein Duplizieren:
## 3. Implementierungsplan (schrittweise) ## 3. Implementierungsplan (schrittweise)
### Phase 0 — Endpunkte klassifizieren & Read/Control trennen (Fundament, klein) ### Phase 0 — Endpunkte klassifizieren & Read/Control trennen (Fundament, klein)
- [x] Jeden Endpunkt in `docs/API.md` als **Read** (public-tauglich) oder **Control** (nur lokal) - [ ] Jeden Endpunkt in `docs/API.md` als **Read** (public-tauglich) oder **Control** (nur lokal)
markieren. Kandidaten Control: alle `POST/PUT/DELETE`, `/api/dev/*`, Job-Trigger. markieren. Kandidaten Control: alle `POST/PUT/DELETE`, `/api/dev/*`, Job-Trigger.
- [ ] Auf **versteckte Writes/Provider-Calls** in Read-Endpunkten prüfen (Deep-Dive!) und - [ ] Auf **versteckte Writes/Provider-Calls** in Read-Endpunkten prüfen (Deep-Dive!) und
bereinigen oder als Control einstufen. bereinigen oder als Control einstufen.
- [x] `MapPredictalyticsEndpoints()` in `MapReadEndpoints()` + `MapControlEndpoints()` splitten - [ ] `MapPredictalyticsEndpoints()` in `MapReadEndpoints()` + `MapControlEndpoints()` splitten
(rein struktureller Refactor, Verhalten unverändert; bestehende Tests bleiben grün). (rein struktureller Refactor, Verhalten unverändert; bestehende Tests bleiben grün).
- [x] `GET /api/capabilities` einführen. - [ ] `GET /api/capabilities` einführen.
### Phase 1 — WebUI als sauberes, gemeinsames SPA (parallel zum neuen Design) ### Phase 1 — WebUI als sauberes, gemeinsames SPA (parallel zum neuen Design)
- [x] Neues Design als **ein statisches Artefakt** bauen, reiner API-Client, `API_BASE` - [ ] Neues Design als **ein statisches Artefakt** bauen, reiner API-Client, `API_BASE`
konfigurierbar (heute `''` = gleiche Origin bleibt lokal gültig). konfigurierbar (heute `''` = gleiche Origin bleibt lokal gültig).
- [x] Admin-Aktionen an `capabilities.canControl` koppeln. - [ ] Admin-Aktionen an `capabilities.canControl` koppeln.
- [x] Trait-Chips/-Filter im Design vorsehen — **erscheinen erst, wenn Backend-D2 steht** - [ ] Trait-Chips/-Filter im Design vorsehen — **erscheinen erst, wenn Backend-D2 steht**
(kein UI-Bug, das Backend liefert Traits noch nicht). (kein UI-Bug, das Backend liefert Traits noch nicht).
### Phase 2 — Lokale Grenze härten (klein, jetzt schon sinnvoll) ### Phase 2 — Lokale Grenze härten (klein, jetzt schon sinnvoll)
- [x] CORS von `AllowAnyOrigin` auf konfigurierte Origin-Whitelist umstellen. - [ ] CORS von `AllowAnyOrigin` auf konfigurierte Origin-Whitelist umstellen.
- [x] Bind-Adresse explizit (localhost/LAN), nie `0.0.0.0` ohne Firewall; Konfig-Kommentar - [ ] Bind-Adresse explizit (localhost/LAN), nie `0.0.0.0` ohne Firewall; Konfig-Kommentar
„NIEMALS ins Internet — das ist die interne Schicht". „NIEMALS ins Internet — das ist die interne Schicht".
### Phase 3 — DB für Public vorbereiten (mittel, kann früh passieren) ### Phase 3 — DB für Public vorbereiten (mittel, kann früh passieren)
@@ -1,26 +1,5 @@
# Plan: Daten-Ingest skalieren (Rate-Limits, Egress-Kanäle, Blockchain) # Plan: Daten-Ingest skalieren (Rate-Limits, Egress-Kanäle, Blockchain)
> **Archiviert am 2026-08-23** — offene Punkte siehe [`ROADMAP.md`](../../ROADMAP.md),
> Stufe 5. Dieses Dokument wird nicht mehr gepflegt.
>
> **Erledigt und hier nachträglich abgehakt:**
> * **1c Ingest-Tiering** — `IngestMode` mit `SnapshotOnly`/`Aggregated`, siehe
> [`FIXPLAN-TODO.md`](FIXPLAN-TODO.md) Teil D.
> * **1d Redundanz** — `IMemoryCache` im `PolymarketApiClient`.
> * **2a/2b Egress-Kanäle** — `Infrastructure/Services/EgressPoolService.cs` und
> `EgressPoolHandler.cs`: Round-Robin über die aktiven Kanäle, Umschalten rein per
> Konfiguration, Cooldown nach drei Fehlschlägen in Folge (`:152`), sodass ein 429 nur
> den betroffenen Kanal trifft. Der Limiter-Schlüssel enthält den Kanal
> (`RateLimiterService.cs:36`). Tests: `EgressPoolTests.cs`.
>
> **Teilweise:** Health-Statistik je Kanal (Erfolgsquote, 429-Rate, Latenz) fehlt — es gibt
> nur Fehlerzähler und Cooldown. Tote Kanäle werden also pausiert, aber nicht ausgewertet.
>
> **Offen:** 1a (Blockchain/Subgraph als Bulk-Quelle) und 1b (Markt-Stammdaten-Cache) sind
> Roadmap 5.1 und 5.2. **Abschnitt 3a (Header-Rotation) ist bewusst verworfen** — siehe
> Roadmap 5.3: Verschleierung gegenüber Polymarket riskiert genau den Zugang, auf dem das
> Projekt aufsetzt.
> Stand: 2026-07-12 · Status: **Richtung bestätigt** (Nutzer: beide Egress-Wege umschaltbar > Stand: 2026-07-12 · Status: **Richtung bestätigt** (Nutzer: beide Egress-Wege umschaltbar
> umsetzen, sofern Aufwand vertretbar). Kein Code in diesem Schritt. > umsetzen, sofern Aufwand vertretbar). Kein Code in diesem Schritt.
> Problem: Polymarket-Rate-Limits bremsen den Datenimport teils extrem aus. > Problem: Polymarket-Rate-Limits bremsen den Datenimport teils extrem aus.
@@ -53,11 +32,11 @@ REST-Verbraucher) ist `/activity` die falsche Quelle.
nur Volume/Liquidity/Auflösung periodisch aktualisieren. Geschlossene Märkte nie voll re-syncen. nur Volume/Liquidity/Auflösung periodisch aktualisieren. Geschlossene Märkte nie voll re-syncen.
### 1c. Ingest-Tiering (FIXPLAN D3) ### 1c. Ingest-Tiering (FIXPLAN D3)
- [x] Ultra-HF-Trader im `SnapshotOnly`-Modus erzeugen null Trade-Calls (PnL aus `/positions` + - [ ] Ultra-HF-Trader im `SnapshotOnly`-Modus erzeugen null Trade-Calls (PnL aus `/positions` +
Leaderboard, wöchentliche Biopsie). Entfernt genau die Wallets, die das Budget auffressen. Leaderboard, wöchentliche Biopsie). Entfernt genau die Wallets, die das Budget auffressen.
### 1d. Redundanz vermeiden ### 1d. Redundanz vermeiden
- [x] Worker-übergreifender Kurzzeit-Cache „gerade geholt", damit nicht mehrere Worker denselben - [ ] Worker-übergreifender Kurzzeit-Cache „gerade geholt", damit nicht mehrere Worker denselben
Markt/Trader kurz hintereinander abrufen. Markt/Trader kurz hintereinander abrufen.
--- ---
@@ -80,18 +59,18 @@ Ein Kanal ist genau eine Ausgangsroute, per Config als einer von zwei Typen defi
] ]
} }
``` ```
- [x] Pro Kanal **ein** `SocketsHttpHandler`: - [ ] Pro Kanal **ein** `SocketsHttpHandler`:
- `SourceIp``ConnectCallback`, Socket vor Connect an die lokale IP binden - `SourceIp``ConnectCallback`, Socket vor Connect an die lokale IP binden
(`socket.Bind(new IPEndPoint(ip, 0))`). (`socket.Bind(new IPEndPoint(ip, 0))`).
- `Proxy``handler.Proxy = new WebProxy(url); handler.UseProxy = true;`. - `Proxy``handler.Proxy = new WebProxy(url); handler.UseProxy = true;`.
- [x] `IEgressPool` verteilt Requests round-robin/least-loaded über die aktiven Kanäle. - [ ] `IEgressPool` verteilt Requests round-robin/least-loaded über die aktiven Kanäle.
Leere/❑ Kanalliste = heutiges Verhalten (ein Default-Ausgang). Leere/❑ Kanalliste = heutiges Verhalten (ein Default-Ausgang).
- [x] Umschalten „nur eigene IPs" ↔ „nur Proxys" ↔ „Mix" = Config ändern, kein Deploy-Umbau. - [ ] Umschalten „nur eigene IPs" ↔ „nur Proxys" ↔ „Mix" = Config ändern, kein Deploy-Umbau.
### 2b. Rate-Limiter **pro Kanal** (Generalisierung des globalen Limiters) ### 2b. Rate-Limiter **pro Kanal** (Generalisierung des globalen Limiters)
- [x] Limiter-Schlüssel wird `{platform}-{endpointGroup}-{channelId}`. Jeder Kanal hält sein - [ ] Limiter-Schlüssel wird `{platform}-{endpointGroup}-{channelId}`. Jeder Kanal hält sein
eigenes Budget → N Kanäle ≈ N× Durchsatz, jeder Kanal bleibt unter dem Per-Route-Limit. eigenes Budget → N Kanäle ≈ N× Durchsatz, jeder Kanal bleibt unter dem Per-Route-Limit.
- [x] 429 sperrt **nur den betroffenen Kanal** kurz, nicht alle. - [ ] 429 sperrt **nur den betroffenen Kanal** kurz, nicht alle.
### 2c. Betrieb ### 2c. Betrieb
- [ ] Bei eigenen IPs prüfen: sind es echte getrennte Egress-IPs (multi-homed), nicht NAT hinter einer. - [ ] Bei eigenen IPs prüfen: sind es echte getrennte Egress-IPs (multi-homed), nicht NAT hinter einer.
@@ -144,10 +123,10 @@ Auf Wunsch integriert, bewusst opt-in:
(welcher Subgraph deckt Fills sauber ab?), dann als `IHistoricalTradeSource`. (welcher Subgraph deckt Fills sauber ab?), dann als `IHistoricalTradeSource`.
## 5. Tests / Abnahme ## 5. Tests / Abnahme
- [x] Per-Kanal-Limiter: unabhängige Budgets (kein globales Blocken); 429 sperrt nur einen Kanal. - [ ] Per-Kanal-Limiter: unabhängige Budgets (kein globales Blocken); 429 sperrt nur einen Kanal.
- [x] Egress-Binding: Smoke gegen einen Dienst, der die Quell-IP zurückgibt → Round-Robin nutzt - [ ] Egress-Binding: Smoke gegen einen Dienst, der die Quell-IP zurückgibt → Round-Robin nutzt
wirklich verschiedene IPs; Proxy-Kanal geht über den Proxy. wirklich verschiedene IPs; Proxy-Kanal geht über den Proxy.
- [x] Kanal-Health: toter Proxy wird automatisch pausiert, Pool weicht aus. - [ ] Kanal-Health: toter Proxy wird automatisch pausiert, Pool weicht aus.
- [ ] Blockchain-Quelle: Stichproben-Abgleich Bulk-Historie ↔ REST-`/activity` eines Wallets, - [ ] Blockchain-Quelle: Stichproben-Abgleich Bulk-Historie ↔ REST-`/activity` eines Wallets,
bevor sie produktiv wird. bevor sie produktiv wird.
- [ ] **Header-Rotation:** bei `Enabled=false` wird genau ein konsistenter Standard-Satz gesendet - [ ] **Header-Rotation:** bei `Enabled=false` wird genau ein konsistenter Standard-Satz gesendet
-407
View File
@@ -1,407 +0,0 @@
# Analyse: Linux-Fähigkeit von Predictalytics
**Stand:** 2026-08-06 · Branch `main` @ `aa19a89` · reine Ist-Analyse, keine Codeänderungen
---
## 0. Kernbefund vorab
> **Avalonia UI und LiveCharts2 werden nicht benötigt.**
Die Annahme im Auftrag war, dass eine WinForms-Oberfläche auf Avalonia + LiveCharts2 portiert
werden muss. Das trifft auf dieses Projekt nicht zu:
* Die **eigentliche Analyse-Oberfläche ist bereits eine Browser-Anwendung**
`src/Predictalytics.Api/wwwroot/` (3.745 Zeilen HTML/CSS/JS), Charts über
**Chart.js 4.4.4** (`index.html:9`, CDN). Sie ist damit heute schon plattformunabhängig.
* `Predictalytics.WinFormsHost` ist **kein UI-Projekt im fachlichen Sinne**, sondern eine
Bedien-Hülle für den Betrieb: Start/Stop der Worker, Start/Stop des Kestrel-Webservers,
ein `PropertyGrid` für Einstellungen, eine `RichTextBox` als Log-Terminal und vier
manuelle Wartungsaktionen. **1.698 LOC inkl. Designer-Datei.** Kein einziges Diagramm.
Die Aufgabe lautet damit nicht „UI-Framework migrieren", sondern
**„Bedien-Hülle durch einen headless Host ersetzen"** — eine Größenordnung weniger Aufwand.
---
## 1. Portierungsstatus je Projekt
| Projekt | TFM | Status | Bemerkung |
|---|---|---|---|
| `Predictalytics.Domain` | `net8.0` | ✅ portabel | 42 Dateien, reine Fachlogik |
| `Predictalytics.Application` | `net8.0` | ✅ portabel | 39 Dateien |
| `Predictalytics.Infrastructure` | `net8.0` | ✅ portabel | 94 Dateien, EF Core + Pomelo MySQL |
| `Predictalytics.Api` | `net8.0` | ✅ portabel | Minimal-APIs + wwwroot |
| `Predictalytics.Worker` | `net8.0` | ✅ portabel | 13 Worker-Services |
| `Predictalytics.Application.Tests` | `net10.0` | ⚠️ Inkonsistenz | s. Punkt 4.8 |
| `Predictalytics.WinFormsHost` | `net8.0-windows` | ❌ **muss ersetzt werden** | einziger Blocker |
| `LicenseLabrador.Client` (Schwester-Repo) | `netstandard2.0` | ⚠️ teilportabel | s. Punkt 3.2 |
**54.056 Zeilen C# gesamt. Davon sind 1.698 (3,1 %) windows-gebunden — und davon ist
etwa die Hälfte (`EmbeddedWebServer.cs`, Serilog-Setup) inhaltlich bereits portabel und
nur im falschen Projekt beheimatet.**
---
## 2. Was bereits sauber ist (und keinen Aufwand erzeugt)
Diese Punkte wurden explizit geprüft, weil sie erfahrungsgemäß die teuren Überraschungen
einer Linux-Portierung sind. Der Code ist hier durchweg in gutem Zustand:
### 2.1 Zeitzonen — überwiegend sauber, mit einer wichtigen Ausnahme ⚠️
> **Korrektur (2026-08-06, nach Phase 0):** Die erste Fassung dieser Analyse stufte den
> Punkt als „vollständig sauber, Aufwand 0" ein. Das war falsch — siehe unten. Die
> explizite Zeitzonen-*API*-Nutzung ist sauber, die *implizite* über `DateTime.TryParse`
> war es nicht.
```
166 × DateTime.UtcNow / DateTimeOffset.UtcNow
0 × DateTime.Now · DateTime.Today · DateTimeOffset.Now
0 × ToLocalTime() · DateTimeKind.Local
0 × TimeZoneInfo / FindSystemTimeZoneById
13 × DateTime.TryParse ohne Culture/Styles ← Problem
```
Der klassische Linux-Killer `TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time")`
(Windows-IDs existieren unter Linux nicht, dort gilt IANA `Europe/Berlin`) **kommt nicht
vor**. Insoweit ist die Codebasis sauber.
**Übersehen hatte ich die implizite Variante:** `DateTime.TryParse("...Z")` ohne
`DateTimeStyles` liefert `Kind=Local` und **rechnet den Wert in Lokalzeit um**. In beiden
Providern wurden so ISO-Zeitstempel der Plattform-APIs eingelesen:
* `PolymarketProvider.cs``StartDate`, `EndDate`, `CreatedAt`, `ClosedAt` (10 Stellen)
* `LimitlessProvider.cs``ExecutedAt`, `ExpirationDate` (2 Stellen)
Dass UTC gemeint war, belegen die Fallbacks: jede dieser Zuweisungen fällt bei
Parse-Fehler auf `DateTime.UtcNow` zurück, und direkt daneben steht `DbCreatedAt =
DateTime.UtcNow`. Auf der Berliner Entwicklungsmaschine landeten die geparsten Werte
dadurch um 12 Stunden verschoben in der Datenbank, die Fallback-Werte dagegen korrekt.
Für die Portierung ist das doppelt relevant: derselbe Code hätte auf einem Linux-Server
mit `TZ=UTC` **andere Werte** produziert als auf dem Windows-Rechner.
**Behoben in Phase 0** über `Infrastructure/Helpers/DateParsing.cs`
(`InvariantCulture` + `AdjustToUniversal | AssumeUniversal`).
⚠️ **Offen: Altdaten.** Bereits gespeicherte Zeilen tragen die Verschiebung weiter. Ob ein
Backfill nötig ist, hängt daran, wie stark `StartDate`/`EndDate`/`ClosedAt` in die
Auswertung eingehen — siehe „Nächste Schritte" im Portierungsplan.
### 2.2 Kulturabhängige Zahlenformatierung — bis auf eine Stelle sauber ✅
Alle Parser für Fremd-APIs (Polymarket, Limitless) verwenden explizit `InvariantCulture`:
* `LimitlessProvider.cs:59,60,110,113,114,115`
* `PolymarketModels.cs:17` · `PolymarketProvider.cs:386`
* `TradeHistoryWorker.cs:466,467`
**Eine Ausnahme (echter Latent-Bug, plattformunabhängig):**
```csharp
// Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs:268
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
```
Ohne `InvariantCulture`. Auf einem System mit deutscher Locale (`de_DE.UTF-8`, unter Linux
sehr üblich) wird `"1234.56"` als **123456** interpretiert — Faktor 100.000 zu hoch.
Der Fehler existiert schon heute auf einem deutschsprachigen Windows; Linux macht ihn nur
wahrscheinlicher, weil die Locale dort oft explizit gesetzt wird.
**Aufwand: 5 Minuten. Sollte unabhängig von der Portierung gefixt werden.**
Zusätzliche Absicherung empfohlen: `InvariantGlobalization` bzw. ein explizites
`CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture` im neuen Host.
### 2.3 Pfade und Dateisystem ✅
* Keine hartkodierten Laufwerksbuchstaben im Code — **eine** Ausnahme, siehe 4.5.
* `Path.Combine()` wird konsequent verwendet, keine manuell zusammengesetzten `\`-Pfade.
* **Kein einziger Datei-/Verzeichniszugriff** in `Infrastructure`, `Worker`, `Api`,
`Application`, `Domain`. Sämtliches File-IO liegt in `WinFormsHost` (Settings, Logs)
und im Lizenz-Client.
### 2.4 Groß-/Kleinschreibung (case-sensitive Dateisystem) ✅
Linux-Dateisysteme sind case-sensitiv, Windows nicht — ein häufiger Stolperstein bei
statischen Web-Assets. Geprüft:
| Referenz | Datei | Passt |
|---|---|---|
| `href="css/style.css?v=20260715"` | `wwwroot/css/style.css` | ✅ |
| `src="js/app.js?v=20260715"` | `wwwroot/js/app.js` | ✅ |
| `href="./docs.html"` | `wwwroot/docs.html` | ✅ |
| `href="./index.html"` | `wwwroot/index.html` | ✅ |
Alles durchgängig kleingeschrieben und deckungsgleich. **Aufwand: 0.**
Hinweis: MySQL-Tabellennamen sind unter Linux abhängig von `lower_case_table_names`
ebenfalls case-sensitiv. Da Prod-DB und Dev-DB bereits auf Linux-Servern laufen
(`lqf7.your-database.de`) und der Code über EF Core mit konsistenter Schreibweise
zugreift, besteht hier kein zusätzliches Risiko durch die Client-Portierung.
### 2.5 Keine Windows-spezifischen APIs außerhalb des Hosts ✅
Geprüft auf `Registry`, `Microsoft.Win32`, `ManagementObject`/WMI, `ProtectedData`,
`DllImport`, `System.Drawing`, `Environment.OSVersion`: **keine Treffer** außerhalb von
`Predictalytics.WinFormsHost`. Auch `Encoding.Default` (unter Linux immer UTF-8, unter
Windows Codepage) kommt nirgends vor.
### 2.6 NuGet-Abhängigkeiten ✅
Alle verwendeten Pakete sind plattformneutral: EF Core 8 + Pomelo MySQL, Serilog (+ File/
Console/Compact), Swashbuckle, `Microsoft.Extensions.*`, xunit, Moq. Keine
windows-exklusiven Pakete außerhalb des WinForms-Hosts.
### 2.7 Ein bereits vorhandener Vorteil: der Composition Root existiert schon ✅
`Predictalytics.Api/ApiConfiguration.cs` (`ConfigureApi`) ist eine **vollständige,
eigenständige Host-Konfiguration** — DI, CORS, Swagger, Static Files, Endpoint-Mapping.
Ihr fehlt nur eine `Main`-Methode. Der neue headless Host kann darauf direkt aufsetzen,
statt die Verdrahtung neu zu schreiben.
Ebenso ist `Infrastructure/Logging/RichTextBoxSink.cs` trotz des Namens **vollständig
portabel** — es ist ein reiner Delegate-Sink (`Action<string, LogEventLevel>`) ohne
WinForms-Referenz. Lediglich umbenennen (z. B. `DelegateSink`) wäre sinnvoll.
---
## 3. Die tatsächlichen Blocker
### 3.1 `Predictalytics.WinFormsHost` — Ersatz durch headless Host ⛔ **Hauptaufwand**
Das Projekt ist der einzige Einstiegspunkt der Anwendung (`static void Main` existiert nur
hier). Es leistet fünf Dinge, die im neuen Host anders gelöst werden müssen:
| Funktion heute | Ersatz unter Linux | Aufwand |
|---|---|---|
| **Serilog-Setup** (`Program.cs:35-148`) — Datei-Sinks nach Level und Plattform | 1:1 übernehmbar, ist bereits portabel. Nur `RichTextBoxSink` entfällt bzw. wird Console-Sink. | trivial |
| **`EmbeddedWebServer.cs`** (365 Z.) — Kestrel- und Worker-Lifecycle | Inhaltlich portabel. Wird zum normalen `IHost` mit `WebApplication` + `AddWorkerServices()`. Vereinfacht sich sogar deutlich, weil Start/Stop-Buttons entfallen. | klein |
| **`AppSettings.cs` + `PropertyGrid`** — Laufzeit-Konfiguration in `settings.json` | `appsettings.json` + Umgebungsvariablen + `IOptions<T>`. Die `[Category]`/`[DisplayName]`-Attribute entfallen ersatzlos. | kleinmittel |
| **Manuelle Aktionen** — „Sync Markets", „DB Update", „Recalculate All Traders" (Bestätigungsdialoge + `MessageBox`) | Entweder CLI-Verben (`--sync-markets`, `--recalc-all`) oder geschützte Admin-Endpunkte in der bestehenden Web-UI. Die Rückfrage-Logik („nicht während laufender Worker") muss erhalten bleiben. | mittel |
| **Log-Terminal** (`RichTextBox`) + DB-Größen-Anzeige + Build-Version | Entfällt / Console + `journalctl`. Die DB-Größenabfrage (`MainForm.cs:317-343`) kann als Health-Endpunkt weiterleben. | klein |
`WatchdogHeartbeatService.cs` (141 Z.) ist reines `HttpClient`-Verhalten und **vollständig
portabel** — sollte nach `Infrastructure` oder in den neuen Host wandern.
**Empfehlung:** WinFormsHost **nicht löschen**, sondern beide Hosts parallel führen.
Er ist die etablierte Betriebsoberfläche unter Windows; beide teilen sich denselben
Composition Root. Der Wartungsaufwand für die dünne Hülle ist gering.
### 3.2 Lizenzierung — der unterschätzte Blocker ⛔
Der `LicenseLabrador.Client` ist erfreulich gut vorbereitet, aber die **Aktivierung** ist es nicht.
**Was bereits funktioniert:**
* `HardwareId.cs:52-84` behandelt Linux explizit: liest `/etc/machine-id` bzw.
`/var/lib/dbus/machine-id`, mit MAC-Adressen-Fallback. Sauber gelöst.
* Zielframework `netstandard2.0`, `Microsoft.Win32.Registry` wird per `IsWindows()`-Guard
nur unter Windows angefasst.
**Was nicht funktioniert:**
1. **`LicenseDialog` ist ein blockierender WinForms-Modaldialog.**
`Program.cs:16``LicenseGuard.EnsureLicensed()``dialog.ShowDialog()`.
Auf einem headless Server gibt es niemanden, der einen Key eintippt. Es braucht einen
**nicht-interaktiven Aktivierungspfad**: Lizenzschlüssel aus Umgebungsvariable oder
Konfigurationsdatei, Aktivierung beim ersten Start, sauberer Abbruch mit Exit-Code und
klarer Logmeldung, wenn keine gültige Lizenz vorliegt.
2. **`StateStore.cs:43,92` — DPAPI-Verschlüsselung fällt unter Linux still auf Klartext zurück.**
```csharp
try { dataToSave = ProtectedData.Protect(plainBytes, null, DataProtectionScope.CurrentUser); }
catch { dataToSave = plainBytes; } // ← Linux landet immer hier
```
Der Lizenz-Cache (inkl. Lizenzschlüssel) liegt dann unverschlüsselt auf der Platte.
Funktional unkritisch, aber eine bewusste Entscheidung — mindestens sollten die
Dateirechte (`0600`) und das Verzeichnis abgesichert werden.
3. **`LicenseConfig.StorageDirectory`** nutzt `Environment.SpecialFolder.ApplicationData`
→ unter Linux `~/.config`. Unter systemd mit `DynamicUser=yes` oder ohne `$HOME`
existiert das nicht. Muss explizit gesetzt werden (z. B. `/var/lib/predictalytics`).
4. **`LicenseGuard.StartPeriodicRevalidation`** verwendet `System.Windows.Forms.Timer`
→ `PeriodicTimer` oder `IHostedService`. `Application.Exit()` → `IHostApplicationLifetime.StopApplication()`.
**Das ist der Punkt mit der größten Restunsicherheit**, weil er das Schwester-Repo
LicenseLabrador berührt und serverseitig geprüft werden muss (Aktivierung ohne UI).
### 3.3 Kestrel bindet nur auf `localhost` ⚠️
```csharp
// EmbeddedWebServer.cs:122
builder.WebHost.UseUrls($"http://localhost:{port}");
```
Das ist für einen Desktop-Host korrekt, für einen Linux-Server aber falsch: von außen
nicht erreichbar. Muss konfigurierbar werden (`0.0.0.0` bzw. `ASPNETCORE_URLS`), inkl.
Anpassung der CORS-`AllowedOrigins` (heute fest `http://localhost:5000`).
**Sicherheitshinweis:** Sobald der Dienst nicht mehr nur auf `localhost` lauscht, ist die
bisher implizite Absicherung weg. `MapPredictalyticsControlEndpoints()` (schreibende
Operationen) und Swagger sind dann exponiert. Es gibt bereits ein `CanControl`/`AuthRequired`-
Konzept (`ApiConfiguration.cs:66`) — das muss vor dem Linux-Deployment scharf geschaltet
werden, plus Reverse Proxy (nginx/Caddy) mit TLS davor.
---
## 4. Weitere Fundstellen (klein, aber notwendig)
### 4.1 `Process.Start("explorer.exe", …)` — 3 Stellen
`MainForm.cs:192,207,209` (Browser öffnen, Log-Ordner öffnen). Betrifft nur den
WinForms-Host; im headless Host entfallen die Aktionen ersatzlos.
### 4.2 `System.Windows.Forms.Timer` — 2 Stellen
`MainForm.cs:71` (DB-Größe), `LicenseGuard.cs:70` (Revalidierung).
→ `PeriodicTimer` / `IHostedService`.
### 4.3 Schriftart `Cascadia Code`
`MainForm.cs:25`. Nur WinForms-relevant, entfällt.
### 4.4 Log-Verzeichnis
`Program.cs:33`: `Path.Combine(AppContext.BaseDirectory, "logs")`.
Technisch portabel, aber unter Linux ist das Installationsverzeichnis üblicherweise nicht
beschreibbar. → konfigurierbar machen, Default `/var/log/predictalytics` bzw.
`journalctl` über den Console-Sink.
Nebenbefund: `src/Predictalytics.WinFormsHost/logs/Predictalytics-20260428.json` ist
offenbar **versehentlich eingecheckt** und enthält Stacktraces mit lokalen Pfaden. Sollte
aus der Versionierung entfernt und `.gitignore` ergänzt werden.
### 4.5 `FindWwwrootPath()` — hartkodierter Absolutpfad ⚠️
```csharp
// EmbeddedWebServer.cs:314-321
var candidates = new[] {
Path.Combine(baseDir, "wwwroot"),
Path.GetFullPath(Path.Combine(baseDir, "..","..","..","..","Predictalytics.Api","wwwroot")),
Path.GetFullPath(Path.Combine(baseDir, "..","..","..","..","..","src","Predictalytics.Api","wwwroot")),
@"j:\Softwareprojekte\Predictalytics\Predictalytics\src\Predictalytics.Api\wwwroot" // ←
};
```
Die Suchheuristik über Build-Output-Verzeichnisse funktioniert im Deployment nicht
zuverlässig, der `j:\`-Fallback gar nicht. Sauber: `wwwroot` als `Content` mit
`CopyToOutputDirectory` in die Host-`.csproj` aufnehmen (in der Api-`.csproj` bereits
vorhanden) und nur noch `Path.Combine(AppContext.BaseDirectory, "wwwroot")` verwenden.
### 4.6 MySQL-Verbindung: `SslMode = None` fest verdrahtet ⚠️
`AppSettings.cs:100`. Bei einem lokal laufenden Windows-Client zu einer entfernten DB war
das schon fragwürdig; bei einem Serverbetrieb sollte SSL konfigurierbar (und
standardmäßig aktiv) sein. Kein Linux-spezifisches Problem, fällt aber bei der
Konfigurationsmigration ohnehin an.
### 4.7 Egress-Kanäle (`SourceIp`) — verhält sich unter Linux anders ⚠️
`EgressPoolService.cs:70-88` bindet einen Socket per `socket.Bind(new IPEndPoint(ip, 0))`
an eine Quell-IP. Das funktioniert unter Linux grundsätzlich, aber:
* Die IP muss auf einem Interface tatsächlich konfiguriert sein (sonst `EADDRNOTAVAIL`);
Windows ist hier toleranter.
* Für nicht-lokale IPs bräuchte es `IP_FREEBIND`/`CAP_NET_ADMIN`.
Falls Egress-Kanäle produktiv genutzt werden, ist das ein expliziter Testpunkt.
Proxy-Kanäle (`WebProxy`) sind unproblematisch.
### 4.8 Zielframework-Inkonsistenz ⚠️
`Predictalytics.Application.Tests` steht auf **`net10.0`**, alle anderen auf `net8.0`.
Das funktioniert (Test-Assembly darf höher liegen), erzwingt aber ein .NET-10-SDK auf dem
Build-Server. Bei einer CI-Einrichtung für Linux vereinheitlichen — entweder alles auf
`net8.0` oder ein bewusster Sprung der gesamten Solution auf `net10.0`.
### 4.9 `Predictalytics.slnx` und Build
Die Solution referenziert den WinFormsHost. Auf Linux schlägt ein `dotnet build` der
Gesamtsolution fehl (`net8.0-windows` + `UseWindowsForms`). Optionen:
* Den WinFormsHost mit `<TargetFrameworks>` bzw. einer Condition auf
`$([MSBuild]::IsOSPlatform('Windows'))` versehen, oder
* eine zweite Solution-Datei für Linux-Builds pflegen.
Zusätzlich: Die `.csproj` des Hosts referenziert das Schwester-Repo über einen
**relativen Pfad mit vier Ebenen nach oben** (`..\..\..\..\LicenseLabrador\…`). Das setzt
eine feste Verzeichnisstruktur voraus und ist für CI/Container fragil — besser über ein
NuGet-Paket oder ein Git-Submodul lösen.
---
## 5. Was zusätzlich neu entsteht (Deployment)
Kein Portierungsproblem im engeren Sinn, aber Teil der Aufgabe „Linux-fähig":
* **systemd-Unit** (`Restart=always`, `User=`, `WorkingDirectory=`, `EnvironmentFile=`)
oder alternativ **Dockerfile** + `docker-compose` (es existiert bereits ein
`J:\Softwareprojekte\dockerCompose`-Verzeichnis — vermutlich anknüpfbar).
* **Reverse Proxy** (nginx/Caddy) mit TLS vor Kestrel.
* **Secrets-Handling**: DB-Passwort und Watchdog-API-Key liegen heute im Klartext in
`settings.json` neben der Exe. Unter Linux → Environment-File mit `0600` oder
Secret-Store.
*Randnotiz:* In `LicenseGuard.cs:16-17` stehen Basic-Auth-Zugangsdaten im Quelltext.
Bei einem Server-Deployment (und erst recht bei der angedachten Monetarisierung) sollte
das überdacht werden — unabhängig von dieser Portierung.
* **EF-Migrationen**: `EnsureDatabaseAsync()` existiert bereits und wird beim Start
aufgerufen — für den Serverbetrieb ist zu entscheiden, ob Migrationen automatisch
laufen sollen oder als separater Schritt.
* **CI**: Build/Test auf Linux, Publish als `linux-x64` (ggf. self-contained).
* `docs/BETRIEB-Watchdog-Lizenz.md` muss um den Linux-Betrieb ergänzt werden.
---
## 6. Aufwandsschätzung
| Arbeitspaket | Aufwand (PT) | Risiko |
|---|---|---|
| **A** Neues Host-Projekt `Predictalytics.Host` (net8.0): Serilog, Kestrel, Worker, Lifecycle | 23 | niedrig — Composition Root existiert bereits |
| **B** Konfigurationsmigration `AppSettings`/`PropertyGrid``appsettings.json` + Env + `IOptions` | 1 | niedrig |
| **C** Manuelle Wartungsaktionen → CLI-Verben bzw. Admin-Endpunkte | 11,5 | niedrig |
| **D** Lizenzierung headless: nicht-interaktive Aktivierung, StorageDirectory, Timer, Cache-Absicherung | 1,53 | **mittelhoch** — berührt LicenseLabrador, serverseitig zu verifizieren |
| **E** Kestrel-Binding + CORS + Absicherung der Control-Endpunkte | 0,51 | mittel (Sicherheitsentscheidung nötig) |
| **F** Kleinkram: `wwwroot`-Deployment, Log-Pfad, Culture-Bug, TFM-Vereinheitlichung, Solution-Split, eingecheckte Logs | 0,51 | niedrig |
| **G** Deployment: systemd/Docker, Reverse Proxy, Secrets, CI | 12 | niedrigmittel |
| **H** Verifikation auf einer echten Linux-Instanz (Worker-Dauerlauf, DB, Egress, Watchdog, Lizenz) | 2 | mittel |
| **Summe** | **~9,514,5 PT** | |
**Realistisch: 23 Wochen bei Teilzeit, ca. 2 Wochen fokussiert.**
Zum Vergleich: Der ursprünglich angenommene Weg über **Avalonia + LiveCharts2** hätte
grob **48 Wochen** gekostet — für eine Oberfläche, die es fachlich bereits als Web-UI
gibt. Diese Arbeit wäre vollständig verloren gewesen.
---
## 7. Empfohlenes Vorgehen
1. **F zuerst** (halber Tag): Culture-Bug, TFM, eingecheckte Logs, `wwwroot`-Deployment.
Wirkt sofort, unabhängig von der Portierung, reduziert Rauschen.
2. **A + B parallel** — das neue Host-Projekt entsteht neben dem WinFormsHost, nicht
an seiner Stelle. Beide teilen sich `ApiConfiguration` und `AddWorkerServices()`.
Ab hier läuft der Stack unter Linux (noch ohne Lizenz-Gate).
3. **E** — Binding und Absicherung, bevor irgendetwas exponiert wird.
4. **D** — Lizenzierung. Am besten früh anfangen zu klären, weil es das Schwester-Repo
und ggf. Serveränderungen betrifft; die Umsetzung kann dann parallel laufen.
5. **C**, **G**, **H** zum Abschluss.
**Zwei Entscheidungen sind vorab zu treffen:**
* **Bleibt der WinFormsHost erhalten?** Empfehlung: **ja**, als Windows-Betriebsoberfläche.
Er ist dünn, funktioniert und beide Hosts teilen sich denselben Kern. Der Web-UI-Zugang
ist ohnehin identisch.
* **Wie wird headless lizenziert?** Lizenzschlüssel per Umgebungsvariable/Config und
Aktivierung beim ersten Start ist der pragmatische Weg — muss aber mit LicenseLabrador
abgestimmt werden.
---
## 8. Zusammenfassung in drei Sätzen
Predictalytics ist in deutlich besserem Zustand für eine Linux-Portierung als erwartet:
96,9 % des Codes sind bereits plattformneutral, es gibt keine Windows-Zeitzonen-IDs und
keine Windows-APIs außerhalb des Hosts, und die Analyse-Oberfläche ist längst eine
Browser-Anwendung mit Chart.js — LiveCharts2 wird nicht gebraucht.
Zwei Defekte an der Grenze zu den Plattform-APIs (13 × lokalzeit-verzerrendes
Datums-Parsing, 1 × kulturabhängiges Zahlen-Parsing) waren allerdings vorhanden und sind
in Phase 0 behoben.
Der eigentliche Aufwand liegt im Ersetzen der 1.698 Zeilen umfassenden WinForms-
Bedienhülle durch einen headless Host und im Umbau der Lizenzaktivierung, die heute
zwingend einen Modaldialog erfordert.
Geschätzt **9,514,5 Personentage**, mit dem einzigen nennenswerten Risiko im
Lizenzierungspfad.
-766
View File
@@ -1,766 +0,0 @@
# Implementierungsplan: Predictalytics auf Windows + Linux
**Stand:** 2026-08-06 · Basis: [ANALYSE-Linux-Portierung.md](ANALYSE-Linux-Portierung.md)
**Ziel:** Ein Code-Stand, der unter Windows und Linux identisch läuft — Bedienhülle in
Avalonia, gesamte Solution auf .NET 10.
---
## 0. Drei Entscheidungen vorab — **alle getroffen (2026-08-06)**
| # | Entscheidung | Ergebnis |
|---|---|---|
| 0.1 | EF Core | **Weg A** — TFM `net10.0`, EF Core `9.0.18` + Pomelo `9.0.0` |
| 0.2 | UI-Framework | **Avalonia 12.1.1**, kein LiveCharts2 |
| 0.3 | Linux-Ziel | **Beides** — Avalonia-GUI *und* Headless-Modus (AP 3.4 verbindlich) |
### 0.1 ✅ EF Core: Version 9 behalten, nicht auf 10 gehen — **entschieden: Weg A**
Das ist der einzige Punkt, an dem „alles auf .NET 10" nicht sauber aufgeht.
**Befund (verifiziert über nuget.org am 2026-08-06):**
```
Pomelo.EntityFrameworkCore.MySql → neueste stabile Version: 9.0.0 (2025-08-17)
nuspec: targetFramework net8.0
Microsoft.EntityFrameworkCore.Relational [9.0.0, 9.0.999] ← harte Obergrenze
```
**Es gibt keinen Pomelo-Provider für EF Core 10.** Die Versionsspanne ist nach oben
geschlossen; ein Upgrade auf EF Core 10 ist mit Pomelo technisch unmöglich.
| Weg | Konsequenz |
|---|---|
| **A — empfohlen: TFM `net10.0`, EF Core `9.0.18`, Pomelo `9.0.0`** | Alle Projekte laufen auf .NET 10. EF-Core-9-Pakete (net8.0) laufen problemlos auf der .NET-10-Runtime. **Kein Provider-Wechsel, kein DB-Risiko.** Nur +1 EF-Major statt +2. |
| **B — EF Core `10.0.10` + `MySql.EntityFrameworkCore 10.0.9` (Oracle)** | Erzwingt den Wechsel des Datenbankproviders. Andere SQL-Generierung, andere Migrations-Historie, abweichendes Verhalten bei Typ-Mapping und Bulk-Operationen — **gegen die Produktiv-DB.** Realistisch +35 PT plus Regressionsrisiko in 94 Infrastructure-Dateien und 30+ Migrationen. |
**→ Gewählt: Weg A.** „Auf .NET 10 gleichbleiben" ist das eigentliche Ziel und wird
vollständig erreicht — das Zielframework aller Projekte ist `net10.0`. Dass die
EF-Core-*Bibliothek* bei 9.0.18 bleibt, ist ein Implementierungsdetail ohne Auswirkung auf
Runtime, Sprachfeatures oder Toolchain. Sobald Pomelo einen EF-Core-10-Provider
veröffentlicht, ist der Nachzug ein Einzeiler.
Weg B entfällt; ein Providerwechsel gegen die Produktiv-DB findet nicht statt.
### 0.2 Avalonia 12.1.1 — entschieden ✅
```
Avalonia 12.1.1 (2026-07-29), lib-TFMs: net8.0, net10.0 ← natives .NET 10
Avalonia.Desktop 12.1.1 · Avalonia.Themes.Fluent 12.1.1 · Avalonia.Controls.DataGrid 12.1.2
```
Eine Einschränkung: **`Avalonia.Diagnostics` liegt noch bei 11.3.18**, es gibt keine
12.x-Version. Das ist das DevTools-Inspektorfenster (F12) — reines Entwicklerwerkzeug, für
die Laufzeit irrelevant. Falls beim Layouten vermisst: temporär eine 11.3.18-Referenz nur
in der Debug-Konfiguration, oder ohne auskommen.
**LiveCharts2 wird nicht benötigt.** Die Bedienhülle enthält kein einziges Diagramm; alle
Charts liegen in der Web-UI (Chart.js). Keine Chart-Bibliothek im Shell-Projekt.
### 0.3 ✅ Wo läuft die Linux-Instanz? — **entschieden: beides**
Eine Avalonia-GUI braucht unter Linux eine Desktop-Umgebung: X11 (bzw. Wayland über XWayland),
dazu `libx11`, `libice`, `libsm`, `libfontconfig1` und installierte Fonts. Auf einem
**headless Server ohne Desktop startet sie nicht.**
**→ Gewählt: beides in einem Binary.** Die Avalonia-Shell für Desktop-Betrieb (Windows und
Linux), zusätzlich ein Schalter `--headless` für Server ohne Desktop-Session.
Damit ist **AP 3.4 verbindlicher Bestandteil**, nicht optional. Nach der
Hosting-Extraktion (Phase 2) kostet er ca. 0,5 PT, weil der gesamte Kern dann bereits
GUI-frei ist. Konsequenzen, die dadurch fix werden:
* Der Lizenzschlüssel muss **auch ohne Dialog** beziehbar sein (Konfiguration /
Umgebungsvariable) — koppelt an die laufende HardwareID-Arbeit.
* Das Deployment liefert zwei Betriebsarten: Desktop-Start und systemd/Container.
* Phase 6 verifiziert beide Modi.
---
## 1. Zielarchitektur
Der entscheidende Umbau ist nicht die GUI, sondern das **Herausziehen eines
plattformneutralen Hosting-Kerns**. Heute liegen ~870 portable Zeilen (EmbeddedWebServer,
Watchdog, Serilog-Setup, Settings) im windows-gebundenen Projekt fest. Danach ist die
GUI nur noch eine dünne Schale darüber — und der Headless-Modus fällt fast von selbst ab.
```
src/
Predictalytics.Domain net10.0 unverändert
Predictalytics.Application net10.0 unverändert
Predictalytics.Infrastructure net10.0 unverändert
Predictalytics.Api net10.0 unverändert
Predictalytics.Worker net10.0 unverändert
Predictalytics.Hosting NEU net10.0 ← plattformneutraler Kern
Predictalytics.Shell NEU net10.0 ← Avalonia-Bedienhülle (Win + Linux)
Predictalytics.Application.Tests net10.0 net10.0 statt bisher net10.0 (bleibt)
Predictalytics.WinFormsHost net10.0-windows ← Übergang, s. 6.2
```
**`Predictalytics.Hosting`** (neu, GUI-frei) nimmt auf:
| Herkunft | Ziel |
|---|---|
| `WinFormsHost/Services/EmbeddedWebServer.cs` (365 Z.) | `Hosting/PredictalyticsHost.cs` — unverändert bis auf Binding + wwwroot |
| `WinFormsHost/AppSettings.cs` (137 Z.) | `Hosting/PredictalyticsOptions.cs` — ohne `[Category]`/`[Editor]`-Attribute |
| `WinFormsHost/Services/WatchdogHeartbeatService.cs` (141 Z.) | 1:1, ist bereits portabel |
| `WinFormsHost/Program.cs:33-148` (Serilog-Setup) | `Hosting/LoggingSetup.cs` — 1:1, ist bereits portabel |
| `WinFormsHost/Services/LicenseGuard.cs` (102 Z.) | `Hosting/LicenseGuard.cs` — ohne WinForms-Timer/Dialog |
| `Infrastructure/Logging/RichTextBoxSink.cs` | umbenennen → `DelegateSink.cs` (ist schon portabel) |
**`Predictalytics.Shell`** (neu, Avalonia) enthält nur noch View + ViewModel.
---
## 2. Phase 0 — Vorarbeiten ✅ **erledigt (2026-08-06)**
| # | Aufgabe | Ergebnis |
|---|---|---|
| 0.1 | `InvariantCulture` ergänzen | ✅ `LimitlessProvider.cs:270` (Volume) |
| 0.1b | **Nachtrag:** 13 × lokalzeit-verzerrendes `DateTime.TryParse` | ✅ neuer Helfer `Infrastructure/Helpers/DateParsing.cs`, alle Fundstellen umgestellt |
| 0.2 | Eingecheckte Logdatei entfernen | ✅ nicht nötig — Datei ist **nicht** getrackt, `.gitignore:14 logs/` greift bereits |
| 0.3 | `j:\`-Absolutpfad-Fallback entfernen | ✅ `EmbeddedWebServer.cs` |
| 0.4 | Branch anlegen | ✅ `feature/linux-net10` |
**Abnahme erreicht:** `dotnet build` → 0 Fehler, 10 Warnungen (alle vorbestehend).
`dotnet test`**100 bestanden, 0 Fehler, 1 übersprungen**.
### Nachtrag 0.1b — Umfang und offener Punkt
`DateTime.TryParse("2026-08-06T12:00:00Z")` ohne `DateTimeStyles` liefert `Kind=Local` und
rechnet in Lokalzeit um. Betroffen waren in beiden Providern `StartDate`, `EndDate`,
`CreatedAt`, `ClosedAt`, `ExecutedAt`, `ExpirationDate` — während der Fallback derselben
Zuweisungen (`DateTime.UtcNow`) korrektes UTC schrieb. Auf der Berliner Maschine also
12 h Versatz, auf einem Linux-Server mit `TZ=UTC` hingegen nicht: derselbe Code hätte
plattformabhängig unterschiedliche Werte erzeugt.
**Altdaten-Backfill: entfällt (Entscheidung 2026-08-06).** Der Fix wirkt nur für neue
Schreibvorgänge; bereits gespeicherte Zeilen tragen den 12-h-Versatz weiter. Bewusst
akzeptiert — die Daten verlieren mit steigendem Alter ohnehin an Relevanz, und die
betroffenen Felder werden bei jedem Markt-Sync überschrieben.
**Konsequenz für Phase 6:** Beim Vergleich Linux-Lauf ↔ Windows-Referenz (Prüfung 8.3)
dürfen historische Zeilen nicht als Referenz dienen — nur frisch geschriebene Datensätze
sind aussagekräftig.
---
## 3. Phase 1 — .NET 10 ✅ **erledigt (2026-08-06)**
**Abnahme erreicht:** `dotnet build` → 0 Fehler, 8 Warnungen (alle vorbestehend, 2 weniger
als vorher). `dotnet test`**100 bestanden, 0 Fehler, 1 übersprungen**, Testhost
`net10.0`. Alle Projekte laufen auf `net10.0`, WinFormsHost auf `net10.0-windows`.
Ergebnis gegenüber der Planung:
| Erwartung | Tatsächlich |
|---|---|
| Swashbuckle 6.9 → 10.x „wahrscheinlichster Reibungspunkt", ggf. Wechsel auf `AddOpenApi()` | **Keine Bruchstelle.** Der genutzte API-Ausschnitt (`AddSwaggerGen`/`SwaggerDoc`/`UseSwagger`/`UseSwaggerUI`) ist stabil geblieben. Kein Umbau nötig. |
| EF Core 8 → 9 „überschaubar" | Bestätigt, keine Anpassung nötig |
| SQLite-Sicherheitswarnung „löst sich mit dem EF-Bump von selbst" | **Falsch** — EF Core 9.0.18 zieht SQLitePCLRaw 2.1.10, weiterhin von GHSA-2m69-gcr7-jv3q betroffen. Behoben über einen expliziten transitiven Pin auf 2.1.12. |
Bewusst **vor** dem Avalonia-Umbau erledigt: eine Baustelle nach der anderen, und das
Shell-Projekt entsteht direkt auf dem Zielframework.
### AP 1.1 — Zentrale Frameworkverwaltung ✅
`Directory.Build.props` setzt `TargetFramework=net10.0` zentral; `<TargetFramework>` aus
allen `.csproj` entfernt (Ausnahme: WinFormsHost → `net10.0-windows`). Zusätzlich zentral:
`SatelliteResourceLanguages=en`, das bisher in vier Projekten einzeln stand.
`Directory.Packages.props` mit `ManagePackageVersionsCentrally` eingeführt — 21 Pakete,
Versionen aus allen `.csproj` entfernt. Zusätzlich
`CentralPackageTransitivePinningEnabled`, um Sicherheits-Pins auf transitive
Abhängigkeiten setzen zu können.
**Nicht angefasst:** `Spike/Spike.csproj` — leeres Restprojekt ohne Quelldateien, nicht in
der Solution, baut auch im Ist-Zustand nicht (`CS5001: keine Main-Methode`). Kandidat zum
Löschen, aber nicht Teil dieser Portierung.
### AP 1.2 — Paketmatrix
| Paket | heute | Ziel | Anmerkung |
|---|---|---|---|
| `Microsoft.EntityFrameworkCore(.Relational/.Design)` | 8.0.11 | **9.0.18** | Obergrenze durch Pomelo |
| `Pomelo.EntityFrameworkCore.MySql` | 8.0.2 | **9.0.0** | zieht MySqlConnector 2.4.0 |
| `Microsoft.EntityFrameworkCore.InMemory/.Sqlite` (Tests) | 8.0.11 | **9.0.18** | muss zu EF Core passen |
| `Swashbuckle.AspNetCore` | 6.9.0 | **10.2.3** | ⚠️ 6.x ist nicht ASP.NET-Core-10-tauglich |
| `Serilog` | 4.2.0 | 4.4.0 | |
| `Serilog.AspNetCore` | 8.0.3 | 10.0.0 | |
| `Serilog.Sinks.File` | 6.0.0 | 7.0.0 | |
| `Serilog.Extensions.Logging` / `.Sinks.Console` / `.Formatting.Compact` | 8.0.0 / 6.0.0 / 3.0.0 | jeweils aktuell | |
| `Microsoft.Extensions.Http` / `.Hosting.Abstractions` / `.Logging.Abstractions` | 8.0.x | 10.0.x | |
| `Microsoft.NET.Test.Sdk` / `xunit` / `Moq` / `coverlet` | aktuell | prüfen | |
Tatsächlich aufgelöste Versionen (verifiziert über `dotnet list package`): EF Core 9.0.18,
Pomelo 9.0.0, Serilog 4.4.0 / Extensions.Logging 10.0.0 / Sinks.File 7.0.0 /
Sinks.Console 6.1.1, Swashbuckle 10.2.3, Test-SDK 18.8.1, coverlet 10.0.1.
Sicherheits-Pin: SQLitePCLRaw 2.1.12 (statt transitiv 2.1.10).
### AP 1.3 — Erwartete Bruchstellen *(Rückblick: keine eingetreten)*
* **Swashbuckle 6.9 → 10.x** ist der wahrscheinlichste Reibungspunkt: geänderte API-Oberfläche,
und ASP.NET Core 10 bringt mit `Microsoft.AspNetCore.OpenApi` eine eigene
OpenAPI-Generierung mit. Betrifft `Api/ApiConfiguration.cs:19-20` und
`WinFormsHost/Services/EmbeddedWebServer.cs:143-144,154-155`.
*Alternative:* auf das eingebaute `AddOpenApi()`/`MapOpenApi()` umsteigen und Swashbuckle
ganz ablösen — sauberer, aber die Swagger-UI müsste ersetzt werden (z. B. Scalar).
* **EF Core 8 → 9:** überschaubar. Zu prüfen sind Migrationsverhalten und die
`ExecuteSqlRawAsync`-Aufrufe in `EmbeddedWebServer.cs:56-100`.
* `Predictalytics.Application.Tests` steht bereits auf `net10.0` — hier wird die
Inkonsistenz gerade aufgelöst statt vergrößert.
**Abnahme:** `dotnet build` + `dotnet test` grün; WinFormsHost startet, Worker laufen,
Web-UI erreichbar; **Migrationen gegen die Dev-DB (`bergisnu_db0`) verifiziert, nicht gegen Prod.**
---
## 4. Phase 2 — Hosting-Kern extrahieren ✅ **erledigt (2026-08-06)**
**Abnahme erreicht:** `dotnet build` → 0 Fehler. `dotnet test` → **100 bestanden, 0 Fehler,
1 übersprungen**. `Predictalytics.Hosting` baut ohne Windows-Bezug; `WinFormsHost` enthält
nur noch UI-Code (`MainForm`, `LicenseDialog`, `TerminalHelper`, `Program`) und referenziert
Api/Worker/Infrastructure/LicenseLabrador nur noch transitiv über Hosting.
### ⚠️ Betrieblicher Hinweis: `settings.json` ging verloren
Beim Aufräumen der Build-Ausgabe in Phase 1 wurde `bin/` gelöscht — und dort lag die zur
Laufzeit erzeugte `settings.json` mit DB-Zugangsdaten und Watchdog-API-Key. Die Datei war
nie versioniert (`.gitignore:11`) und ist nicht wiederherstellbar.
**Beim nächsten Start neu einzutragen:** DB Server/Database/User/Password, Watchdog API Key,
ggf. Egress-Kanäle.
Der Fehler kann sich nicht wiederholen: durch AP 2.3 liegen die Einstellungen jetzt unter
`%APPDATA%\Predictalytics\settings.json` bzw. `~/.config/Predictalytics/settings.json`
außerhalb des Build-Verzeichnisses. `dotnet clean` oder ein Löschen von `bin/` sind damit
folgenlos.
### Abweichungen von der Planung
| Geplant | Tatsächlich |
|---|---|
| Hosting als `Microsoft.NET.Sdk.Web` mit `OutputType=Library` (analog Api) | **`Microsoft.NET.Sdk` + `<FrameworkReference Include="Microsoft.AspNetCore.App" />`.** Der Web-SDK globbt `wwwroot/**` automatisch als Static Web Asset und kollidierte mit dem Content-Eintrag aus AP 2.5 (`DiscoverPrecompressedAssets`: doppelter Schlüssel). Gilt auch für den Host: WinFormsHost ist jetzt ebenfalls normaler SDK. Für die Avalonia-Shell gleich so anlegen. |
| `Process.Start("explorer.exe", …)` erst in AP 3.3 ersetzen | Vorgezogen — `MainForm` wurde ohnehin umgeschrieben. Jetzt `ProcessStartInfo { UseShellExecute = true }`, funktioniert unter Windows und Linux. |
| `LicenseGuard` komplett nach Hosting | Aufgeteilt: der plattformneutrale Teil liegt in `Hosting/LicenseGuard.cs`, der interaktive Dialogaufruf in `WinFormsHost/Services/LicenseGate.cs`. Genau die Naht, an der Phase 3 den Avalonia-Dialog bzw. den Headless-Pfad einhängt. |
| `[Editor]`-Attribut für den mehrzeiligen Egress-Editor erhalten | **Entfallen.** Es verwies auf die .NET-Framework-Assembly `System.Design`, die es in .NET (Core) nicht gibt — der Editor dürfte schon vorher nicht gegriffen haben. Die Avalonia-Settings-View bekommt ein echtes mehrzeiliges Textfeld. Alle übrigen `System.ComponentModel`-Attribute sind plattformneutral und bleiben, das PropertyGrid behält Gruppen und Beschreibungen. |
### Zusätzlich mitgenommen
* `PredictalyticsOptions.WebserverHost` — Bind-Adresse konfigurierbar (Vorbereitung AP 4.1),
Default `localhost`, also verhaltensgleich.
* `PredictalyticsOptions.DbSslMode` — Default bewusst `None`, damit sich das Verbindungs-
verhalten in einem reinen Refactoring nicht ändert. Umstellung auf `Preferred` ist
jetzt eine reine Konfigurationsfrage.
* `PredictalyticsHost.GetDatabaseSizeMbAsync()` — die DB-Abfrage lag vorher direkt im
`MainForm`.
* `RichTextBoxSink``DelegateSink` umbenannt (war nie WinForms-abhängig).
**Reiner Verschiebe- und Entkopplungsvorgang, keine neue Fachlogik.**
### AP 2.1 — Projekt `Predictalytics.Hosting` anlegen
`net10.0`, Referenzen auf `Api`, `Worker`, `Infrastructure`, `LicenseLabrador.Client`.
### AP 2.2 — Verschieben (Tabelle in Abschnitt 1)
`EmbeddedWebServer``PredictalyticsHost`, Watchdog, Serilog-Setup, LicenseGuard.
`RichTextBoxSink``DelegateSink` umbenennen (der Sink ist ein reiner
`Action<string, LogEventLevel>`-Delegat und war nie WinForms-abhängig).
### AP 2.3 — Konfiguration entkoppeln
`AppSettings``PredictalyticsOptions` ohne `System.ComponentModel`-Attribute.
Ladereihenfolge: `appsettings.json``appsettings.{Environment}.json`
Umgebungsvariablen (`PREDICTALYTICS_`) → `settings.json` im Benutzerprofil (Kompatibilität).
Ablageort plattformabhängig über `Environment.SpecialFolder.ApplicationData`
(Windows: `%APPDATA%`, Linux: `~/.config`) statt neben der Executable — das
Installationsverzeichnis ist unter Linux üblicherweise nicht beschreibbar.
⚠️ Beim Umbau ist `SslMode = MySqlSslMode.None` (`AppSettings.cs:100`) fest verdrahtet.
Da die Zugangsdaten künftig auch über Netz zu einer entfernten DB gehen, sollte das
konfigurierbar und standardmäßig aktiv werden.
### AP 2.4 — Lifecycle GUI-frei machen
* `System.Windows.Forms.Timer``PeriodicTimer` bzw. `IHostedService`
(`MainForm.cs:71` DB-Größe, `LicenseGuard.cs:70` Revalidierung)
* `Application.Exit()``IHostApplicationLifetime.StopApplication()`
* `PredictalyticsHost` bekommt Events/Observables (`StatusChanged`, `DbSizeChanged`),
an die sich später sowohl das Avalonia-ViewModel als auch der Headless-Modus hängen.
### AP 2.5 — wwwroot sauber deployen
`FindWwwrootPath()` (`EmbeddedWebServer.cs:308-335`) mit der Verzeichnis-Heuristik ersatzlos
streichen. Stattdessen in den Host-`.csproj`:
```xml
<Content Include="..\Predictalytics.Api\wwwroot\**"
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
```
und im Code nur noch `Path.Combine(AppContext.BaseDirectory, "wwwroot")`.
**Abnahme:** WinFormsHost benutzt nur noch `Predictalytics.Hosting` und enthält
ausschließlich UI-Code. `Predictalytics.Hosting` baut ohne Windows-Referenzen.
---
## 5. Phase 3 — Avalonia-Bedienhülle ✅ **erledigt (2026-08-07)**
**Abnahme:** `dotnet build` → 0 Fehler, 0 Warnungen. `dotnet test` → 100 bestanden.
Beide Betriebsarten laufen an:
| Prüfung | Ergebnis |
|---|---|
| `Predictalytics.Shell.exe --headless` | Logging läuft, Lizenzprüfung greift, sauberer Abbruch mit **Exit-Code 2** und handlungsfähiger Meldung |
| `Predictalytics.Shell.exe` (GUI) | startet, Lizenzfenster erscheint, kein Absturz |
| Konsolen-Kodierung | UTF-8 explizit gesetzt — ohne das zeigt die Windows-Konsole nur Fragezeichen statt Rahmen und Emoji |
### Nachtrag 2026-08-07: Hauptfenster verifiziert ✅
Zunächst blockierte eine abgelaufene Lizenz die Prüfung. Nach der Umstellung auf das
Deploymentcenter (Abschnitt 5a) startet die GUI vollständig durch — belegt durch die
Logdateien des Laufs:
```
[INF] Shell initialisiert. Bereit.
[INF] 'Start Webserver' startet die WebUI auf http://localhost:5000
[INF] Einstellungen: C:\Users\bergm\AppData\Roaming\Predictalytics\settings.json
```
Damit sind Lizenzschranke, Serilog-Aufbau mit Terminal-Sink, `MainWindowViewModel.Start()`
und der neue Einstellungspfad nachgewiesen. **Noch nicht durchgeklickt**: die einzelnen
Wartungsaktionen und die Einstellungsansicht — dafür fehlen die in Phase 1 verlorenen
DB-Zugangsdaten.
### Umsetzung
| Datei | Zweck |
|---|---|
| `Program.cs` | Einstiegspunkt. Argumentauswertung **vor** jeder Avalonia-Initialisierung — sonst stirbt der Prozess auf einem Server ohne X11, bevor `--headless` greift. |
| `App.axaml(.cs)` | Lizenzschranke, dann Hauptfenster. `ShutdownMode.OnExplicitShutdown` während der Aktivierung, danach `OnMainWindowClose`. |
| `ViewModels/MainWindowViewModel.cs` | Bindungsziel, `CommunityToolkit.Mvvm`-Quellgeneratoren, kapselt `PredictalyticsHost` |
| `ViewModels/LogLine.cs` | Terminalzeile mit Einfärbung nach Loglevel (Farbschema aus WinForms übernommen) |
| `Views/MainWindow.axaml` | Menü, Werkzeugleiste, Terminal-Tab, Einstellungen-Tab, Statusleiste |
| `Views/LicenseWindow.axaml` | Aktivierungsfenster, ersetzt `LicenseDialog` |
| `Views/Dialogs.cs` | Ersatz für `MessageBox.Show` — im Code aufgebaut, kein Drittanbieterpaket |
| `Services/HeadlessRunner.cs` | AP 3.4: Betrieb ohne Oberfläche |
| `Services/ConsoleAttach.cs` | Windows-Konsole anhängen + UTF-8 |
### Abweichungen und Entscheidungen
* **`PropertyGrid`-Ersatz handgeschrieben**, wie empfohlen. Vier Gruppen als
`HeaderedContentControl`, Passwortfelder mit `PasswordChar`, **echtes mehrzeiliges
Textfeld** für die Egress-Kanäle (besser als das in Phase 2 entfallene `[Editor]`-Attribut).
SSL-Modus als `ComboBox`; bewusst nur `None`/`Preferred`/`Required``VerifyCA`/`VerifyFull`
bräuchten hinterlegte Zertifikate und würden hier nur zu Fehlkonfiguration einladen.
* **Speichern ist jetzt explizit** („Speichern & übernehmen") statt wie beim `PropertyGrid`
bei jeder Einzeländerung. Klarer, und erspart `INotifyPropertyChanged` auf `PredictalyticsOptions`.
* **Feste Fenstergröße aufgelöst** (vorher `1886×1088` fix): jetzt `MinWidth`/`MinHeight`
mit skalierendem `DockPanel`/`Grid`.
* **Schrift-Fallbackkette** `Cascadia Code, DejaVu Sans Mono, Liberation Mono, Consolas, monospace`
fürs Terminal; `Avalonia.Fonts.Inter` als mitgelieferte UI-Schrift, weil Linux-Distributionen
sehr unterschiedliche Standardfonts haben.
* **Ringpuffer statt Leeren**: die WinForms-Fassung verwarf bei 500 Zeilen das gesamte
Terminal (`rtb.Clear()`), jetzt fällt jeweils nur die älteste Zeile heraus.
* **`Avalonia.Diagnostics` nicht referenziert** — gibt es nur bis 11.3.18, nicht für 12.x.
* **`global::Avalonia.Application`**: innerhalb von `Predictalytics.*` löst der kurze Name
`Application` auf den eigenen Namespace `Predictalytics.Application` auf. Betrifft jede
künftige Avalonia-Klasse in diesem Projekt.
* **`Microsoft.NET.Sdk` statt `Sdk.Web`**, wie in Phase 2 gelernt (Static-Web-Assets-Kollision
mit dem `wwwroot`-Content-Eintrag).
### Headless-Betrieb
Lizenz über `PREDICTALYTICS_LICENSE_KEY`; ohne nutzbare Lizenz Abbruch mit Exit-Code 2
statt eines Dialogs, den niemand sieht. `SIGTERM` und `SIGINT` werden über
`PosixSignalRegistration` abgefangen, damit systemd sauber stoppen kann.
Exit-Codes: `0` planmäßig beendet · `2` keine nutzbare Lizenz · `3` Abbruch mit Fehler.
### AP 3.1 — Projekt `Predictalytics.Shell`
```xml
<PackageReference Include="Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
```
MVVM mit `CommunityToolkit.Mvvm` (Source-Generatoren) — bei ~15 Settings-Properties plus
Status-Bindings spart das spürbar Boilerplate gegenüber handgeschriebenem `INotifyPropertyChanged`.
### AP 3.2 — Control-Mapping
| WinForms (heute) | Avalonia | Aufwand |
|---|---|---|
| `MenuStrip` + 3 Menüs | `Menu` / `NativeMenuBar` | klein |
| `ToolStrip` + 2 Buttons | `StackPanel` + `Button` | klein |
| `TabControl` / `TabPage` | `TabControl` / `TabItem` | klein |
| `StatusStrip` + 3 Labels | `DockPanel` unten + `TextBlock` | klein |
| `MessageBox.Show` (7 Aufrufe) | eigener Dialog oder `MsBox.Avalonia` | klein |
| **`RichTextBox` (farbiges Log)** | `ListBox`/`ItemsControl` über `ObservableCollection<LogLine>`, `DataTemplate` mit `Foreground`-Binding | **mittel** |
| **`PropertyGrid`** | **kein Äquivalent in Avalonia Core** | **mittelgroß** |
**Zum Log-View:** die bisherige Ringpuffer-Logik (`TerminalHelper.cs:26-31` — bei 500 Zeilen
`rtb.Clear()`) wird zur `ObservableCollection` mit `RemoveAt(0)` — angenehmer, weil die
Historie nicht mehr komplett verworfen wird. Marshalling über `Dispatcher.UIThread.Post`
statt `Control.BeginInvoke`. Farbschema aus `TerminalHelper.cs:33-39` direkt übernehmbar.
**Zum PropertyGrid** — das ist der einzige echte UI-Entwurfsaufwand. Drei Optionen:
| Option | Bewertung |
|---|---|
| **Handgeschriebene Settings-View** (empfohlen) | 4 Gruppen / 15 Properties (Webserver 2, Egress 1, Watchdog 6, Database 4 + abgeleiteter ConnectionString). Als `Expander` je Gruppe mit `TextBox`/`CheckBox`/`NumericUpDown`. Volle Kontrolle über Passwortfelder (`PasswordChar`) und Mehrzeilen-Editor für Egress. ~11,5 PT. |
| Drittanbieter `Avalonia.PropertyGrid` | Spart Zeit, bringt aber eine Abhängigkeit fragwürdiger Pflegelage und passt selten zum Fluent-Theme. |
| Reflection-basierter Generator | Elegant, aber Overengineering für 15 Properties. |
### AP 3.3 — Plattformdetails
* **Fixe Fenstergröße auflösen:** `MinimumSize`/`MaximumSize = 1886×1088`
(`MainForm.Designer.cs:233-234`) ist bei abweichender DPI/Auflösung unter Linux
untauglich. → skalierendes Grid-Layout, sinnvolles `MinWidth`/`MinHeight`.
* **Schriftart:** `"Cascadia Code"` (`MainForm.cs:25`) existiert unter Linux meist nicht.
→ Fallback-Kette `Cascadia Code, DejaVu Sans Mono, monospace`.
* **`Process.Start("explorer.exe", …)`** (3×, `MainForm.cs:192,207,209`) →
```csharp
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
```
funktioniert auf Windows und Linux (dort über `xdg-open`).
* **Lizenzdialog** (`LicenseDialog.cs`, 118 Z.) in Avalonia nachbauen: Label, `TextBox`
(Großschreibung — `CharacterCasing.Upper` hat kein direktes Pendant, per
`TextInput`-Handler oder Converter lösen), Status-Label, zwei Buttons. Der
Aktivierungsablauf (`ValidateAsync` + `VerifyChecksum`) bleibt unverändert.
*Hinweis: greift auf die HardwareID-Arbeit zu, die separat läuft.*
### AP 3.4 — Headless-Modus *(verbindlich, 0,5 PT — Entscheidung 0.3)*
Schalter `--headless` in `Program.cs` der Shell: statt `BuildAvaloniaApp().StartWithClassicDesktopLifetime()`
direkt `PredictalyticsHost` starten und auf `IHostApplicationLifetime` warten, Logs nur auf
Console. Nach Phase 2 ist das reine Verdrahtung, weil der Kern bereits GUI-frei ist.
Zwingend mitzudenken:
* **Lizenz ohne Dialog:** Schlüssel aus `PREDICTALYTICS_LICENSE_KEY` bzw. Konfiguration;
bei fehlender/ungültiger Lizenz sauberer Abbruch mit Exit-Code ≠ 0 und klarer Logzeile
statt eines Dialogs, den niemand sieht.
* **Keine Avalonia-Initialisierung** vor der Argumentauswertung — sonst versucht der
Prozess auf einem Server ohne X11 zu starten und stirbt, bevor der Schalter greift.
* SIGTERM sauber behandeln (`IHostApplicationLifetime`), damit systemd korrekt stoppt.
**Abnahme:** Shell startet unter Windows **und** Linux-Desktop mit GUI; derselbe Build
startet mit `--headless` auf einem Server ohne X11. In beiden Modi: Worker laufen, Web-UI
erreichbar, Settings persistieren; im GUI-Modus zusätzlich farbiger Log-View und
Lizenzdialog.
---
## 5b. WinForms-Host abgelöst ✅ (2026-08-08)
`main` brachte eine vollständige Deployment-Center-Integration mit (Lizenz, Heartbeat mit
DB-Health-Check, Fehler-Stream, UpdateService), die im WinForms-Host entstanden war. Diese
wurde nach `feature/linux-net10` gemerged und dabei in den plattformneutralen Kern gezogen.
**`Predictalytics.WinFormsHost` ist entfernt** — die Avalonia-Shell ist ab jetzt die einzige
Bedienoberfläche.
### Was nach `Predictalytics.Hosting` wanderte
| Datei | Anmerkung |
|---|---|
| `DcConfig`, `DcApiClient`, `DcErrorSink`, `DcHeartbeatService`, `DcUpdateService` | unverändert, waren bereits plattformneutral |
| `DcErrorReporter` | `Application.ThreadException` + `MessageBox` entfernt; stattdessen `ReportUiThreadException(Exception)`, das der Host aufruft. Avalonia hängt sich über `Dispatcher.UIThread.UnhandledException` ein. |
| `LicenseGuard` + `LicenseSession` | Dialogaufruf und `System.Windows.Forms.Timer` entfernt. Neu: `TryUseCachedAsync()`, `ActivateAsync()` und `StartPeriodicRevalidation(session, onUnusable)` über `PeriodicTimer`. Die Unterscheidung transienter Fehler und die Gnadenfrist-Warnung sind unverändert übernommen. |
| Dc-Einstellungen | aus `AppSettings` nach `PredictalyticsOptions`; die Watchdog-Einstellungen entfielen |
| `DcErrorSink` in Serilog | in `LoggingSetup` aufgenommen, Startbanner nutzt jetzt `DcConfig.AppVersion` |
`Deploymentcenter.BuildInfo.targets` wird nun von `Predictalytics.Hosting` importiert;
`BuildInfo` entsteht damit im Namensraum `Predictalytics.Hosting`.
### Was in der Avalonia-Shell nachgezogen wurde
* Menü **Deployment Center** mit „Nach Updates suchen" und „Lizenzstatus anzeigen"
* Einstellungsgruppe **Watchdog → Deployment Center** (Token, Heartbeat, Fehler-Reporting,
Update-Kanal als ComboBox; die Server-URL wird nur angezeigt, nicht editiert)
* Heartbeat-Snapshot samt `SELECT 1`-DB-Probe, identisch zur WinForms-Fassung
* Update-Prüfung beim Start (still) und über das Menü (interaktiv), inklusive
`NotifyStopping()` vor dem Start des Update-Agenten
* Lizenzfenster nutzt `IsTransient`: bei fehlender Serververbindung wird nicht behauptet,
die Lizenz sei ungültig
### Verifiziert
```
🔑 Lizenz geprüft: valid (HWID 2:win:a765bd47…, Quelle machine-guid)
Shell initialisiert. Bereit.
🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token
Update-Prüfung: v1.0.0 ist aktuell (Kanal prod)
```
`--license-status` meldet gültig samt Offline-Gnadenfrist. Build 0 Fehler, 100 Tests grün.
**Noch offen:** Heartbeat und Fehler-Stream laufen erst mit gesetztem DC-Token; die
Wartungsaktionen brauchen DB-Zugangsdaten.
---
## 5a. Zusatz — Umstellung auf das Deploymentcenter-Lizenzsystem ✅ (2026-08-07)
> **Überholt durch 5b.** Der hier beschriebene eigene `LicenseGuard` gegen
> `Deploymentcenter.Client` war ein Provisorium und wurde durch die reifere Fassung aus
> `main` ersetzt (`LicenseSession`, `IsTransient`, `TryGetCachedKey`, Gnadenfrist-Warnung).
> Die Begründungen zur Hardware-ID v2 und zum AES-GCM-Cache gelten unverändert.
**Nicht ursprünglich geplant.** Beim Aktivieren der neuen Lizenz stellte sich heraus, dass
Predictalytics noch am alten Lizenzsystem hing (LicenseLabrador, `license.mhdf.de`),
während die Lizenzen inzwischen im Deploymentcenter (`dc.mhdf.de`) gepflegt werden.
Vor dem Umbau mit einem Wegwerf-Probelauf gegen den echten Client abgesichert:
Slug `predictalytics`, HWID v2 aus `machine-guid`, Server meldet `valid`.
### Was sich geändert hat
| | vorher (LicenseLabrador) | jetzt (Deploymentcenter) |
|---|---|---|
| Endpunkt | `license.mhdf.de/public/api/v1` | `dc.mhdf.de/api/license/v1` |
| Client | `LicenseLabrador.Client` (netstandard2.0) | `Deploymentcenter.Client` (netstandard2.0;net8.0) |
| Authentifizierung | Ed25519-Signaturprüfung + Basic Auth im Quelltext | serverseitig, kein Geheimnis im Client |
| Hardware-ID | SHA-256 aus MachineGuid **+ Rechnername** | **v2**: `2:<plattform>:<hex>`, ohne Rechnername |
| Linux-HWID | `/etc/machine-id`, sonst MAC | `/etc/machine-id` → dbus → DMI-UUID → MAC → Schlüsseldatei |
| Container | nicht vorgesehen | `DEPLOYMENTCENTER_HWID` überschreibt |
| Cache | DPAPI, **stiller Klartext-Rückfall unter Linux** | AES-256-GCM, HKDF aus HWID+Slug, **kein Klartext-Rückfall**, `0600` unter Linux |
| Ablageort | `%APPDATA%` | zusätzlich `XDG_CONFIG_HOME` unter Linux |
**Damit erledigt sich Analyse-Punkt 3.2 Nr. 2 von selbst:** der stille Klartext-Rückfall
des Lizenz-Caches unter Linux existiert im neuen Client nicht mehr. Der Rechnername steckt
außerdem nicht mehr im Hash — Umbenennen verbraucht keinen Aktivierungsplatz.
### Neue CLI-Schalter (`Services/LicenseCli.cs`)
Aus dem Deploymentcenter-Integrationsleitfaden übernommen, für Server ohne Oberfläche:
```bash
Predictalytics.Shell --license-status # HWID, Cache-Ablage, Serverprüfung
Predictalytics.Shell --license-set-key <SCHLÜSSEL> # einmalig aktivieren
Predictalytics.Shell --license-deactivate # Aktivierungsplatz freigeben
```
Die Schalter werden **vor** jeder Avalonia-Initialisierung ausgewertet, wie `--headless`.
Der Schlüssel wird in der Ausgabe maskiert (`LLAB3-*****-*****-*****-AABB2`).
### ✅ Nachgetragen: Watchdog ist migriert (Stand 2026-08-23)
Der hier notierte Rest — `WatchdogHeartbeatService` gegen `watchdog.mhdf.de` mit dem
Header `X-Watchdog-Key` — ist erledigt. An seiner Stelle steht
`Hosting/DcHeartbeatService.cs`, das gegen `POST /api/watchdog/v1/ping` am
Deployment Center meldet (`:105,143`). Damit laufen Lizenz, Watchdog, UpdateService,
Fehler-Stream und Bugtracker über dieselbe Anbindung; `watchdog.mhdf.de` wird
nicht mehr angesprochen.
---
## 6. Phase 4 — Aufräumen und Absichern ✅ **erledigt (2026-08-08)**
**Abnahme:** Build 0 Fehler, 8 Warnungen (alle vorbestehend).
`dotnet test` → **124 bestanden** (vorher 100; 24 neue Tests zur Absicherung).
### AP 4.1 — Steuerendpunkte werden jetzt tatsächlich geschützt ✅
Der Befund war schlimmer als in der Analyse notiert: `/api/capabilities` **meldete**
`CanControl`/`AuthRequired` aus der Konfiguration, **erzwungen wurde nichts**.
`MapPredictalyticsControlEndpoints()` mappte Jobs, Trader-Steuerung und die Dev-Endpunkte
bedingungslos und ungeschützt.
Neu:
| Bindung | Token | Verhalten |
|---|---|---|
| localhost | — | Steuerendpunkte aktiv, ungeschützt (das Betriebssystem schützt) |
| localhost | gesetzt | Steuerendpunkte aktiv, Token wird verlangt |
| **extern** | **fehlt** | **Steuerendpunkte werden gar nicht gemappt**, dazu ein `Log.Error` |
| extern | gesetzt | Steuerendpunkte verlangen `X-Predictalytics-Key` |
Bewusst fail-safe: eine unbedachte Umstellung der Bind-Adresse öffnet nichts stillschweigend.
`ApiTokenFilter` vergleicht laufzeitkonstant (`CryptographicOperations.FixedTimeEquals`),
damit die Antwortzeit nicht verrät, wie viele Zeichen stimmen.
Swagger ist bei externer Bindung abgeschaltet — es legt die gesamte API-Oberfläche offen.
`/api/capabilities` meldet nun den tatsächlichen Zustand statt einer Konfigurationsabsicht.
### AP 4.2 — Globalisierung ✅
`RuntimeSetup.UseInvariantCulture()`, aufgerufen als Erstes in beiden Startpfaden.
Bewusst **nicht** über `InvariantGlobalization` im Projekt: das änderte zusätzlich die
Semantik von Zeichenkettenvergleichen und wäre ein größerer Eingriff als nötig.
### AP 4.3 — Log-Verzeichnis ✅
Standard ist jetzt `%APPDATA%\Predictalytics\logs` bzw. `~/.config/Predictalytics/logs` —
neben den Einstellungen statt neben der Programmdatei, die unter Linux üblicherweise nicht
beschreibbar ist und beim Update überschrieben würde. Übersteuerbar über
`PREDICTALYTICS_LOG_DIR`, etwa auf `/var/log/predictalytics` im systemd-Betrieb.
### AP 4.4 — Solution und Repo-Kopplung ✅
Der WinFormsHost-Sonderfall entfällt, weil das Projekt entfernt ist — die Solution baut
unter Linux ohne Bedingung durch. Die relative Referenz auf das Schwester-Repo bleibt
(vier Ebenen aufwärts), scheitert jetzt aber mit einer verständlichen Meldung samt
Klon-URL statt mit einem Fehler über eine fehlende Projektdatei.
**Offen:** Git-Submodul oder NuGet-Paket statt Pfadreferenz — für CI und Container relevant,
aber eine Entscheidung über Repo-Grenzen hinweg.
### AP 4.5 — Egress `SourceIp` ✅ (dokumentiert)
Code ist korrekt; der Unterschied ist zur Laufzeit. Linux verlangt, dass die Adresse
tatsächlich auf einem Interface konfiguriert ist (sonst `EADDRNOTAVAIL`), Windows ist
toleranter. Als Kommentar an der Bind-Stelle hinterlegt. Praktischer Test steht in Phase 6 aus.
### Neue Tests
* `PredictalyticsOptionsTests` — `IsPubliclyBound` für Loopback-Schreibweisen, Leerwerte
und externe Adressen; `WebserverUrl`; SSL-Modus im Connection-String; und dass leere
Eingaben bestehende DB-Zugangsdaten **nicht** überschreiben.
* `ApiTokenFilterTests` — richtiger Token passiert, fehlender/falscher/zu kurzer/zu langer
sowie abweichend geschriebener Token werden abgewiesen.
### ✅ Ende-zu-Ende verifiziert (2026-08-08, gegen die echte Datenbank)
Nachgeholt, sobald die DB-Zugangsdaten wieder vorlagen. Geprüft wurde mit einem
Wegwerf-Host, der **ausschließlich den Webserver** startet — die Worker blieben aus, weil
parallel eine ältere Instanz auf derselben Datenbank läuft. Zusätzlich mit
`ApiSettings__ReadOnlyDatabase=true`, sodass weder Migrationen noch Seeding liefen
(im Log bestätigt).
Als „externe" Bindung diente `127.0.0.2`: die Adresse gilt der Prüfung als öffentlich,
bleibt aber Loopback — der Test öffnet also nichts im Netz.
| Fall | `/api/capabilities` | `/api/jobs` | Swagger |
|---|---|---|---|
| localhost, kein Token | `canControl:true, authRequired:false` | 200 | 200 |
| **extern, kein Token** | `canControl:false` | **404** | **404** |
| extern, mit Token | `canControl:true, authRequired:true, tokenHeader:"X-Predictalytics-Key"` | ohne Header **401**, falscher Token **401**, richtiger **200** | **404** |
Damit ist belegt: eine externe Bindung ohne Token stellt die Steuerendpunkte gar nicht
erst bereit, und Swagger ist in beiden externen Fällen abgeschaltet.
**Gleichzeitig verifiziert** (Vorgriff auf Phase 6):
* Datenbankverbindung steht — 37.803 MB, `SELECT`-Pfad über `GetDatabaseSizeMbAsync`
* Web-UI und Assets werden ausgeliefert: `/`, `/css/style.css`, `/js/app.js` je 200
* `/api/dashboard` liefert echte Daten: 151.094 Trader, 38.187.946 Trades
---
## 6a. Phase 4 — ursprüngliche Planung
### AP 4.1 — Kestrel-Binding und Absicherung ⚠️
`UseUrls($"http://localhost:{port}")` (`EmbeddedWebServer.cs:122`) konfigurierbar machen
(`ASPNETCORE_URLS` bzw. Options), CORS-`AllowedOrigins` (heute fest `http://localhost:5000`)
mitziehen.
**Sobald nicht mehr nur auf `localhost` gelauscht wird, entfällt die bisher implizite
Absicherung.** `MapPredictalyticsControlEndpoints()` (schreibende Operationen) und Swagger
wären dann offen erreichbar. Das vorhandene `CanControl`/`AuthRequired`-Konzept
(`Api/ApiConfiguration.cs:66`) muss davor scharf geschaltet werden.
### AP 4.2 — Globalisierung
Im Host-Bootstrap `CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture`
setzen, damit Zahlenformatierung unabhängig von der Systemlocale ist.
`InvariantGlobalization=true` (spart `libicu` im Container) erst später erwägen — es ändert
String-Vergleichssemantik und sollte nicht im selben Sprung passieren.
### AP 4.3 — Log-Verzeichnis
`Path.Combine(AppContext.BaseDirectory, "logs")` (`Program.cs:33`) konfigurierbar machen.
Default: Windows `%APPDATA%`, Linux `~/.local/share` bzw. `/var/log/predictalytics`.
### AP 4.4 — Solution und Repo-Kopplung
* `Predictalytics.slnx`: WinFormsHost mit
`Condition="$([MSBuild]::IsOSPlatform('Windows'))"` versehen, damit
`dotnet build` der Gesamtsolution unter Linux durchläuft.
* Die `..\..\..\..\LicenseLabrador\…`-Projektreferenz (4 Ebenen aufwärts) ist für CI und
Container fragil → Git-Submodul oder NuGet-Paket.
### AP 4.5 — Egress-Kanäle prüfen ⚠️
`EgressPoolService.cs:70-88` bindet Sockets an eine Quell-IP. Unter Linux muss die IP
tatsächlich auf einem Interface konfiguriert sein (sonst `EADDRNOTAVAIL`); Windows ist
toleranter. Für nicht-lokale IPs bräuchte es `IP_FREEBIND`/`CAP_NET_ADMIN`.
Nur relevant, falls Egress-Kanäle produktiv genutzt werden. Proxy-Kanäle sind unkritisch.
---
## 7. Phase 5 — Deployment und CI (12 PT)
* `dotnet publish -r linux-x64` (und `win-x64`); bei self-contained an
`--self-contained` + Trimming-Verhalten von Avalonia denken.
* **Systemabhängigkeiten Linux (GUI):** `libx11-6`, `libice6`, `libsm6`, `libfontconfig1`,
`libharfbuzz0b`, Monospace-Fonts (z. B. `fonts-dejavu`). **(GUI-Variante braucht eine
Desktop-Session — siehe 0.3.)**
* systemd-Unit bzw. Dockerfile (für den Headless-Modus); es existiert bereits ein
`J:\Softwareprojekte\dockerCompose` — vermutlich anknüpfbar.
* Reverse Proxy (nginx/Caddy) mit TLS vor Kestrel.
* **Secrets:** DB-Passwort und Deployment-Center-Token liegen im Klartext in
`settings.json` (nicht versioniert). Unter Linux → EnvironmentFile mit `0600` oder
Secret-Store.
*Erledigt:* Die früher hier genannten Basic-Auth-Zugangsdaten in `LicenseGuard.cs`
gibt es nicht mehr — die Datei läuft vollständig über den `Deploymentcenter.Client`.
*Offen und dringlicher:* In `src/Predictalytics.Api/appsettings.json` steht ein
**echter OpenRouter-API-Key im Klartext**, und diese Datei **ist** versioniert.
Schlüssel zurückziehen, neu ausstellen, künftig über `OpenRouter__ApiKey` setzen.
* CI: Build + Test auf Linux **und** Windows.
* `docs/BETRIEB-Deploymentcenter.md` um den Linux-Betrieb ergänzen.
---
## 8. Phase 6 — Verifikation (2 PT)
| # | Prüfung |
|---|---|
| 8.1 | Worker-Dauerlauf ≥ 24 h unter Linux gegen die **Dev-DB**, Logvergleich mit einem Windows-Lauf |
| 8.2 | EF-Migrationen von Null gegen Dev-DB (Weg A: nur EF-9-Regression) |
| 8.3 | Zahlenformatierung: Lauf unter `LANG=de_DE.UTF-8` — Volumina/Preise/PnL gegen Windows-Referenz |
| 8.4 | Web-UI vollständig durchklicken (case-sensitive Assets, alle Endpunkte) |
| 8.5 | Lizenz: Erstaktivierung, Neustart aus Cache, Offline-Gnadenfrist, Revalidierung |
| 8.6 | Watchdog-Heartbeats erscheinen unter `watchdog.mhdf.de` |
| 8.7 | Egress-Kanäle (falls genutzt) |
| 8.8 | Shell unter Windows **und** Linux-Desktop: alle Buttons, Menüs, Settings-Persistenz |
| 8.9 | Derselbe Build mit `--headless` auf einem Server ohne X11: Start, Lizenz aus Konfiguration, SIGTERM-Stopp |
---
## 9. Zeitplan
| Phase | Inhalt | PT |
|---|---|---|
| 0 | Vorarbeiten | 0,5 |
| 1 | .NET 10 + Paketmatrix | 1,52,5 |
| 2 | Hosting-Kern extrahieren | 1,52 |
| 3 | Avalonia-Shell **inkl. Headless-Modus (AP 3.4)** | 4,56,5 |
| 4 | Aufräumen, Binding, Absicherung | 11,5 |
| 5 | Deployment + CI (Desktop **und** systemd/Container) | 1,52,5 |
| 6 | Verifikation (beide Betriebsarten) | 2 |
| | **Summe** | **12,517,5 PT** |
**≈ 2,53,5 Wochen fokussiert.**
Phasen 02 sind risikoarm und liefern bereits Wert (aufgeräumte Architektur, aktuelles
Framework). Phase 3 ist der Hauptaufwand. Die Phasen sind strikt sequenziell — jede baut
auf der vorigen auf; einzig AP 5 (Deployment) kann parallel zu Phase 3 vorbereitet werden.
---
## 10. Risiken
| Risiko | Eintritt | Wirkung | Gegenmaßnahme |
|---|---|---|---|
| **Pomelo hat keinen EF-Core-10-Provider** | **sicher** | blockiert EF Core 10 | ✅ gelöst durch Weg A: EF Core 9 auf net10.0 |
| Swashbuckle 6.9 → 10.x mit Brüchen | wahrscheinlich | halber bis ganzer Tag | ggf. auf eingebautes `AddOpenApi()` wechseln |
| PropertyGrid-Ersatz wird aufwendiger als geschätzt | mittel | +1 PT | handgeschriebene View, 15 Properties sind überschaubar |
| Avalonia-GUI läuft auf dem Linux-Ziel nicht (kein Desktop) | sicher auf Servern | Shell unbrauchbar | ✅ abgedeckt durch AP 3.4 (verbindlich) |
| `Avalonia.Diagnostics` fehlt für 12.x | sicher | nur Komfort | ohne DevTools arbeiten oder 11.3.18 in Debug |
| **Lizenzaktivierung headless** | **sicher (AP 3.4)** | mittel | koppelt an laufende HardwareID-Arbeit — früh abstimmen |
| Egress `SourceIp` unter Linux | niedrig | mittel | AP 4.5, früh testen |
| Regression in der Datenschicht | niedrig (Weg A) | hoch | ausschließlich Dev-DB, nie Prod |
---
## 11. Nächster Schritt
**Stand 2026-08-23: Phase 0 bis 4 sind abgeschlossen** (jeweils oben mit ✅ vermerkt),
ebenso der Zusatz 5a (Deploymentcenter-Lizenzsystem) und 5b (WinForms-Host abgelöst).
Der ursprüngliche Text an dieser Stelle empfahl noch, mit Phase 0 zu beginnen — das ist
überholt und deshalb ersetzt.
**Offen sind Phase 5 und Phase 6:**
1. **Phase 5 — Deployment und CI** (Abschnitt 7). Nichts davon ist bisher angefasst: kein
`dotnet publish -r linux-x64`, keine systemd-Unit, kein Dockerfile, kein Reverse Proxy,
keine CI. Das ist der eigentliche verbleibende Arbeitsblock.
2. **Phase 6 — Verifikation** (Abschnitt 8, Punkte 8.18.9). Der Dauerlauf unter Linux
(8.1) und die Prüfung unter `LANG=de_DE.UTF-8` (8.3) stehen aus. **Bisher wurde die
Anwendung noch auf keinem Linux-System ausgeführt** — die Portierung ist zwar
vollständig gebaut, aber unter Linux unbewiesen.
3. Unabhängig davon und vorgelagert: den **OpenRouter-API-Key** aus
`src/Predictalytics.Api/appsettings.json` zurückziehen und neu ausstellen (siehe
Abschnitt 7, Secrets).
-25
View File
@@ -1,25 +0,0 @@
# Archiv — abgelöste Plandokumente
Diese Dokumente wurden am **2026-08-23** hierher verschoben. Sie werden **nicht mehr
gepflegt**. Was von ihnen noch offen war, steht gebündelt in [`ROADMAP.md`](../../ROADMAP.md);
der Ist-Stand des Projekts in [`STATUS.md`](../../STATUS.md).
**Wozu sie trotzdem da sind:** Die Roadmap nennt jeden offenen Punkt knapp. Die Herleitung —
warum etwas so entschieden wurde, welche Messungen dahinterstehen, welche Alternativen
verworfen wurden — steht hier. Wer einen Roadmap-Punkt umsetzt, findet hier den Kontext.
**Vorsicht bei der Lektüre:** Der Text unterhalb des jeweiligen Statusblocks beschreibt den
Stand seiner Entstehungszeit. Test-Baselines, Dateipfade und „so ist es heute"-Aussagen sind
teils überholt. Immer gegen den Code prüfen, nie direkt übernehmen.
| Dokument | Stand | Was drinsteht |
|---|---|---|
| [`UMSETZUNGSPLAN.md`](UMSETZUNGSPLAN.md) | 2026-07-01, abgehakt 2026-08-23 | Phase 1 (Kernanalytik: PnL-Engine, Win-Rate, Deep-Dive, Copytrading-Score) und Phase 2 (Migrationen, Scoring-Entkopplung, Bugliste, Tests) sowie das Speicherplatz-Konzept. Bis auf A4 und B2 umgesetzt. |
| [`FIXPLAN-DONE.md`](FIXPLAN-DONE.md) | 2026-07-09 | Vorgängerfassung des Fix-Plans, vollständig abgearbeitet. Reine Historie. |
| [`FIXPLAN-TODO.md`](FIXPLAN-TODO.md) | 2026-07-09/11/13 | Teil D (Merkmals-Tags, HF-Trader-Tiering mit `IngestMode`) und Teil F (Kategorie-Erkennung: kanonische Tags, Rausch-Filter, Backfill). Umgesetzt bis auf F5 und zwei Tests. |
| [`FIXPLAN-G-Speicher.md`](FIXPLAN-G-Speicher.md) | 2026-07-17 | Speicher-Governor, Retention für aggregierte Trader, `UsdcSize`/`OutcomeIndex`, Per-Kategorie-Edge. Vollständig umgesetzt; der Anhang mit den einmaligen SQL-Optimierungen ist offen. |
| [`FIXPLAN-UI-Ranglisten.md`](FIXPLAN-UI-Ranglisten.md) | 2026-08-03 | Web-UI: Showcase-Sektionen, serverseitige Sortierung und Filterung. Vollständig umgesetzt bis auf die Sortierpfeile. |
| [`PLAN-Linux-Portierung.md`](PLAN-Linux-Portierung.md) | 2026-08-06 bis -08 | Der Portierungsplan mit allen Entscheidungen und Abnahmevermerken. **Die ausführlichste Quelle für Roadmap-Stufe 2** — Abschnitt 7 (Deployment) und 8 (Verifikationsmatrix) sind dort detaillierter als in der Roadmap. |
| [`ANALYSE-Linux-Portierung.md`](ANALYSE-Linux-Portierung.md) | 2026-08-06 | Die Bestandsaufnahme, die dem Portierungsplan vorausging: was am WinForms-Host hing, welche Pakete windows-spezifisch waren. Historisch. |
| [`PLAN-Architektur-WebUI-Backend.md`](PLAN-Architektur-WebUI-Backend.md) | 2026-07 | Drei-Schichten-Konzept Core/Lokal/Public als Vorbereitung einer Monetarisierung. Phase 02 sind im Zuge der Portierung erledigt; Phase 34 sind Roadmap-Stufe 6. |
| [`PLAN-DatenIngest-Skalierung.md`](PLAN-DatenIngest-Skalierung.md) | 2026-07 | Rate-Limits, Egress-Kanäle, Blockchain als Bulk-Quelle. Egress-Kanäle und Ingest-Tiering sind umgesetzt; der Rest ist Roadmap-Stufe 5. |
BIN
View File
Binary file not shown.
+45 -39
View File
@@ -1,18 +1,48 @@
using Predictalytics.Api.Endpoints; using Predictalytics.Api.Endpoints;
using Predictalytics.Api.Security; using Predictalytics.Infrastructure;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using System; using System;
namespace Predictalytics.Api; namespace Predictalytics.Api;
/// <summary> /// <summary>
/// Registriert die Endpunkte der lokalen API. Den WebApplication-Aufbau selbst /// Configures the API WebApplication. Used by the embedded Kestrel server.
/// (Kestrel, CORS, Swagger, Static Files) macht Predictalytics.Hosting —
/// hier stehen nur die Routen.
/// </summary> /// </summary>
public static class ApiConfiguration public static class ApiConfiguration
{ {
public static WebApplication ConfigureApi(WebApplicationBuilder builder, string? connectionStringOverride = null)
{
builder.Services.AddPredictalytics(builder.Configuration, connectionStringOverride);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
?? new[] { "http://localhost:5000" };
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
var app = builder.Build();
app.UseCors();
app.UseSwagger();
app.UseSwaggerUI();
app.UseDefaultFiles();
app.UseStaticFiles(new StaticFileOptions
{
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
});
// Register both Read and Control endpoints for the local host
app.MapPredictalyticsReadEndpoints();
app.MapPredictalyticsControlEndpoints();
return app;
}
/// <summary> /// <summary>
/// Registers all Read-only endpoints designed for viewing and analysis. /// Registers all Read-only endpoints designed for viewing and analysis.
/// Safely exposed publicly since these endpoints perform no DB writes. /// Safely exposed publicly since these endpoints perform no DB writes.
@@ -29,47 +59,23 @@ public static class ApiConfiguration
routes.MapCoMovementEndpoints(); routes.MapCoMovementEndpoints();
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow })); routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
// Meldet der WebUI den tatsaechlichen Zustand, nicht eine Konfigurationsabsicht: routes.MapGet("/api/capabilities", (IConfiguration config) =>
// ControlState wird beim Mappen der Steuerendpunkte gesetzt.
routes.MapGet("/api/capabilities", () => Results.Ok(new
{ {
CanControl = ControlEndpointsMapped, var canControl = config.GetValue<bool>("ApiSettings:CanControl", true);
AuthRequired = ControlEndpointsRequireToken, var authRequired = config.GetValue<bool>("ApiSettings:AuthRequired", false);
TokenHeader = ControlEndpointsRequireToken ? ApiTokenFilter.HeaderName : null return Results.Ok(new { CanControl = canControl, AuthRequired = authRequired });
})); });
} }
/// <summary>Ob die Steuerendpunkte ueberhaupt erreichbar sind.</summary>
public static bool ControlEndpointsMapped { get; private set; }
/// <summary>Ob sie einen Token verlangen.</summary>
public static bool ControlEndpointsRequireToken { get; private set; }
/// <summary> /// <summary>
/// Registers all Control endpoints that trigger crawls, modifications, or database updates. /// Registers all Control endpoints that trigger crawls, modifications, or database updates.
/// Excluded from the Public API routing to ensure system security.
/// </summary> /// </summary>
/// <param name="requiredToken"> public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes)
/// Wenn gesetzt, verlangt jeder Aufruf den Header
/// <see cref="ApiTokenFilter.HeaderName"/> mit diesem Wert. Null oder leer bedeutet
/// ungeschuetzt — das ist nur vertretbar, solange Kestrel an localhost gebunden ist.
/// </param>
public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes, string? requiredToken = null)
{ {
IEndpointRouteBuilder target = routes; routes.MapTraderControlEndpoints();
routes.MapAlertControlEndpoints();
if (!string.IsNullOrWhiteSpace(requiredToken)) routes.MapJobEndpoints();
{ routes.MapDevEndpoints();
// Leere Gruppe ohne Praefix: sie dient nur dazu, den Filter an alle
// darin gemappten Endpunkte zu haengen.
target = routes.MapGroup("").AddEndpointFilter(new ApiTokenFilter(requiredToken!));
ControlEndpointsRequireToken = true;
}
target.MapTraderControlEndpoints();
target.MapAlertControlEndpoints();
target.MapJobEndpoints();
target.MapDevEndpoints();
ControlEndpointsMapped = true;
} }
} }
@@ -72,7 +72,7 @@ public static class DevEndpoints
// its raw SQL referenced non-existent columns/tables (Trades.Type/Payout, // its raw SQL referenced non-existent columns/tables (Trades.Type/Payout,
// Traders.LastPositionsUpdatedAt, table "Jobs") and would have deleted ALL // Traders.LastPositionsUpdatedAt, table "Jobs") and would have deleted ALL
// TraderPositions including the pruned-history conserves. The supported // TraderPositions including the pruned-history conserves. The supported
// repair path is the "Recalculate All Traders" action in the Avalonia // repair path is the "Recalculate All Traders" action in the WinForms
// Development menu (EmbeddedWebServer.RunRecalculateAllTradersAsync). // Development menu (EmbeddedWebServer.RunRecalculateAllTradersAsync).
} }
} }
@@ -1,11 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Api</RootNamespace> <RootNamespace>Predictalytics.Api</RootNamespace>
<!-- Bibliothek fuer Predictalytics.Hosting, laeuft nicht eigenstaendig. --> <!-- This project is used as a library by WinFormsHost; it does not run standalone -->
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" /> <ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" /> <ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
@@ -1,47 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace Predictalytics.Api.Security;
/// <summary>
/// Schuetzt die schreibenden Endpunkte mit einem gemeinsamen Token.
/// <para>
/// Solange Kestrel nur auf localhost lauscht, ist das Betriebssystem die Absicherung.
/// Sobald der Dienst von aussen erreichbar ist, faellt diese weg — dann muss jeder
/// Aufruf, der etwas veraendert, den Header <see cref="HeaderName"/> mitschicken.
/// </para>
/// </summary>
public sealed class ApiTokenFilter : IEndpointFilter
{
public const string HeaderName = "X-Predictalytics-Key";
private readonly byte[] _expected;
public ApiTokenFilter(string token) => _expected = Encoding.UTF8.GetBytes(token);
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var provided = context.HttpContext.Request.Headers[HeaderName].ToString();
if (!IsMatch(provided))
{
return Results.Json(
new { error = "unauthorized", message = $"Header {HeaderName} fehlt oder ist falsch." },
statusCode: StatusCodes.Status401Unauthorized);
}
return await next(context);
}
/// <summary>
/// Laufzeitkonstanter Vergleich: ein zeichenweise abbrechender Vergleich verriete
/// ueber die Antwortzeit, wie viele Zeichen bereits stimmen.
/// </summary>
private bool IsMatch(string provided)
{
if (string.IsNullOrEmpty(provided)) return false;
var actual = Encoding.UTF8.GetBytes(provided);
return CryptographicOperations.FixedTimeEquals(actual, _expected);
}
}
+1 -2
View File
@@ -9,8 +9,7 @@
}, },
"OpenRouter": { "OpenRouter": {
"BaseUrl": "https://openrouter.ai/api/v1", "BaseUrl": "https://openrouter.ai/api/v1",
"_ApiKey": "NIEMALS hier eintragen - der Schluessel gehoert in die Umgebungsvariable OpenRouter__ApiKey. Diese Datei ist versioniert.", "ApiKey": "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"
"ApiKey": ""
}, },
"Egress": { "Egress": {
"Channels": [] "Channels": []
@@ -1,64 +0,0 @@
using Microsoft.AspNetCore.Http;
using Predictalytics.Api.Security;
namespace Predictalytics.Application.Tests.Hosting;
/// <summary>
/// Der Filter ist das Einzige, was die schreibenden Endpunkte schuetzt, sobald der
/// Dienst ueber das Netz erreichbar ist.
/// </summary>
public class ApiTokenFilterTests
{
private const string Token = "s3cr3t-token";
[Fact]
public async Task Correct_token_passes_through()
{
var (context, next, called) = Build(Token);
var result = await new ApiTokenFilter(Token).InvokeAsync(context, next);
Assert.True(called.Value);
Assert.Equal("weiter", result);
}
[Theory]
[InlineData("")] // Header fehlt
[InlineData("falsch")]
[InlineData("s3cr3t-toke")] // ein Zeichen zu kurz
[InlineData("s3cr3t-tokenX")] // ein Zeichen zu lang
[InlineData("S3CR3T-TOKEN")] // Schreibweise muss zaehlen
[InlineData(" s3cr3t-token")] // fuehrender Leerraum
public async Task Wrong_or_missing_token_is_rejected(string provided)
{
var (context, next, called) = Build(provided);
var result = await new ApiTokenFilter(Token).InvokeAsync(context, next);
Assert.False(called.Value);
Assert.NotNull(result);
Assert.NotEqual("weiter", result);
}
private static (DefaultEndpointFilterInvocationContext Context, EndpointFilterDelegate Next, StrongBox<bool> Called)
Build(string headerValue)
{
var httpContext = new DefaultHttpContext();
if (headerValue.Length > 0)
{
httpContext.Request.Headers[ApiTokenFilter.HeaderName] = headerValue;
}
var called = new StrongBox<bool>(false);
EndpointFilterDelegate next = _ =>
{
called.Value = true;
return ValueTask.FromResult<object?>("weiter");
};
return (new DefaultEndpointFilterInvocationContext(httpContext), next, called);
}
private sealed class StrongBox<T>(T value)
{
public T Value { get; set; } = value;
}
}
@@ -1,85 +0,0 @@
using Predictalytics.Hosting;
namespace Predictalytics.Application.Tests.Hosting;
/// <summary>
/// <see cref="PredictalyticsOptions.IsPubliclyBound"/> entscheidet, ob die schreibenden
/// API-Endpunkte ueberhaupt bereitgestellt werden und ob Swagger offen liegt. Ein falsches
/// Ergebnis ist deshalb kein Schoenheitsfehler, sondern eine offene Steuerschnittstelle.
/// </summary>
public class PredictalyticsOptionsTests
{
[Theory]
[InlineData("localhost")]
[InlineData("LOCALHOST")] // Schreibweise darf nicht entscheiden
[InlineData("LocalHost")]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData(" localhost ")] // Leerraum aus dem Eingabefeld
[InlineData("")] // leer = Vorgabe = lokal
[InlineData(" ")]
public void IsPubliclyBound_is_false_for_loopback(string host)
{
var options = new PredictalyticsOptions { WebserverHost = host };
Assert.False(options.IsPubliclyBound);
}
[Theory]
[InlineData("0.0.0.0")]
[InlineData("::")]
[InlineData("192.168.178.10")]
[InlineData("10.0.0.5")]
[InlineData("predictalytics.example.com")]
public void IsPubliclyBound_is_true_for_everything_else(string host)
{
var options = new PredictalyticsOptions { WebserverHost = host };
Assert.True(options.IsPubliclyBound);
}
[Fact]
public void WebserverUrl_falls_back_to_localhost_when_host_is_blank()
{
var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 5000 };
Assert.Equal("http://localhost:5000", options.WebserverUrl);
}
[Fact]
public void WebserverUrl_combines_host_and_port()
{
var options = new PredictalyticsOptions { WebserverHost = "0.0.0.0", WebserverPort = 8080 };
Assert.Equal("http://0.0.0.0:8080", options.WebserverUrl);
}
[Fact]
public void ConnectionString_honours_the_configured_ssl_mode()
{
var options = new PredictalyticsOptions
{
DbServer = "db.example.com",
DbName = "predictalytics",
DbUser = "app",
DbPassword = "secret",
DbSslMode = MySqlConnector.MySqlSslMode.Preferred
};
var builder = new MySqlConnector.MySqlConnectionStringBuilder(options.ConnectionString);
Assert.Equal(MySqlConnector.MySqlSslMode.Preferred, builder.SslMode);
Assert.Equal("predictalytics", builder.Database);
}
/// <summary>
/// Die Setter ignorieren leere Werte bewusst — sonst wuerde ein versehentlich geleertes
/// Eingabefeld die hinterlegten Zugangsdaten verwerfen.
/// </summary>
[Fact]
public void Blank_database_values_do_not_overwrite_existing_ones()
{
var options = new PredictalyticsOptions { DbName = "predictalytics", DbUser = "app" };
options.DbName = "";
options.DbUser = " ";
Assert.Equal("predictalytics", options.DbName);
Assert.Equal("app", options.DbUser);
}
}
@@ -1,17 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -23,7 +26,6 @@
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" /> <ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" /> <ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" /> <ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -16,6 +16,7 @@ public interface IAnalyticsService
Task<IReadOnlyList<Services.ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default); Task<IReadOnlyList<Services.ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default);
/// <summary>Get list of all discovered traits.</summary> /// <summary>Get list of all discovered traits.</summary>
Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default);
/// <summary>Get a trader's details.</summary> /// <summary>Get a trader's details.</summary>
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default); Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
@@ -5,12 +5,5 @@ namespace Predictalytics.Application.Interfaces;
public interface IOpenRouterApiClient public interface IOpenRouterApiClient
{ {
/// <summary>
/// Rueckgabe, wenn kein API-Schluessel hinterlegt ist. Aufrufer pruefen darauf, statt
/// die Antwort als JSON zu lesen - sonst erzeugt eine fehlende Konfiguration bei jedem
/// Versuch einen Parse-Fehler im Log.
/// </summary>
const string NotConfigured = "OpenRouter ist nicht konfiguriert.";
Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default); Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default);
} }
@@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Application</RootNamespace> <RootNamespace>Predictalytics.Application</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -45,14 +45,6 @@ public class AiStrategyAnalysisService : IAiStrategyAnalysisService
var jsonResult = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct); var jsonResult = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct);
// Ohne Schluessel gar nicht erst parsen: das erzeugte sonst pro Versuch einen
// Error-Eintrag, obwohl nur die Konfiguration fehlt.
if (jsonResult == IOpenRouterApiClient.NotConfigured)
{
_logger.LogWarning("KI-Analyse fuer {TraderName} uebersprungen: OpenRouter ist nicht konfiguriert.", trader.DisplayName);
return jsonResult;
}
try try
{ {
// Remove markdown code blocks if present // Remove markdown code blocks if present
@@ -242,6 +242,13 @@ public class AnalyticsService : IAnalyticsService
return ShowcaseBuilder.Build(candidates); return ShowcaseBuilder.Build(candidates);
} }
public Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default)
{
// Actually this is unused since we mapped it straight to db context in the endpoint,
// but required by interface.
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
}
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, string? category = null, string? query = null, CancellationToken ct = default) public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, string? category = null, string? query = null, CancellationToken ct = default)
{ {
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering. // NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Domain</RootNamespace> <RootNamespace>Predictalytics.Domain</RootNamespace>
</PropertyGroup> </PropertyGroup>
-96
View File
@@ -1,96 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Predictalytics.Hosting;
/// <summary>
/// Failed Deployment Center call. <see cref="Code"/> is the stable, machine readable
/// error code from the API envelope ("unauthorized", "rate_limited", ...) — react to it,
/// not to the message text.
/// </summary>
public sealed class DcApiException : Exception
{
public DcApiException(HttpStatusCode statusCode, string? code, string body)
: base($"Deployment Center HTTP {(int)statusCode}{(code is null ? "" : $" ({code})")}: {Shorten(body)}")
{
StatusCode = statusCode;
Code = code;
}
public HttpStatusCode StatusCode { get; }
public string? Code { get; }
/// <summary>True for errors that repeating the same call cannot fix (wrong/missing token).</summary>
public bool IsPermanent =>
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ||
Code is "unauthorized" or "project_forbidden";
private static string Shorten(string body) =>
body.Length <= 300 ? body : body[..300] + "…";
}
/// <summary>
/// Minimal JSON client for the Deployment Center API. Used by the heartbeat and the error
/// stream; the license and update modules bring their own client (Deploymentcenter.Client).
/// </summary>
public sealed class DcApiClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
private readonly string _token;
public DcApiClient(string token, TimeSpan? timeout = null)
{
_token = token ?? "";
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(10) };
}
public async Task<string> PostJsonAsync(string path, object payload, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, DcConfig.BaseUrl + path);
if (!string.IsNullOrWhiteSpace(_token))
{
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}");
}
request.Content = new StringContent(
JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new DcApiException(response.StatusCode, ExtractErrorCode(body), body);
}
return body;
}
/// <summary>Reads error.code out of {"status":"error","error":{"code":"…"}}.</summary>
private static string? ExtractErrorCode(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("error", out var error) &&
error.ValueKind == JsonValueKind.Object &&
error.TryGetProperty("code", out var code))
{
return code.GetString();
}
}
catch (JsonException)
{
// Not every error path answers with the envelope (e.g. a proxy returning HTML).
}
return null;
}
public void Dispose() => _http.Dispose();
}
-37
View File
@@ -1,37 +0,0 @@
namespace Predictalytics.Hosting;
/// <summary>
/// Compile-time settings for the Deployment Center (https://dc.mhdf.de), which replaced
/// the separate Watchdog and LicenseLabrador servers.
///
/// The base URL is deliberately NOT user configuration: it decides where the license check
/// goes and where update packages are downloaded from. A configurable endpoint would let
/// anyone point the app at a fake license or update server.
/// </summary>
public static class DcConfig
{
public const string BaseUrl = "https://dc.mhdf.de";
/// <summary>Slug in dc_projects — license, UpdateService, error stream and bugtracker share it.</summary>
public const string ProductSlug = "predictalytics";
/// <summary>
/// Reported to the license activation list, the monitor and the error stream.
/// Generated by Deploymentcenter.BuildInfo.targets from &lt;Version&gt; in the csproj —
/// bump it there when releasing, it is what the UpdateService compares against.
/// </summary>
public static string AppVersion => BuildInfo.Version;
/// <summary>Commit this build came from — travels with error reports.</summary>
public static string GitCommitShort => BuildInfo.GitCommitShort;
/// <summary>Dashboard grouping of the Watchdog monitor.</summary>
public const string MonitorGroup = "Applications";
/// <summary>Value for the "environment" field of the error stream.</summary>
#if DEBUG
public const string Environment = "development";
#else
public const string Environment = "production";
#endif
}
@@ -1,291 +0,0 @@
using System.Diagnostics;
using Serilog;
namespace Predictalytics.Hosting;
/// <summary>
/// Forwards runtime errors to the Deployment Center error stream
/// (POST /api/errors/v1/report). The server groups identical errors, counts them up and
/// silences known-harmless ones via ignore rules — so this reports rather than filters.
///
/// Static on purpose: the Serilog sink and the global exception handlers are wired up
/// before the settings are known, and both have to reach the same rate limiter.
/// </summary>
public static class DcErrorReporter
{
/// <summary>Server limit is 60 reports per minute and IP — stay well below it.</summary>
private const int MaxReportsPerMinute = 20;
/// <summary>The same error is only reported again after this interval (the server counts it up anyway).</summary>
private static readonly TimeSpan RepeatSuppression = TimeSpan.FromMinutes(5);
private static readonly object Sync = new();
private static readonly Dictionary<string, DateTime> RecentSignatures = new();
private static DcApiClient? _api;
private static bool _enabled;
private static bool _tokenRejected;
private static bool _transportFailureLogged;
private static DateTime _windowStartUtc = DateTime.UtcNow;
private static int _sentInWindow;
public static bool IsEnabled => _enabled && _api is not null && !_tokenRejected;
/// <summary>(Re-)configures the reporter. An empty token switches it off.</summary>
public static void Configure(string token, bool enabled)
{
string? offReason;
lock (Sync)
{
_api?.Dispose();
_api = null;
_tokenRejected = false;
_transportFailureLogged = false;
if (!enabled || string.IsNullOrWhiteSpace(token))
{
_enabled = false;
offReason = !enabled
? "in den Einstellungen abgeschaltet"
: "kein Deployment-Center-Token hinterlegt";
}
else
{
_api = new DcApiClient(token);
_enabled = true;
offReason = null;
}
}
// Auf Warning-Ebene, damit es im Terminal des Hosts steht: ein stumm
// abgeschalteter Fehler-Stream faellt sonst erst auf, wenn im Deployment
// Center nach einem Absturz nichts ankommt. Warning erreicht den
// DcErrorSink nicht (Error+), erzeugt also keine Rueckkopplung.
if (offReason is null)
{
Log.Warning("🛰 Fehler-Stream aktiv — Error/Fatal gehen an {Url} (Projekt {Slug}, Build {Build}).",
DcConfig.BaseUrl, DcConfig.ProductSlug, DcConfig.AppVersion);
}
else
{
Log.Warning("🛰 Fehler-Stream INAKTIV ({Reason}) — Laufzeitfehler bleiben nur lokal im Log.", offReason);
}
}
public static void Shutdown()
{
lock (Sync)
{
_enabled = false;
_api?.Dispose();
_api = null;
}
}
/// <summary>
/// Installs the global handlers. Without them an unhandled exception ends the process
/// without a trace in the error stream — exactly the case the stream exists for.
/// </summary>
public static void InstallGlobalHandlers()
{
// Der UI-Thread-Handler bleibt beim Host: jedes Fenstersystem hat sein eigenes
// Ereignis dafuer (WinForms Application.ThreadException, Avalonia
// Dispatcher.UIThread.UnhandledException). Der Host ruft dafuer
// ReportUiThreadException auf.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
if (e.ExceptionObject is not Exception ex) return;
// Report before logging: the Serilog sink would report the same exception first
// and the duplicate suppression would then swallow the blocking call — the
// process would die before anything reached the server.
Report(ex, "fatal", blocking: true);
Log.Fatal(ex, "Unbehandelte Ausnahme — Prozess wird beendet");
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Log.Warning(e.Exception, "Unbeobachtete Task-Ausnahme");
Report(e.Exception, "error");
e.SetObserved();
};
}
/// <summary>
/// Vom Host aufzurufen, wenn im UI-Thread eine unbehandelte Ausnahme auftritt.
/// Protokolliert und meldet sie; die Meldung an den Benutzer bleibt beim Host,
/// weil dafuer ein Fenster gebraucht wird.
/// </summary>
public static void ReportUiThreadException(Exception exception)
{
Log.Error(exception, "Unbehandelte Ausnahme im UI-Thread");
Report(exception, "error");
}
public static void Report(Exception exception, string level = "error", bool blocking = false)
{
if (!IsEnabled) return;
var inner = Unwrap(exception);
var (file, line) = ResolveOrigin(inner);
Send(
exceptionType: inner.GetType().FullName ?? inner.GetType().Name,
message: inner.Message,
stackTrace: exception.ToString(),
level: level,
file: file,
line: line,
blocking: blocking);
}
/// <summary>Reports a logged error that carries no exception (Log.Error("..." )).</summary>
public static void ReportMessage(string exceptionType, string message, string? stackTrace, string level)
{
if (!IsEnabled) return;
Send(exceptionType, message, stackTrace, level, null, null, blocking: false);
}
private static void Send(
string exceptionType, string message, string? stackTrace, string level,
string? file, int? line, bool blocking)
{
DcApiClient api;
lock (Sync)
{
if (_api is null || !_enabled || _tokenRejected) return;
if (!PassesRateLimit(exceptionType, message)) return;
api = _api;
}
var payload = new
{
project_slug = DcConfig.ProductSlug,
exception = exceptionType,
message = Truncate(message, 2000),
stack_trace = Truncate(stackTrace, 8000),
level,
build = DcConfig.AppVersion,
environment = DcConfig.Environment,
file,
line,
// Tells apart reports coming from several installations of the same build,
// and pins the report to an exact commit.
context = new { host = System.Environment.MachineName, commit = DcConfig.GitCommitShort }
};
var task = PostAsync(api, payload);
if (blocking)
{
task.Wait(TimeSpan.FromSeconds(5));
}
}
private static async Task PostAsync(DcApiClient api, object payload)
{
try
{
await api.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false);
}
catch (DcApiException ex) when (ex.IsPermanent)
{
lock (Sync) { _tokenRejected = true; }
// Warning, nicht Error: der DcErrorSink nimmt erst ab Error an, ein Error
// hier kaeme als naechster Report zurueck. _tokenRejected sperrt weitere
// Versuche, die Meldung erscheint also genau einmal.
Log.Warning("🛰 Fehler-Stream abgewiesen ({Code}) — Token pruefen (Recht 'bugtracker:report'). Melden ist bis zum naechsten Speichern der Einstellungen aus.", ex.Code);
}
catch (Exception ex)
{
// Erster Fehlschlag sichtbar, danach still: sonst haengt an jedem
// Netzwerkaussetzer eine Warnung pro gemeldetem Fehler.
bool first;
lock (Sync)
{
first = !_transportFailureLogged;
_transportFailureLogged = true;
}
if (first)
{
Log.Warning("🛰 Fehlermeldung an das Deployment Center fehlgeschlagen: {Message}. Weitere Fehlschlaege werden nicht mehr gemeldet.", ex.Message);
}
else
{
Log.Debug(ex, "Deployment Center error report failed");
}
}
}
/// <summary>Local budget: the server counts duplicates itself, we only avoid burning the rate limit.</summary>
private static bool PassesRateLimit(string exceptionType, string message)
{
var now = DateTime.UtcNow;
if (now - _windowStartUtc > TimeSpan.FromMinutes(1))
{
_windowStartUtc = now;
_sentInWindow = 0;
}
if (_sentInWindow >= MaxReportsPerMinute) return false;
var signature = exceptionType + "|" + Truncate(message, 200);
if (RecentSignatures.TryGetValue(signature, out var last) && now - last < RepeatSuppression)
{
return false;
}
if (RecentSignatures.Count > 500) RecentSignatures.Clear();
RecentSignatures[signature] = now;
_sentInWindow++;
return true;
}
/// <summary>
/// AggregateException and TargetInvocationException say nothing about the actual defect;
/// grouping on them would throw unrelated errors into one bucket.
/// </summary>
private static Exception Unwrap(Exception exception)
{
while (exception is AggregateException { InnerExceptions.Count: 1 } aggregate)
{
exception = aggregate.InnerExceptions[0];
}
return exception;
}
/// <summary>Reads file and line from the first stack frame that has debug info (PDB present).</summary>
private static (string? File, int? Line) ResolveOrigin(Exception exception)
{
try
{
var trace = new StackTrace(exception, fNeedFileInfo: true);
foreach (var frame in trace.GetFrames())
{
var file = frame.GetFileName();
if (string.IsNullOrEmpty(file)) continue;
var lineNo = frame.GetFileLineNumber();
return (ToRepoRelative(file!), lineNo > 0 ? lineNo : (int?)null);
}
}
catch
{
// Origin is a nicety, never a reason to drop the report.
}
return (null, null);
}
/// <summary>Turns C:\build\...\src\Foo\Bar.cs into src/Foo/Bar.cs so the path matches the repo.</summary>
private static string ToRepoRelative(string path)
{
var normalized = path.Replace('\\', '/');
var marker = normalized.LastIndexOf("/src/", StringComparison.OrdinalIgnoreCase);
return marker >= 0 ? normalized[(marker + 1)..] : normalized;
}
private static string? Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value)) return value;
return value!.Length <= max ? value : value[..max] + "…";
}
}
-39
View File
@@ -1,39 +0,0 @@
using Serilog.Core;
using Serilog.Events;
namespace Predictalytics.Hosting;
/// <summary>
/// Serilog sink that forwards Error and Fatal events to the Deployment Center error stream.
///
/// It is registered while the logger is being built, long before the settings are known —
/// <see cref="DcErrorReporter"/> is asked at emit time whether reporting is switched on, so
/// toggling the setting takes effect without rebuilding the logger.
/// </summary>
public sealed class DcErrorSink : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < LogEventLevel.Error) return;
if (!DcErrorReporter.IsEnabled) return;
var level = logEvent.Level == LogEventLevel.Fatal ? "fatal" : "error";
if (logEvent.Exception is not null)
{
DcErrorReporter.Report(logEvent.Exception, level);
return;
}
// No exception attached: the rendered message is all the identity this error has.
var source = logEvent.Properties.TryGetValue("SourceContext", out var ctx)
? ctx.ToString().Trim('"')
: "Predictalytics";
DcErrorReporter.ReportMessage(
exceptionType: source,
message: logEvent.RenderMessage(),
stackTrace: null,
level: level);
}
}
@@ -1,217 +0,0 @@
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Serilog;
namespace Predictalytics.Hosting;
/// <summary>Single self-assessed health check sent along with a heartbeat.</summary>
public sealed class DcCheck
{
public DcCheck(bool ok, string? message = null, double? value = null)
{
Ok = ok;
Message = message;
Value = value;
}
[JsonPropertyName("ok")] public bool Ok { get; }
[JsonPropertyName("message")] public string? Message { get; }
[JsonPropertyName("value")] public double? Value { get; }
}
/// <summary>What the application reports about itself in one heartbeat.</summary>
public sealed class DcHeartbeatSnapshot
{
/// <summary>ok | warning | error — "stopped"/"maintenance" are sent by the service itself.</summary>
public string Status { get; set; } = "ok";
public string? Message { get; set; }
/// <summary>A failing check downgrades an "ok" heartbeat to "warning" on the server.</summary>
public Dictionary<string, DcCheck> Checks { get; } = new();
/// <summary>Numeric values; the server keeps 14 days of history per metric.</summary>
public Dictionary<string, double> Metrics { get; } = new();
}
/// <summary>
/// Sends periodic heartbeats to the Deployment Center Watchdog
/// (POST /api/watchdog/v1/ping) so an outage of this app — or of the whole machine —
/// raises an alarm. A Deployment Center outage must never impact the app: every call
/// is best effort.
///
/// The server evaluates by interval: no heartbeat for more than 2× the interval means
/// "warning", more than 4× means "down".
/// </summary>
public sealed class DcHeartbeatService : IDisposable
{
private readonly DcApiClient _api;
private readonly string _source;
private readonly string _instance;
private readonly int _intervalSeconds;
private readonly Func<CancellationToken, Task<DcHeartbeatSnapshot>>? _snapshotProvider;
private readonly DateTime _startedUtc = DateTime.UtcNow;
// Guards against overlapping sends when a call takes longer than the interval.
private readonly SemaphoreSlim _sendGate = new(1, 1);
private System.Threading.Timer? _timer;
private bool _lastSendFailed;
private bool _tokenRejected;
private bool _stoppingNotified;
private volatile bool _disposed;
public DcHeartbeatService(
string token,
string source,
string instance,
int intervalSeconds,
Func<CancellationToken, Task<DcHeartbeatSnapshot>>? snapshotProvider = null)
{
_api = new DcApiClient(token);
_source = string.IsNullOrWhiteSpace(source) ? "Predictalytics" : source;
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
_intervalSeconds = Math.Max(15, intervalSeconds);
_snapshotProvider = snapshotProvider;
}
public void Start()
{
_timer?.Dispose();
_timer = new System.Threading.Timer(
async _ => await SendHeartbeatAsync().ConfigureAwait(false),
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
Log.Information("🐕 Deployment Center Heartbeat gestartet → {Url} (source={Source}, alle {Interval}s)",
DcConfig.BaseUrl, _source, _intervalSeconds);
}
/// <summary>
/// Announces a planned shutdown as status "stopped". The evaluator leaves such a monitor
/// alone until a normal heartbeat arrives again — without it, every orderly shutdown
/// produces a false alarm a few minutes later.
/// </summary>
public void NotifyStopping()
{
if (_stoppingNotified) return;
_stoppingNotified = true;
try
{
_timer?.Dispose();
_timer = null;
var payload = BuildPayload("stopped", "Predictalytics wird planmäßig beendet.", null, null);
// Synchronous with a short cap: the form is closing and must not hang.
_api.PostJsonAsync("/api/watchdog/v1/ping", payload).Wait(TimeSpan.FromSeconds(4));
}
catch
{
// Best effort only.
}
}
/// <remarks>
/// Invoked from a timer callback, i.e. as async void — an exception escaping here would
/// take the whole process down. Nothing in this method may throw.
/// </remarks>
private async Task SendHeartbeatAsync()
{
if (_disposed || _tokenRejected) return;
if (!await _sendGate.WaitAsync(0).ConfigureAwait(false)) return;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_intervalSeconds));
DcHeartbeatSnapshot snapshot;
try
{
snapshot = _snapshotProvider is null
? new DcHeartbeatSnapshot()
: await _snapshotProvider(cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
// Collecting the state must never keep the heartbeat from going out —
// that would turn a diagnostic hiccup into a false "down".
snapshot = new DcHeartbeatSnapshot { Status = "warning", Message = $"Statusermittlung fehlgeschlagen: {ex.Message}" };
}
snapshot.Metrics["uptime_sec"] = Math.Round((DateTime.UtcNow - _startedUtc).TotalSeconds);
var payload = BuildPayload(snapshot.Status, snapshot.Message, snapshot.Checks, snapshot.Metrics);
await _api.PostJsonAsync("/api/watchdog/v1/ping", payload, cts.Token).ConfigureAwait(false);
if (_lastSendFailed)
{
_lastSendFailed = false;
Log.Information("🐕 Deployment Center Heartbeat wieder erfolgreich zugestellt.");
}
}
catch (DcApiException ex) when (ex.IsPermanent)
{
// Retrying cannot help — a rejected token would otherwise log forever.
_tokenRejected = true;
Log.Warning("🐕 Deployment Center weist das Token zurück ({Code}) — Heartbeats werden eingestellt. " +
"Bitte in den Settings ein Token mit dem Recht 'watchdog:ping' eintragen.", ex.Code ?? "unauthorized");
}
catch (Exception ex)
{
// Log the first failure as warning, subsequent ones quietly (no log flood).
if (!_lastSendFailed)
{
_lastSendFailed = true;
Log.Warning("🐕 Deployment Center Heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
}
else
{
Log.Debug(ex, "Deployment Center heartbeat failed");
}
}
finally
{
_sendGate.Release();
}
}
public void Dispose()
{
_disposed = true;
_timer?.Dispose();
_timer = null;
// Give a send in flight a moment to finish before the HttpClient goes away. Called
// from the UI thread (settings change, form closing), so the wait stays short — if it
// expires, the pending call just fails and gets logged like any other network error.
if (_sendGate.Wait(TimeSpan.FromMilliseconds(250)))
{
_sendGate.Release();
}
_api.Dispose();
// _sendGate is deliberately not disposed: a late Release() on a disposed semaphore
// would throw on the timer thread for no gain — SemaphoreSlim without a wait handle
// holds no unmanaged resources.
}
private object BuildPayload(
string status,
string? message,
Dictionary<string, DcCheck>? checks,
Dictionary<string, double>? metrics) => new
{
source = _source,
instance = _instance,
type = "heartbeat",
status,
interval = _intervalSeconds,
message,
group = DcConfig.MonitorGroup,
os = $"{RuntimeInformation.OSDescription} / .NET {System.Environment.Version}",
// Since 2.1 the monitor can show which build is running — that is what ties
// "this monitor went down" to "we rolled out 1.4.3 an hour ago".
version = DcConfig.AppVersion,
checks = checks is { Count: > 0 } ? checks : null,
metrics = metrics is { Count: > 0 } ? metrics : null
};
}
@@ -1,132 +0,0 @@
using Deploymentcenter.Client;
using Serilog;
namespace Predictalytics.Hosting;
/// <summary>
/// Checks the Deployment Center UpdateService for a newer release
/// (GET /api/updateservice/v1/check).
///
/// The check itself only reads metadata. Installing is done by the standalone
/// update-agent, which replaces the running installation and therefore has to be
/// started explicitly by the user.
///
/// Since Deployment Center 2.4 the release directories sit behind HTTP basic auth:
/// the license key is the credential. Without it every download answers 401, so the
/// key has to travel with both the check and the agent handover.
/// </summary>
public static class DcUpdateService
{
/// <summary>
/// Runtime identifier the release directories are split by (win-x64, linux-x64, …).
/// Taken from the SDK so app and agent agree on the spelling.
/// </summary>
public static string Platform => PlatformId.Current;
public static async Task<UpdateCheckResult> CheckAsync(
string channel, string? licenseKey = null, CancellationToken ct = default)
{
var client = new UpdateClient();
return await client.CheckForUpdateAsync(
baseUrl: DcConfig.BaseUrl,
projectId: DcConfig.ProductSlug,
currentVersion: DcConfig.AppVersion,
channel: NormalizeChannel(channel),
platform: Platform,
credentials: ResolveCredentials(licenseKey),
cancellationToken: ct).ConfigureAwait(false);
}
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
public static string? FindUpdateAgent()
{
return UpdateClient.ResolveAgentPath(AppContext.BaseDirectory);
}
/// <summary>
/// Hands control to the update agent and ends this process. Returns false if the agent
/// is not present — then the update has to be installed by hand.
/// </summary>
public static bool LaunchAgent(string channel, string? licenseKey = null)
{
var agent = FindUpdateAgent();
if (agent is null)
{
Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.",
UpdateClient.AgentFileName);
return false;
}
var key = ResolveLicenseKey(licenseKey);
if (string.IsNullOrWhiteSpace(key))
{
// The agent would download, get 401 and abort — with the app already closed.
Log.Warning("Kein Lizenzschlüssel verfügbar — der Update-Agent käme nicht an das Paket. " +
"Update wird nicht gestartet.");
return false;
}
var restartPath = ResolveRestartPath();
Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}, Plattform {Platform}) und beende die Anwendung...",
agent, NormalizeChannel(channel), Platform);
return UpdateClient.LaunchUpdateAgent(
agentPath: agent,
projectId: DcConfig.ProductSlug,
channel: NormalizeChannel(channel),
action: "update",
version: "latest",
exitCurrentApp: true,
restartPath: restartPath,
currentVersion: DcConfig.AppVersion,
platform: Platform,
// The agent waits for this process to go away before it copies over
// the installation — otherwise it writes onto still-locked assemblies.
waitForCurrentProcess: true,
licenseKey: key);
}
/// <summary>
/// Basic-auth credentials for the protected release directories. Falls back to the key
/// of the last successful activation, which also covers the headless path.
/// </summary>
private static ReleaseCredentials? ResolveCredentials(string? licenseKey)
=> ReleaseCredentials.FromLicenseKey(ResolveLicenseKey(licenseKey));
private static string? ResolveLicenseKey(string? licenseKey)
=> string.IsNullOrWhiteSpace(licenseKey)
? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug)
: licenseKey;
/// <summary>
/// Executable the agent should start again once it is done.
///
/// Passing null is not an option: the SDK then falls back to Environment.ProcessPath
/// itself, so the "dotnet" case below cannot be opted out of — it has to be resolved
/// to the apphost here.
/// </summary>
private static string? ResolveRestartPath()
{
var path = Environment.ProcessPath;
// Started as "dotnet Predictalytics.Shell.dll": ProcessPath is the shared runtime
// host, and restarting that without its argument would launch nothing. The apphost
// next to the entry assembly is what the user actually started from.
if (path is null || Path.GetFileNameWithoutExtension(path).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
{
var entry = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name;
if (!string.IsNullOrWhiteSpace(entry))
{
var appHost = Path.Combine(
AppContext.BaseDirectory,
OperatingSystem.IsWindows() ? entry + ".exe" : entry!);
if (File.Exists(appHost)) return appHost;
}
}
return path;
}
private static string NormalizeChannel(string channel)
=> string.IsNullOrWhiteSpace(channel) ? "prod" : channel.Trim();
}
-196
View File
@@ -1,196 +0,0 @@
using Deploymentcenter.Client;
using Serilog;
namespace Predictalytics.Hosting;
/// <summary>An activated license: the key belonging to this machine plus the last result.</summary>
public sealed class LicenseSession
{
public LicenseSession(LicenseClient client, string licenseKey, LicenseValidationResult result)
{
Client = client;
LicenseKey = licenseKey;
LastResult = result;
}
public LicenseClient Client { get; }
public string LicenseKey { get; }
public LicenseValidationResult LastResult { get; internal set; }
}
/// <summary>Ergebnis der Pruefung des zwischengespeicherten Schluessels.</summary>
/// <param name="Session">Gesetzt, wenn eine nutzbare Lizenz vorliegt.</param>
/// <param name="LastResult">Letzte Serverantwort — null, wenn gar kein Schluessel hinterlegt war.</param>
public readonly record struct CachedLicenseCheck(LicenseSession? Session, LicenseValidationResult? LastResult)
{
public bool IsUsable => Session is not null;
}
/// <summary>
/// Startup license gate backed by the Deployment Center (POST /api/license/v1/validate).
/// <para>
/// Endpoint and product slug are deliberately compiled in (see <see cref="DcConfig"/>):
/// a configurable endpoint would let anyone point the app at a fake license server.
/// </para>
/// <para>
/// Enthaelt bewusst keinen Dialog: die interaktive Aktivierung liegt beim Host
/// (Avalonia-Fenster), der Headless-Modus bezieht den Schluessel aus der
/// Umgebungsvariable oder dem verschluesselten Cache.
/// </para>
/// </summary>
public static class LicenseGuard
{
/// <summary>Re-check interval while the app is running (12 h).</summary>
public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12);
public static LicenseClient CreateClient() => new();
/// <summary>Hardware ID v2 of this machine — shown in the dialog and needed for support.</summary>
public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(DcConfig.ProductSlug);
/// <summary>Ablageort des verschluesselten Lizenz-Caches.</summary>
public static string StorageDirectory => LicenseConfig.GetStorageDirectory(DcConfig.ProductSlug);
/// <summary>
/// Prueft den Schluessel der letzten erfolgreichen Aktivierung aus dem verschluesselten
/// Cache. Der Host entscheidet, was bei einem negativen Ergebnis passiert — Dialog
/// anzeigen (GUI) oder mit Fehlercode beenden (headless).
/// </summary>
public static async Task<CachedLicenseCheck> TryUseCachedAsync(LicenseClient? client = null)
{
client ??= CreateClient();
var hardware = GetHardwareInfo();
// The client takes the key as a parameter on every call; the key of the last
// successful activation lives in the encrypted local cache.
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
if (string.IsNullOrWhiteSpace(cachedKey))
{
return new CachedLicenseCheck(null, null);
}
var result = await client.ValidateAsync(DcConfig.ProductSlug, cachedKey!, DcConfig.BaseUrl);
if (result.IsValid)
{
Log.Information("🔑 Lizenz geprüft: {Status}{Cached} (HWID {Hwid}, Quelle {Source})",
result.Status, result.IsCached ? " — aus Offline-Cache" : "",
hardware.HardwareId, hardware.HwidSource);
WarnIfGraceRunningOut(result);
return new CachedLicenseCheck(new LicenseSession(client, cachedKey!, result), result);
}
Log.Warning("🔑 Gespeicherter Lizenzschlüssel nicht nutzbar ({Status}): {Message}",
result.Status, result.Message);
return new CachedLicenseCheck(null, result);
}
/// <summary>Aktiviert einen neu eingegebenen Schluessel.</summary>
public static async Task<(LicenseSession? Session, LicenseValidationResult Result)> ActivateAsync(
LicenseClient client, string licenseKey)
{
var key = licenseKey.Trim().ToUpperInvariant();
var result = await client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl);
return result.IsValid
? (new LicenseSession(client, key, result), result)
: (null, result);
}
/// <summary>
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app keeps
/// running; only a definitive negative triggers <paramref name="onUnusable"/> — a server
/// outage must not.
/// </summary>
public static IDisposable StartPeriodicRevalidation(
LicenseSession session, Action<LicenseValidationResult> onUnusable)
{
var cts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(RevalidationInterval);
try
{
while (await timer.WaitForNextTickAsync(cts.Token))
{
try
{
var result = await session.Client.ValidateAsync(
DcConfig.ProductSlug, session.LicenseKey, DcConfig.BaseUrl);
session.LastResult = result;
if (result.IsValid)
{
if (result.IsCached)
{
Log.Warning("Lizenzserver nicht erreichbar — Prüfung erfolgte aus dem Offline-Cache.");
}
WarnIfGraceRunningOut(result);
continue;
}
if (result.IsTransient)
{
// server_unavailable / cache_expired: no verdict, only a failed connection.
// A running installation must not be shut down for that — but an exhausted
// grace period is worth more than a warning: the next start will stop at
// the license dialog.
if (result.Status.Equals("cache_expired", StringComparison.OrdinalIgnoreCase))
{
Log.Error("Offline-Gnadenfrist abgelaufen und das Deployment Center ist nicht erreichbar. " +
"Diese Sitzung läuft weiter, der nächste Start verlangt aber eine Online-Prüfung: {Message}",
result.Message);
}
else
{
Log.Warning("Lizenz-Revalidierung vorläufig fehlgeschlagen ({Status}): {Message} — wird erneut versucht.",
result.Status, result.Message);
}
continue;
}
Log.Fatal("Lizenzprüfung fehlgeschlagen ({Status}): {Message} — Anwendung wird beendet.",
result.Status, result.Message);
onUnusable(result);
return;
}
catch (Exception ex)
{
// Never kill the app from an unexpected exception here.
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
}
}
}
catch (OperationCanceledException)
{
// Regulaeres Beenden.
}
}, cts.Token);
return new Stopper(cts);
}
/// <summary>
/// Since 2.1 the offline grace period is really bounded (cache_ttl_hours, default 168 h).
/// A machine that is offline on purpose should learn that before it runs out, not after.
/// </summary>
private static void WarnIfGraceRunningOut(LicenseValidationResult result)
{
if (result.CacheExpiresAt is not { } expiresAt || expiresAt <= 0) return;
var remaining = DateTimeOffset.FromUnixTimeSeconds(expiresAt) - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.FromHours(48)) return;
Log.Warning("🔑 Offline-Gnadenfrist endet in {Hours:F0} h ({Until:yyyy-MM-dd HH:mm} UTC) — " +
"bis dahin muss das Deployment Center einmal erreichbar sein.",
Math.Max(0, remaining.TotalHours), DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime);
}
private sealed class Stopper(CancellationTokenSource cts) : IDisposable
{
public void Dispose()
{
try { cts.Cancel(); } catch { /* bereits beendet */ }
cts.Dispose();
}
}
}
@@ -1,47 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Predictalytics.Hosting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Bewusst der normale SDK statt Microsoft.NET.Sdk.Web: das hier ist eine
Bibliothek, kein Web-Projekt. Der FrameworkReference liefert WebApplication
und Kestrel, ohne die Static-Web-Assets-Maschinerie des Web-SDK
mitzuschleppen (die mit dem wwwroot-Content der Hosts kollidiert). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<!-- Externes Schwester-Repo: J:\Softwareprojekte\Deploymentcenter muss neben dem Predictalytics-Checkout liegen.
Loest Watchdog + LicenseLabrador ab (Lizenz, UpdateService, Fehler-Stream, Bugtracker). -->
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
<!-- Ohne das Schwester-Repo scheitert der Build sonst mit einer Meldung ueber eine
fehlende Projektdatei, aus der niemand die eigentliche Ursache ablesen kann. -->
<Target Name="CheckDeploymentcenterCheckout" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj')"
Text="Das Schwester-Repo 'Deploymentcenter' wurde nicht gefunden. Es muss neben dem Predictalytics-Checkout liegen (erwartet: $(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter). Klonen von http://192.168.178.10:8418/Richard/Deploymentcenter.git" />
</Target>
<!-- Erzeugt Predictalytics.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal)
zur Uebersetzungszeit aus <Version> und dem Git-Stand. -->
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
</Project>
@@ -1,242 +0,0 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Predictalytics.Hosting;
/// <summary>
/// Laufzeit-Konfiguration der Anwendung.
/// <para>
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
/// Basisbibliothek). Die Avalonia-Shell nutzt sie fuer Beschriftungen und Hilfetexte
/// der Einstellungsansicht.
/// </para>
/// </summary>
public sealed class PredictalyticsOptions
{
private const string FileName = "settings.json";
// ─── Webserver ───
[Category("Webserver")]
[DisplayName("Port")]
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
[DefaultValue(5000)]
public int WebserverPort { get; set; } = 5000;
[Category("Webserver")]
[DisplayName("Bind-Adresse")]
[Description("Netzwerkschnittstelle, auf der Kestrel lauscht. 'localhost' = nur lokal (Standard), " +
"'0.0.0.0' = von außen erreichbar. Bei 0.0.0.0 muss die API abgesichert werden!")]
[DefaultValue("localhost")]
public string WebserverHost { get; set; } = "localhost";
[Category("Webserver")]
[DisplayName("Database Debug")]
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
[DefaultValue(false)]
public bool DbConnectionDebug { get; set; } = false;
[Category("Webserver")]
[DisplayName("API-Token (Steuerbefehle)")]
[Description("Schützt die schreibenden Endpunkte (Jobs, Trader-Steuerung, Dev). Aufrufer müssen dann " +
"den Header X-Predictalytics-Key mitschicken. Bei einer Bind-Adresse außerhalb von localhost " +
"ist der Token Pflicht — ohne ihn werden die Steuerbefehle gar nicht erst bereitgestellt.")]
[PasswordPropertyText(true)]
public string ApiControlToken { get; set; } = "";
/// <summary>
/// Ob die Bind-Adresse den Dienst ueber das Netz erreichbar macht. Bei localhost
/// uebernimmt das Betriebssystem die Absicherung, sonst nicht mehr.
/// </summary>
[Browsable(false)]
[JsonIgnore]
public bool IsPubliclyBound
{
get
{
var host = (WebserverHost ?? "").Trim();
return host.Length > 0
&& !host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
&& host != "127.0.0.1"
&& host != "::1";
}
}
// ─── Egress ───
private string _egressChannelsText = "";
[Category("Egress (Proxy/IP)")]
[DisplayName("Egress Channels")]
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). " +
"Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
// ─── Deployment Center ───
[Category("Deployment Center")]
[DisplayName("Server URL")]
[Description("Basis-URL des Deployment Centers. Fest einkompiliert — ein einstellbarer Endpoint würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu zeigen.")]
[ReadOnly(true)]
[JsonIgnore]
public string DcServerUrl => DcConfig.BaseUrl;
[Category("Deployment Center")]
[DisplayName("API Token")]
[Description("Token des Deployment Centers (Authorization: Bearer). Benötigte Rechte: 'watchdog:ping' für Heartbeats, 'bugtracker:report' für das Fehler-Reporting. Ohne Token werden weder Heartbeats noch Fehler gemeldet.")]
[PasswordPropertyText(true)]
public string DcToken { get; set; } = "";
[Category("Deployment Center")]
[DisplayName("Heartbeat aktiv")]
[Description("Sendet periodische Heartbeats an den Watchdog des Deployment Centers (Dead-Man's-Switch). Benötigt ein Token.")]
[DefaultValue(true)]
public bool DcHeartbeatEnabled { get; set; } = true;
[Category("Deployment Center")]
[DisplayName("Monitor Source")]
[Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
[DefaultValue("Predictalytics")]
public string DcSource { get; set; } = "Predictalytics";
[Category("Deployment Center")]
[DisplayName("Monitor Instance")]
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
[DefaultValue("default")]
public string DcInstance { get; set; } = "default";
[Category("Deployment Center")]
[DisplayName("Heartbeat-Intervall (Sekunden)")]
[Description("Sende-Takt der Heartbeats. Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'.")]
[DefaultValue(60)]
public int DcHeartbeatIntervalSeconds { get; set; } = 60;
[Category("Deployment Center")]
[DisplayName("Fehler melden")]
[Description("Meldet Laufzeitfehler (Error/Fatal) an den Fehler-Stream des Deployment Centers. Gleiche Fehler werden dort gruppiert und hochgezählt.")]
[DefaultValue(true)]
public bool DcErrorReportingEnabled { get; set; } = true;
[Category("Deployment Center")]
[DisplayName("Update-Prüfung beim Start")]
[Description("Prüft beim Start, ob im gewählten Kanal ein neueres Release vorliegt. Installiert wird nichts automatisch.")]
[DefaultValue(true)]
public bool DcUpdateCheckEnabled { get; set; } = true;
[Category("Deployment Center")]
[DisplayName("Update-Kanal")]
[Description("prod, beta oder dev.")]
[DefaultValue("prod")]
public string DcUpdateChannel { get; set; } = "prod";
// ─── Database ───
private string _dbServer = "localhost";
private string _dbName = "";
private string _dbUser = "";
private string _dbPassword = "";
[Category("Database")]
[DisplayName("Server")]
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
[Category("Database")]
[DisplayName("Database")]
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
[Category("Database")]
[DisplayName("User")]
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
[Category("Database")]
[DisplayName("Password")]
[PasswordPropertyText(true)]
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
[Category("Database")]
[DisplayName("SSL Mode")]
[Description("Verschlüsselung der MySQL-Verbindung. 'None' = unverschlüsselt (bisheriges Verhalten), " +
"'Preferred' = verschlüsselt wenn der Server es unterstützt. Bei entfernten Servern 'Preferred' empfohlen.")]
[DefaultValue(MySqlConnector.MySqlSslMode.None)]
public MySqlConnector.MySqlSslMode DbSslMode { get; set; } = MySqlConnector.MySqlSslMode.None;
[Browsable(false)]
[JsonIgnore]
public string ConnectionString
{
get
{
var builder = new MySqlConnector.MySqlConnectionStringBuilder
{
Server = DbServer?.Trim(),
Database = DbName?.Trim(),
UserID = DbUser?.Trim(),
Password = DbPassword?.Trim(),
AllowPublicKeyRetrieval = true,
SslMode = DbSslMode,
Pooling = true,
MinimumPoolSize = 0,
MaximumPoolSize = 100
};
return builder.ConnectionString;
}
}
/// <summary>Kestrel-Bind-URL aus Host und Port.</summary>
[Browsable(false)]
[JsonIgnore]
public string WebserverUrl =>
$"http://{(string.IsNullOrWhiteSpace(WebserverHost) ? "localhost" : WebserverHost.Trim())}:{WebserverPort}";
// ─── Persistenz ───
/// <summary>
/// Ablageort der Einstellungen: %APPDATA%\Predictalytics unter Windows,
/// ~/.config/Predictalytics unter Linux. Bewusst nicht neben der Programmdatei —
/// deren Verzeichnis ist unter Linux ueblicherweise nicht beschreibbar.
/// </summary>
public static string SettingsDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Predictalytics");
public static string SettingsFilePath => Path.Combine(SettingsDirectory, FileName);
/// <summary>Alter Ablageort neben der Programmdatei (bis einschliesslich der WinForms-Fassung).</summary>
private static string LegacySettingsFilePath => Path.Combine(AppContext.BaseDirectory, FileName);
public static PredictalyticsOptions Load()
{
// Neuer Ort hat Vorrang; sonst einmalig aus dem alten uebernehmen, damit
// bestehende Zugangsdaten beim Umstieg nicht verloren gehen.
var path = File.Exists(SettingsFilePath) ? SettingsFilePath
: File.Exists(LegacySettingsFilePath) ? LegacySettingsFilePath
: null;
if (path == null)
{
var fresh = new PredictalyticsOptions();
fresh.Save();
return fresh;
}
try
{
var options = JsonSerializer.Deserialize<PredictalyticsOptions>(File.ReadAllText(path))
?? new PredictalyticsOptions();
if (path == LegacySettingsFilePath) options.Save(); // Migration festschreiben
return options;
}
catch
{
return new PredictalyticsOptions();
}
}
public void Save()
{
Directory.CreateDirectory(SettingsDirectory);
File.WriteAllText(SettingsFilePath,
JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
}
@@ -1,33 +0,0 @@
using System.Globalization;
namespace Predictalytics.Hosting;
/// <summary>
/// Laufzeitvorgaben, die vor allem anderen gelten muessen — unabhaengig davon,
/// ob die Anwendung mit Oberflaeche oder headless startet.
/// </summary>
public static class RuntimeSetup
{
/// <summary>
/// Legt die invariante Kultur fuer alle Threads fest.
/// <para>
/// Die Anwendung verarbeitet ausschliesslich Maschinendaten: Preise, Groessen und
/// Zeitstempel der Plattform-APIs sowie SQL. Wuerde die Systemlocale durchschlagen,
/// haette derselbe Datensatz auf einem deutschen und einem englischen System
/// unterschiedliche Werte — unter Linux faellt das besonders leicht auf, weil dort
/// die Locale ueblicherweise ausdruecklich gesetzt wird.
/// </para>
/// <para>
/// Bewusst nicht ueber <c>InvariantGlobalization</c> im Projekt: das aendert
/// zusaetzlich die Semantik von Zeichenkettenvergleichen und waere ein groesserer
/// Eingriff als noetig.
/// </para>
/// </summary>
public static void UseInvariantCulture()
{
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
}
}
@@ -231,16 +231,13 @@ public class MarketRepository : IMarketRepository
} }
/// <summary> /// <summary>
/// Kuerzt die Textfelder eines Events auf die im Modell konfigurierten Laengen. /// Caps every length-constrained Event string to its column width.
/// Ohne das bricht der gesamte Speichervorgang mit /// Must cover ALL such fields: MySQL rejects the whole batch with
/// "Data too long for column 'Description'" ab und der Marktabgleich endet /// "Data too long for column ..." if a single one overflows, so one long
/// mitten im Durchlauf — Polymarket liefert Beschreibungen weit ueber 4096 Zeichen. /// description aborts the entire market sync — not just that one event.
/// <para> /// Keep in sync with the HasMaxLength calls in AppDbContext.
/// Muss <b>jedes</b> laengenbeschraenkte Feld abdecken und mit den HasMaxLength-Aufrufen
/// in AppDbContext synchron bleiben: ein einziges zu langes Feld kippt den ganzen Batch.
/// </para>
/// </summary> /// </summary>
private static void TruncateEventStrings(Event ev) private void TruncateEventStrings(Event ev)
{ {
ev.Slug = StringHelper.Truncate(ev.Slug, 512) ?? ""; ev.Slug = StringHelper.Truncate(ev.Slug, 512) ?? "";
ev.Title = StringHelper.Truncate(ev.Title, 1024) ?? ""; ev.Title = StringHelper.Truncate(ev.Title, 1024) ?? "";
@@ -71,10 +71,7 @@ public class TradeRepository : ITradeRepository
foreach (var chunk in tradeList.Chunk(500)) foreach (var chunk in tradeList.Chunk(500))
{ {
var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount, UsdcSize, OutcomeIndex) VALUES "); var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount, UsdcSize, OutcomeIndex) VALUES ");
// object? statt object: TransactionHash, MarketOutcomeId, DbMarketId, var parameters = new List<object>();
// AggregatedCount, UsdcSize und OutcomeIndex sind nullable und sollen als
// SQL-NULL durchgereicht werden.
var parameters = new List<object?>();
for (int i = 0; i < chunk.Length; i++) for (int i = 0; i < chunk.Length; i++)
{ {
@@ -114,7 +111,7 @@ public class TradeRepository : ITradeRepository
{ {
try try
{ {
var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray()!, ct); var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct);
_logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted); _logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted);
break; break;
} }
@@ -121,7 +121,7 @@ public static class DependencyInjection
/// <summary> /// <summary>
/// Applies pending EF Core migrations and seeds default platform rows. /// Applies pending EF Core migrations and seeds default platform rows.
/// Requires the target database to already be "stamped" with the InitialBaseline /// Requires the target database to already be "stamped" with the InitialBaseline
/// migration in __EFMigrationsHistory (see docs/archiv/UMSETZUNGSPLAN.md, B1) if it was /// migration in __EFMigrationsHistory (see UMSETZUNGSPLAN.md, section B1) if it was
/// previously created via the old EnsureCreated + manual ALTER approach. /// previously created via the old EnsureCreated + manual ALTER approach.
/// </summary> /// </summary>
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false) public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
@@ -1,34 +0,0 @@
using System.Globalization;
namespace Predictalytics.Infrastructure.Helpers;
/// <summary>
/// Parses timestamp strings from platform APIs as UTC.
/// <para>
/// The platforms deliver ISO-8601 strings; the domain stores UTC throughout — every call
/// site falls back to <see cref="DateTime.UtcNow"/>, and the neighbouring DbCreatedAt is
/// always UTC. A plain <c>DateTime.TryParse</c> breaks that in two ways: it honours the
/// machine's locale, and it converts a "Z"-suffixed timestamp to <em>local</em> time. The
/// same API payload would therefore land in the database differently on a Berlin
/// workstation than on a UTC server.
/// </para>
/// </summary>
public static class DateParsing
{
private const DateTimeStyles UtcStyles =
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal;
/// <summary>
/// Parses an API timestamp as UTC. Returns null when absent or unparseable.
/// Timestamps without a zone designator are treated as UTC.
/// </summary>
public static DateTime? ParseUtc(string? value) =>
DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var parsed)
? parsed
: null;
/// <summary>
/// Parses an API timestamp as UTC, falling back to the current UTC time.
/// </summary>
public static DateTime ParseUtcOrNow(string? value) => ParseUtc(value) ?? DateTime.UtcNow;
}
@@ -1,4 +1,4 @@
using Serilog.Core; using Serilog.Core;
using Serilog.Events; using Serilog.Events;
namespace Predictalytics.Infrastructure.Logging; namespace Predictalytics.Infrastructure.Logging;
@@ -7,11 +7,11 @@ namespace Predictalytics.Infrastructure.Logging;
/// Custom Serilog sink that delegates log writes to a provided action. /// Custom Serilog sink that delegates log writes to a provided action.
/// The action is responsible for marshaling to the correct thread (e.g. UI thread). /// The action is responsible for marshaling to the correct thread (e.g. UI thread).
/// </summary> /// </summary>
public class DelegateSink : ILogEventSink public class RichTextBoxSink : ILogEventSink
{ {
private readonly Action<string, LogEventLevel> _writeAction; private readonly Action<string, LogEventLevel> _writeAction;
public DelegateSink(Action<string, LogEventLevel> writeAction) public RichTextBoxSink(Action<string, LogEventLevel> writeAction)
{ {
_writeAction = writeAction; _writeAction = writeAction;
} }
@@ -1,21 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Infrastructure</RootNamespace> <RootNamespace>Predictalytics.Infrastructure</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Serilog" /> <PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" /> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,8 +1,6 @@
using System.Globalization;
using Predictalytics.Domain.Entities; using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums; using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Helpers;
using Predictalytics.Infrastructure.Logging; using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -237,7 +235,7 @@ public class LimitlessProvider : IPlatformProvider
Price = (decimal)(e.Price ?? 0), Price = (decimal)(e.Price ?? 0),
Size = (decimal)(e.Size ?? 0), Size = (decimal)(e.Size ?? 0),
Amount = (decimal)(e.Price * e.Size ?? 0), Amount = (decimal)(e.Price * e.Size ?? 0),
ExecutedAt = DateParsing.ParseUtcOrNow(e.CreatedAt), ExecutedAt = DateTime.TryParse(e.CreatedAt, out var dt) ? dt : DateTime.UtcNow,
TransactionHash = e.TxHash, TransactionHash = e.TxHash,
TransientWallet = wallet, TransientWallet = wallet,
}; };
@@ -267,12 +265,11 @@ public class LimitlessProvider : IPlatformProvider
Category = catMap.Category, Category = catMap.Category,
Subcategory = catMap.Subcategory, Subcategory = catMap.Subcategory,
ImageUrl = raw.ImageUrl ?? "", ImageUrl = raw.ImageUrl ?? "",
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
NumberStyles.Any, CultureInfo.InvariantCulture, out var vol) ? vol : 0,
Liquidity = (decimal)(raw.Liquidity ?? 0), Liquidity = (decimal)(raw.Liquidity ?? 0),
EndDate = (raw.ExpirationTimestamp.HasValue && raw.ExpirationTimestamp.Value > 0 && raw.ExpirationTimestamp.Value < 253402300799) EndDate = (raw.ExpirationTimestamp.HasValue && raw.ExpirationTimestamp.Value > 0 && raw.ExpirationTimestamp.Value < 253402300799)
? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime ? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime
: DateParsing.ParseUtc(raw.ExpirationDate), : (DateTime.TryParse(raw.ExpirationDate, out var ed) ? ed : null),
IsResolved = raw.Closed, IsResolved = raw.Closed,
LastUpdatedAt = DateTime.UtcNow LastUpdatedAt = DateTime.UtcNow
}; };
@@ -18,13 +18,6 @@ public class OpenRouterApiClient : IOpenRouterApiClient
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly ILogger<OpenRouterApiClient> _logger; private readonly ILogger<OpenRouterApiClient> _logger;
/// <summary>
/// Ohne Schluessel wird gar nicht erst gesendet. Sonst quittiert OpenRouter jeden
/// Aufruf mit 401, das landet als Error im Fehler-Stream des Deployment Centers und
/// der Aufrufer versucht anschliessend, die Fehlermeldung als JSON zu lesen.
/// </summary>
private readonly bool _configured;
public OpenRouterApiClient(HttpClient httpClient, IConfiguration config, ILogger<OpenRouterApiClient> logger) public OpenRouterApiClient(HttpClient httpClient, IConfiguration config, ILogger<OpenRouterApiClient> logger)
{ {
_httpClient = httpClient; _httpClient = httpClient;
@@ -35,17 +28,10 @@ public class OpenRouterApiClient : IOpenRouterApiClient
var apiKey = _config["OpenRouter:ApiKey"]; var apiKey = _config["OpenRouter:ApiKey"];
_httpClient.BaseAddress = new Uri(baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/"); _httpClient.BaseAddress = new Uri(baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/");
_configured = !string.IsNullOrWhiteSpace(apiKey); if (!string.IsNullOrEmpty(apiKey))
if (_configured)
{ {
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
} }
else
{
// Warning, nicht Error: eine fehlende KI-Anbindung ist ein Konfigurationszustand,
// kein Stoerfall - die uebrige Analyse laeuft vollstaendig ohne sie.
_logger.LogWarning("OpenRouter ist nicht konfiguriert (OpenRouter:ApiKey leer) - die KI-Strategieanalyse bleibt aus. Schluessel ueber die Umgebungsvariable OpenRouter__ApiKey setzen.");
}
// OpenRouter recommends adding a referer and title for ranking // OpenRouter recommends adding a referer and title for ranking
_httpClient.DefaultRequestHeaders.Add("HTTP-Referer", "http://localhost"); _httpClient.DefaultRequestHeaders.Add("HTTP-Referer", "http://localhost");
_httpClient.DefaultRequestHeaders.Add("X-Title", "Predictalytics"); _httpClient.DefaultRequestHeaders.Add("X-Title", "Predictalytics");
@@ -53,8 +39,6 @@ public class OpenRouterApiClient : IOpenRouterApiClient
public async Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default) public async Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default)
{ {
if (!_configured) return IOpenRouterApiClient.NotConfigured;
var model = useManualModel var model = useManualModel
? _config["OpenRouter:ManualAnalysisModel"] ?? "anthropic/claude-3-opus" ? _config["OpenRouter:ManualAnalysisModel"] ?? "anthropic/claude-3-opus"
: _config["OpenRouter:DefaultModel"] ?? "google/gemini-flash-1.5"; : _config["OpenRouter:DefaultModel"] ?? "google/gemini-flash-1.5";
@@ -2,7 +2,6 @@ using System.Text.Json;
using Predictalytics.Domain.Entities; using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums; using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Helpers;
using Predictalytics.Infrastructure.Logging; using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -217,9 +216,9 @@ public class PolymarketProvider : IPlatformProvider
Title = rawEv.Title, Title = rawEv.Title,
Description = rawEv.Description, Description = rawEv.Description,
ImageUrl = rawEv.Image, ImageUrl = rawEv.Image,
StartDate = DateParsing.ParseUtc(rawEv.StartDate), StartDate = DateTime.TryParse(rawEv.StartDate, out var esd) ? esd : null,
EndDate = DateParsing.ParseUtc(rawEv.EndDate), EndDate = DateTime.TryParse(rawEv.EndDate, out var eed) ? eed : null,
CreatedAt = DateParsing.ParseUtcOrNow(rawEv.CreatedAt), CreatedAt = DateTime.TryParse(rawEv.CreatedAt, out var ecd) ? ecd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow, DbCreatedAt = DateTime.UtcNow,
IsActive = rawEv.Active, IsActive = rawEv.Active,
IsClosed = rawEv.Closed, IsClosed = rawEv.Closed,
@@ -313,9 +312,9 @@ public class PolymarketProvider : IPlatformProvider
Title = rawEvent.Title, Title = rawEvent.Title,
Description = rawEvent.Description, Description = rawEvent.Description,
ImageUrl = rawEvent.Image, ImageUrl = rawEvent.Image,
StartDate = DateParsing.ParseUtc(rawEvent.StartDate), StartDate = DateTime.TryParse(rawEvent.StartDate, out var sd) ? sd : null,
EndDate = DateParsing.ParseUtc(rawEvent.EndDate), EndDate = DateTime.TryParse(rawEvent.EndDate, out var ed) ? ed : null,
CreatedAt = DateParsing.ParseUtcOrNow(rawEvent.CreatedAt), CreatedAt = DateTime.TryParse(rawEvent.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow, DbCreatedAt = DateTime.UtcNow,
IsActive = rawEvent.Active, IsActive = rawEvent.Active,
IsClosed = rawEvent.Closed, IsClosed = rawEvent.Closed,
@@ -354,20 +353,20 @@ public class PolymarketProvider : IPlatformProvider
MarketSlug = raw.Slug, MarketSlug = raw.Slug,
Description = raw.Description, Description = raw.Description,
ImageUrl = raw.Image, ImageUrl = raw.Image,
Question = raw.Question ?? "", Question = raw.Question,
Category = catMap.Category, Category = catMap.Category,
Subcategory = catMap.Subcategory, Subcategory = catMap.Subcategory,
Volume = (decimal)raw.Volume, Volume = (decimal)raw.Volume,
Volume24h = (decimal)raw.Volume24hr, Volume24h = (decimal)raw.Volume24hr,
Liquidity = (decimal)raw.Liquidity, Liquidity = (decimal)raw.Liquidity,
StartDate = DateParsing.ParseUtc(raw.StartDate), StartDate = DateTime.TryParse(raw.StartDate, out var msd) ? msd : null,
EndDate = DateParsing.ParseUtc(raw.EndDate ?? raw.EndDateIso), EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null,
CreatedAt = DateParsing.ParseUtcOrNow(raw.CreatedAt), CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow, DbCreatedAt = DateTime.UtcNow,
LastUpdatedAt = DateTime.UtcNow, LastUpdatedAt = DateTime.UtcNow,
FeeRateBps = (decimal)(raw.TakerFee * 10000), FeeRateBps = (decimal)(raw.TakerFee * 10000),
IsNegRisk = raw.NegRisk, IsNegRisk = raw.NegRisk,
ClosedAt = DateParsing.ParseUtc(raw.ClosedTime) ClosedAt = DateTime.TryParse(raw.ClosedTime, out var mct) ? mct : null
}; };
// Parse outcomes, prices, and token IDs from JSON strings // Parse outcomes, prices, and token IDs from JSON strings
@@ -73,10 +73,6 @@ public class EgressPoolService : IEgressPoolService, IDisposable
handler.ConnectCallback = async (context, cancellationToken) => handler.ConnectCallback = async (context, cancellationToken) =>
{ {
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp); var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
// Linux ist hier strenger als Windows: die Adresse muss tatsaechlich
// auf einem Interface konfiguriert sein, sonst schlaegt Bind mit
// EADDRNOTAVAIL fehl. Fuer nicht-lokale Adressen braeuchte es
// IP_FREEBIND bzw. CAP_NET_ADMIN.
socket.Bind(new IPEndPoint(ip, 0)); socket.Bind(new IPEndPoint(ip, 0));
try try
{ {
@@ -128,13 +128,7 @@ public class FingerprintSnapshotService : IFingerprintSnapshotService
var total = perfs.Sum(p => p.TotalVolume); var total = perfs.Sum(p => p.TotalVolume);
if (total <= 0) return null; if (total <= 0) return null;
// Je Kategorie gibt es eine Zeile pro Unterkategorie (eindeutiger Index var mix = perfs.ToDictionary(p => p.Category.ToString(), p => p.TotalVolume / total);
// TraderId+Category+Subcategory). Ein ToDictionary auf die Kategorie allein
// wirft deshalb bei jedem Trader, der in zwei Unterkategorien derselben
// Kategorie gehandelt hat ("Economy/Inflation" und "Economy/Jobs").
var mix = perfs
.GroupBy(p => p.Category.ToString())
.ToDictionary(g => g.Key, g => g.Sum(p => p.TotalVolume) / total);
return JsonSerializer.Serialize(mix); return JsonSerializer.Serialize(mix);
} }
} }
-10
View File
@@ -1,10 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Predictalytics.Shell.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
-125
View File
@@ -1,125 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Predictalytics.Hosting;
using Predictalytics.Shell.ViewModels;
using Predictalytics.Shell.Views;
using Serilog;
namespace Predictalytics.Shell;
/// <summary>
/// Avalonia-Anwendungsobjekt.
/// <para>
/// Die Basisklasse wird voll qualifiziert angegeben: innerhalb von
/// <c>Predictalytics.*</c> loest der kurze Name <c>Application</c> auf den
/// Namespace <c>Predictalytics.Application</c> auf, nicht auf den Avalonia-Typ.
/// </para>
/// </summary>
public partial class App : global::Avalonia.Application
{
private IDisposable? _licenseWatch;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Das Lizenzfenster darf beim Schliessen nicht die Anwendung beenden —
// danach soll ja erst das Hauptfenster kommen.
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) => _licenseWatch?.Dispose();
// Nicht awaiten: OnFrameworkInitializationCompleted muss zurueckkehren,
// damit die Nachrichtenschleife anlaufen kann.
Dispatcher.UIThread.Post(async () => await StartupAsync(desktop));
}
base.OnFrameworkInitializationCompleted();
}
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
RuntimeSetup.UseInvariantCulture();
// Einstellungen zuerst: das Fehler-Reporting braucht das Token, bevor die
// Lizenzschranke laeuft — sonst taucht eine fehlgeschlagene Aktivierung
// nirgends im Fehler-Stream auf.
var options = PredictalyticsOptions.Load();
var viewModel = new MainWindowViewModel(options);
var window = new MainWindow(viewModel);
// Serilog-Aufbau liegt in Predictalytics.Hosting; hier kommt nur der
// Terminal-Sink dazu, der selbst auf den UI-Thread marshallt.
LoggingSetup.Configure(viewModel.AppendLog);
// ─── Deployment Center: Fehler-Stream und globale Ausnahmebehandlung ───
DcErrorReporter.Configure(options.DcToken, options.DcErrorReportingEnabled);
DcErrorReporter.InstallGlobalHandlers();
InstallUiThreadHandler();
// Ohne das erschiene jede Aktivierung in der Lizenzliste als "1.0.0".
Deploymentcenter.Client.LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
LoggingSetup.LogStartupBanner();
// ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
var client = LicenseGuard.CreateClient();
var check = await LicenseGuard.TryUseCachedAsync(client);
var session = check.Session;
if (session is null)
{
var licenseWindow = new LicenseWindow(client, check.LastResult);
desktop.MainWindow = licenseWindow;
licenseWindow.Show();
session = await licenseWindow.Completion;
if (session is null)
{
desktop.Shutdown();
return;
}
}
viewModel.License = session;
// ─── Hauptfenster ───
desktop.MainWindow = window;
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
window.Show();
viewModel.Start();
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(session, unusable =>
Dispatcher.UIThread.Post(async () =>
{
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
$"Die Lizenz ist nicht mehr gültig ({unusable.Status}):\n{unusable.Message}\n\nPredictalytics wird beendet.");
desktop.Shutdown();
}));
desktop.Exit += (_, _) =>
{
Log.Information("Application shutting down.");
Log.CloseAndFlush();
};
}
/// <summary>
/// Gegenstueck zu <c>Application.ThreadException</c> in WinForms: eine im UI-Thread
/// durchgereichte Ausnahme wuerde den Prozess sonst spurlos beenden.
/// </summary>
private static void InstallUiThreadHandler()
{
Dispatcher.UIThread.UnhandledException += (_, e) =>
{
DcErrorReporter.ReportUiThreadException(e.Exception);
e.Handled = true;
};
}
}
@@ -1,38 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- WinExe: unter Windows kein Konsolenfenster hinter der GUI. Unter Linux
ist WinExe gleichbedeutend mit Exe. Fuer den Headless-Modus wird unter
Windows die Elternkonsole angehaengt (siehe ConsoleAttach). -->
<OutputType>WinExe</OutputType>
<RootNamespace>Predictalytics.Shell</RootNamespace>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Serilog" />
</ItemGroup>
<ItemGroup>
<!-- Api, Worker, Infrastructure und Deploymentcenter.Client kommen transitiv ueber Hosting. -->
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Die WebUI wird neben die Programmdatei kopiert, damit PredictalyticsHost sie
ueber AppContext.BaseDirectory findet. -->
<Content Include="..\Predictalytics.Api\wwwroot\**"
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
<None Include="run.sh" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
-35
View File
@@ -1,35 +0,0 @@
using Avalonia;
using Predictalytics.Shell.Services;
namespace Predictalytics.Shell;
internal static class Program
{
[STAThread]
public static int Main(string[] args)
{
// Die Argumentauswertung steht bewusst vor jeder Avalonia-Initialisierung:
// auf einem Server ohne X11 wuerde der Prozess sonst beim Aufbau des
// Fenstersystems sterben, bevor die Schalter ueberhaupt greifen.
if (LicenseCli.IsLicenseCommand(args))
{
ConsoleAttach.TryAttachToParent();
return LicenseCli.RunAsync(args).GetAwaiter().GetResult();
}
if (args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase)))
{
ConsoleAttach.TryAttachToParent();
return HeadlessRunner.RunAsync().GetAwaiter().GetResult();
}
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
/// <summary>Wird auch vom Avalonia-Designer verwendet.</summary>
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
@@ -1,39 +0,0 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
namespace Predictalytics.Shell.Services;
/// <summary>
/// Konsolen-Vorbereitung fuer den Headless-Modus.
/// <para>
/// Das Projekt ist als WinExe gebaut, damit unter Windows kein Konsolenfenster
/// hinter der GUI steht. Im Headless-Modus fehlt dadurch aber die Ausgabe, wenn
/// aus einer Eingabeaufforderung gestartet wird — <see cref="TryAttachToParent"/>
/// haengt den Prozess an die Konsole des aufrufenden Prozesses an.
/// </para>
/// <para>Unter Linux ist WinExe gleichbedeutend mit Exe; dort entfaellt das Anhaengen.</para>
/// </summary>
internal static class ConsoleAttach
{
private const int AttachParentProcess = -1;
public static void TryAttachToParent()
{
if (OperatingSystem.IsWindows())
{
try { AttachConsole(AttachParentProcess); }
catch { /* ohne Konsole bleiben die Logdateien */ }
}
// Die Logausgabe enthaelt Rahmenzeichen und Emoji. Linux-Terminals sind
// ohnehin UTF-8, die Windows-Konsole laeuft ohne das hier auf der
// ANSI-Codepage und zeigt nur Fragezeichen.
try { Console.OutputEncoding = Encoding.UTF8; }
catch { /* keine Konsole angehaengt — irrelevant */ }
}
[SupportedOSPlatform("windows")]
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AttachConsole(int dwProcessId);
}
@@ -1,207 +0,0 @@
using System.Runtime.InteropServices;
using Predictalytics.Hosting;
using Serilog;
namespace Predictalytics.Shell.Services;
/// <summary>
/// Betrieb ohne Oberflaeche — fuer Linux-Server ohne Desktop-Session
/// (systemd, Container). Startet Webserver und Worker und laeuft, bis
/// SIGTERM oder Strg+C eintrifft.
/// </summary>
internal static class HeadlessRunner
{
/// <summary>Umgebungsvariable fuer die Aktivierung ohne Dialog.</summary>
private const string LicenseKeyVariable = "PREDICTALYTICS_LICENSE_KEY";
public const int ExitOk = 0;
public const int ExitNoLicense = 2;
public const int ExitFailed = 3;
public static async Task<int> RunAsync()
{
RuntimeSetup.UseInvariantCulture();
var options = PredictalyticsOptions.Load();
// Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
LoggingSetup.Configure();
DcErrorReporter.Configure(options.DcToken, options.DcErrorReportingEnabled);
DcErrorReporter.InstallGlobalHandlers();
Deploymentcenter.Client.LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
LoggingSetup.LogStartupBanner();
Log.Information("Headless-Modus. Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
DcHeartbeatService? heartbeat = null;
try
{
var session = await EnsureLicensedAsync();
if (session is null) return ExitNoLicense;
var host = new PredictalyticsHost(options);
using var cts = new CancellationTokenSource();
using var shutdownSignals = RegisterShutdown(cts);
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(session, result =>
{
Log.Fatal("Lizenz nicht mehr gültig ({Status}): {Message} — Dienst wird beendet.",
result.Status, result.Message);
cts.Cancel();
});
heartbeat = StartHeartbeat(options, host);
await host.StartWebServerAsync();
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
// Laeuft, bis abgebrochen wird.
await host.StartWorkersAsync(cts.Token);
heartbeat?.NotifyStopping();
await host.StopWebServerAsync();
Log.Information("Dienst planmäßig beendet.");
return ExitOk;
}
catch (OperationCanceledException)
{
heartbeat?.NotifyStopping();
Log.Information("Dienst planmäßig beendet.");
return ExitOk;
}
catch (Exception ex)
{
Log.Fatal(ex, "Headless-Betrieb abgebrochen.");
return ExitFailed;
}
finally
{
heartbeat?.Dispose();
await Log.CloseAndFlushAsync();
}
}
/// <summary>
/// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
/// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
/// </summary>
private static async Task<LicenseSession?> EnsureLicensedAsync()
{
var client = LicenseGuard.CreateClient();
var check = await LicenseGuard.TryUseCachedAsync(client);
if (check.Session is not null) return check.Session;
var key = Environment.GetEnvironmentVariable(LicenseKeyVariable);
if (string.IsNullOrWhiteSpace(key))
{
Log.Fatal("Keine nutzbare Lizenz ({Status}) und {Variable} ist nicht gesetzt. " +
"Im Headless-Betrieb gibt es keinen Aktivierungsdialog — bitte den Lizenzschlüssel " +
"über die Umgebungsvariable bereitstellen oder einmalig mit --license-set-key aktivieren.",
check.LastResult?.Status ?? "kein Schlüssel hinterlegt", LicenseKeyVariable);
return null;
}
Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
var (session, result) = await LicenseGuard.ActivateAsync(client, key);
if (session is not null)
{
Log.Information("Lizenz aktiviert ({Status}).", result.Status);
return session;
}
Log.Fatal("Aktivierung fehlgeschlagen ({Status}): {Message}", result.Status, result.Message);
return null;
}
private static DcHeartbeatService? StartHeartbeat(PredictalyticsOptions options, PredictalyticsHost host)
{
if (!options.DcHeartbeatEnabled) return null;
if (string.IsNullOrWhiteSpace(options.DcToken))
{
Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token.");
return null;
}
var heartbeat = new DcHeartbeatService(
options.DcToken,
options.DcSource,
options.DcInstance,
options.DcHeartbeatIntervalSeconds,
ct => CollectSnapshotAsync(options, host, ct));
heartbeat.Start();
return heartbeat;
}
private static async Task<DcHeartbeatSnapshot> CollectSnapshotAsync(
PredictalyticsOptions options, PredictalyticsHost host, CancellationToken ct)
{
var snapshot = new DcHeartbeatSnapshot
{
Message = host.WorkersRunning ? "Worker laufen" : "Worker gestoppt"
};
snapshot.Metrics["workers_running"] = host.WorkersRunning ? 1 : 0;
snapshot.Metrics["webserver_running"] = host.WebServerRunning ? 1 : 0;
snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1);
if (!string.IsNullOrWhiteSpace(options.DbName))
{
var started = System.Diagnostics.Stopwatch.StartNew();
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
await using var conn = new MySqlConnector.MySqlConnection(options.ConnectionString);
await conn.OpenAsync(timeout.Token);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync(timeout.Token);
snapshot.Checks["db"] = new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds);
}
catch (Exception ex)
{
snapshot.Checks["db"] = new DcCheck(false, ex.Message);
}
}
return snapshot;
}
/// <summary>
/// SIGTERM (systemd stop), SIGINT und Strg+C sauber behandeln, damit der Dienst
/// nicht hart abgeschossen wird.
/// </summary>
private static IDisposable RegisterShutdown(CancellationTokenSource cts)
{
var registrations = new List<IDisposable>();
void Handle(PosixSignalContext context)
{
context.Cancel = true; // Standardverhalten (sofortiges Beenden) unterdruecken
Log.Information("Signal {Signal} empfangen — fahre herunter…", context.Signal);
cts.Cancel();
}
registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGTERM, Handle));
registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGINT, Handle));
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
return new CompositeDisposable(registrations);
}
private sealed class CompositeDisposable(List<IDisposable> items) : IDisposable
{
public void Dispose()
{
foreach (var item in items)
{
try { item.Dispose(); } catch { /* beendet sich ohnehin */ }
}
}
}
}
@@ -1,123 +0,0 @@
using Deploymentcenter.Client;
using Predictalytics.Hosting;
namespace Predictalytics.Shell.Services;
/// <summary>
/// Lizenzverwaltung von der Kommandozeile — die im Deploymentcenter-Integrations-
/// leitfaden vorgesehenen Schalter. Gedacht fuer Server ohne Oberflaeche, wo es
/// keinen Aktivierungsdialog geben kann.
/// </summary>
internal static class LicenseCli
{
public const string StatusSwitch = "--license-status";
public const string SetKeySwitch = "--license-set-key";
public const string DeactivateSwitch = "--license-deactivate";
public static bool IsLicenseCommand(string[] args) =>
args.Length > 0 && args[0] is StatusSwitch or SetKeySwitch or DeactivateSwitch;
public static async Task<int> RunAsync(string[] args)
{
LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
return args[0] switch
{
StatusSwitch => await ShowStatusAsync(),
SetKeySwitch => await SetKeyAsync(args.Length > 1 ? args[1] : null),
DeactivateSwitch => await DeactivateAsync(),
_ => HeadlessRunner.ExitFailed
};
}
private static async Task<int> ShowStatusAsync()
{
var hw = LicenseGuard.GetHardwareInfo();
Console.WriteLine("Predictalytics — Lizenzstatus");
Console.WriteLine($" Produkt : {DcConfig.ProductSlug}");
Console.WriteLine($" Version : {DcConfig.AppVersion} ({DcConfig.GitCommitShort})");
Console.WriteLine($" Server : {DcConfig.BaseUrl}");
Console.WriteLine($" Hardware-ID : {hw.HardwareId}");
Console.WriteLine($" HWID-Quelle : {hw.HwidSource} (v{hw.HwidVersion}, {hw.Platform})");
Console.WriteLine($" Cache-Ablage : {LicenseGuard.StorageDirectory}");
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
if (string.IsNullOrWhiteSpace(cachedKey))
{
Console.WriteLine(" Schlüssel : keiner hinterlegt");
Console.WriteLine();
Console.WriteLine($"Aktivierung: {SetKeySwitch} <SCHLÜSSEL>");
return HeadlessRunner.ExitNoLicense;
}
Console.WriteLine($" Schlüssel : {Mask(cachedKey!)}");
Console.WriteLine();
Console.Write("Prüfe am Deployment Center… ");
var check = await LicenseGuard.TryUseCachedAsync();
var result = check.LastResult;
if (check.IsUsable)
{
Console.WriteLine($"gültig ({result!.Status}{(result.IsCached ? ", aus lokalem Cache" : "")})");
if (result.CacheExpiresAt is { } expiresAt && expiresAt > 0)
{
Console.WriteLine($" Offline-Gnadenfrist bis: {DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime:yyyy-MM-dd HH:mm} UTC");
}
return HeadlessRunner.ExitOk;
}
// Transient heisst: kein Urteil, nur eine gescheiterte Verbindung.
Console.WriteLine(result?.IsTransient == true
? $"unentschieden ({result.Status}): {result.Message}"
: $"NICHT gültig ({result?.Status}): {result?.Message}");
return HeadlessRunner.ExitNoLicense;
}
private static async Task<int> SetKeyAsync(string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
Console.Error.WriteLine($"Aufruf: {SetKeySwitch} <SCHLÜSSEL>");
return HeadlessRunner.ExitFailed;
}
Console.Write($"Aktiviere {Mask(key.Trim().ToUpperInvariant())} … ");
var (session, result) = await LicenseGuard.ActivateAsync(LicenseGuard.CreateClient(), key);
if (session is not null)
{
Console.WriteLine($"erfolgreich ({result.Status})");
Console.WriteLine($"Hardware-ID: {result.HardwareId}");
return HeadlessRunner.ExitOk;
}
Console.Error.WriteLine($"fehlgeschlagen ({result.Status}): {result.Message}");
return HeadlessRunner.ExitNoLicense;
}
private static async Task<int> DeactivateAsync()
{
var key = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
if (string.IsNullOrWhiteSpace(key))
{
Console.Error.WriteLine("Kein hinterlegter Schlüssel — nichts zu deaktivieren.");
return HeadlessRunner.ExitFailed;
}
Console.Write("Gebe Aktivierungsplatz am Deployment Center frei… ");
var ok = await LicenseGuard.CreateClient()
.DeactivateAsync(DcConfig.ProductSlug, key!, DcConfig.BaseUrl);
Console.WriteLine(ok ? "erfolgreich" : "fehlgeschlagen");
return ok ? HeadlessRunner.ExitOk : HeadlessRunner.ExitFailed;
}
/// <summary>Nur die Segmentstruktur zeigen, nicht den vollstaendigen Schluessel.</summary>
private static string Mask(string key)
{
var parts = key.Split('-');
if (parts.Length < 2) return "*****";
for (var i = 1; i < parts.Length - 1; i++) parts[i] = new string('*', parts[i].Length);
return string.Join('-', parts);
}
}
@@ -1,26 +0,0 @@
using Avalonia.Media;
using Serilog.Events;
namespace Predictalytics.Shell.ViewModels;
/// <summary>Eine Zeile der Terminalanzeige samt Einfaerbung nach Loglevel.</summary>
public sealed class LogLine
{
public string Text { get; }
public IBrush Foreground { get; }
public LogLine(string text, LogEventLevel level)
{
Text = text;
Foreground = BrushFor(level);
}
// Farbschema unveraendert aus der WinForms-Fassung uebernommen.
private static IBrush BrushFor(LogEventLevel level) => level switch
{
LogEventLevel.Error or LogEventLevel.Fatal => new SolidColorBrush(Color.FromRgb(255, 82, 82)),
LogEventLevel.Warning => new SolidColorBrush(Color.FromRgb(255, 193, 7)),
LogEventLevel.Debug or LogEventLevel.Verbose => new SolidColorBrush(Color.FromRgb(158, 158, 158)),
_ => new SolidColorBrush(Color.FromRgb(76, 175, 80))
};
}
@@ -1,482 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Predictalytics.Hosting;
using Serilog;
using Serilog.Events;
namespace Predictalytics.Shell.ViewModels;
/// <summary>
/// Bindungsziel des Hauptfensters. Haelt den <see cref="PredictalyticsHost"/> und
/// uebersetzt dessen Zustand in Anzeigewerte.
/// </summary>
public sealed partial class MainWindowViewModel : ObservableObject
{
/// <summary>Obergrenze der Terminalzeilen. Aeltere werden vorne verworfen.</summary>
private const int MaxLogLines = 500;
private readonly PredictalyticsHost _host;
private CancellationTokenSource? _workerCts;
private DcHeartbeatService? _heartbeat;
private double? _lastDbSizeMb;
/// <summary>Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.</summary>
public Func<string, string, Task>? ShowInfo { get; set; }
public Func<string, string, Task>? ShowError { get; set; }
public Func<string, string, Task<bool>>? ShowConfirm { get; set; }
public PredictalyticsOptions Options { get; }
public ObservableCollection<LogLine> LogLines { get; } = [];
[ObservableProperty] private string _statusText = "";
[ObservableProperty] private string _dbSizeText = "DB Size: —";
[ObservableProperty] private string _buildVersionText = "";
[ObservableProperty] private string _serverButtonText = "▶ Start Server";
[ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
[ObservableProperty] private bool _isBusy;
/// <summary>
/// Die Lizenz, auf der dieser Lauf beruht — wird nach der Lizenzschranke gesetzt und
/// vom Menuepunkt „Lizenzstatus" gelesen. Das ViewModel entsteht bewusst vorher, damit
/// das Terminal schon waehrend der Aktivierung mitschreibt.
/// </summary>
public LicenseSession? License { get; set; }
public MainWindowViewModel(PredictalyticsOptions options)
{
Options = options;
_host = new PredictalyticsHost(Options);
_host.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus);
try
{
var buildDate = new FileInfo(GetType().Assembly.Location).LastWriteTime;
BuildVersionText = $"v{DcConfig.AppVersion} — Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
}
catch
{
BuildVersionText = $"v{DcConfig.AppVersion}";
}
UpdateStatus();
}
/// <summary>Wird nach dem Serilog-Aufbau aufgerufen.</summary>
public void Start()
{
Log.Information("Shell initialisiert. Bereit.");
Log.Information("'Start Server' beginnt Polling und Discovery.");
Log.Information("'Start Webserver' startet die WebUI auf {Url}", Options.WebserverUrl);
Log.Information("Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
_ = RefreshDbSizeAsync();
var dbSizeTimer = new DispatcherTimer { Interval = TimeSpan.FromHours(6) };
dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
dbSizeTimer.Start();
RestartHeartbeat();
if (Options.DcUpdateCheckEnabled)
{
_ = CheckForUpdatesAsync(silent: true);
}
}
/// <summary>Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.</summary>
public void AppendLog(string message, LogEventLevel level)
{
var text = message.TrimEnd('\r', '\n');
Dispatcher.UIThread.Post(() =>
{
LogLines.Add(new LogLine(text, level));
while (LogLines.Count > MaxLogLines) LogLines.RemoveAt(0);
});
}
private void UpdateStatus()
{
var workers = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
var web = _host.WebServerRunning ? $"[RUNNING] Webserver :{Options.WebserverPort}" : "[STOPPED] Webserver";
StatusText = $"{workers} | {web}";
ServerButtonText = _host.WorkersRunning ? "⏹ Stop Server" : "▶ Start Server";
WebserverButtonText = _host.WebServerRunning ? "⏹ Stop Webserver" : "▶ Start Webserver";
}
// ─── Deployment Center: Heartbeat ───
private void RestartHeartbeat()
{
_heartbeat?.Dispose();
_heartbeat = null;
if (!Options.DcHeartbeatEnabled) return;
if (string.IsNullOrWhiteSpace(Options.DcToken))
{
Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token — bitte in den Einstellungen eintragen.");
return;
}
_heartbeat = new DcHeartbeatService(
Options.DcToken,
Options.DcSource,
Options.DcInstance,
Options.DcHeartbeatIntervalSeconds,
CollectHeartbeatSnapshotAsync);
_heartbeat.Start();
}
/// <summary>
/// Assembles what this app knows about its own health. A heartbeat alone only proves that
/// a timer runs — the DB check is what shows whether the app can actually do its work.
/// </summary>
private async Task<DcHeartbeatSnapshot> CollectHeartbeatSnapshotAsync(CancellationToken ct)
{
var snapshot = new DcHeartbeatSnapshot
{
Message = _host.WorkersRunning ? "Worker laufen" : "Worker gestoppt"
};
// Deliberately no check for "workers stopped": that is a legitimate state chosen by
// the operator and would otherwise keep the monitor permanently on warning.
snapshot.Metrics["workers_running"] = _host.WorkersRunning ? 1 : 0;
snapshot.Metrics["webserver_running"] = _host.WebServerRunning ? 1 : 0;
snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1);
if (_lastDbSizeMb is { } dbSize) snapshot.Metrics["db_size_mb"] = Math.Round(dbSize, 2);
var db = await ProbeDatabaseAsync(ct);
if (db is not null) snapshot.Checks["db"] = db;
return snapshot;
}
/// <summary>SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat.</summary>
private async Task<DcCheck?> ProbeDatabaseAsync(CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(Options.DbName)) return null;
var started = System.Diagnostics.Stopwatch.StartNew();
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
await using var conn = new MySqlConnector.MySqlConnection(Options.ConnectionString);
await conn.OpenAsync(timeout.Token);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync(timeout.Token);
return new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds);
}
catch (Exception ex)
{
return new DcCheck(false, ex.Message);
}
}
// ─── Deployment Center: Updates und Lizenz ───
/// <summary>
/// Asks the UpdateService for a newer release. Silent at startup (log + status bar);
/// only a critical release interrupts the user.
/// </summary>
[RelayCommand]
private Task CheckForUpdatesInteractiveAsync() => CheckForUpdatesAsync(silent: false);
private async Task CheckForUpdatesAsync(bool silent)
{
try
{
var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel, License?.LicenseKey);
// 401 is a licensing statement, not a network hiccup — and it must be caught
// before the "up to date" branch: the server answers no version at all, so
// silently reporting "aktuell" would hide that updates stopped arriving.
if (result.Unauthorized)
{
Log.Error("⛔ Die Release-Ablage weist die Lizenz zurück — es kommen keine Updates mehr an: {Message}",
result.Message);
BuildVersionText = $"v{DcConfig.AppVersion} — Update-Zugang abgelehnt";
if (!silent && ShowError != null)
await ShowError("Deployment Center",
"Die Release-Ablage hat den Lizenzschlüssel abgelehnt (401).\n\n" +
$"{result.Message}\n\n" +
"Das ist kein Netzwerkproblem: Die Lizenz ist abgelaufen oder widerrufen. " +
"Solange das so bleibt, erreichen diese Installation keine Updates.");
return;
}
if (result.Error is not null)
{
Log.Warning("Update-Prüfung fehlgeschlagen: {Message}", result.Message);
if (!silent && ShowError != null)
await ShowError("Deployment Center", $"Update-Prüfung fehlgeschlagen:\n{result.Message}");
return;
}
if (!result.UpdateAvailable)
{
Log.Information("Update-Prüfung: v{Version} ist aktuell (Kanal {Channel}).",
DcConfig.AppVersion, Options.DcUpdateChannel);
if (!silent && ShowInfo != null)
await ShowInfo("Deployment Center", $"Predictalytics v{DcConfig.AppVersion} ist aktuell.");
return;
}
var latest = result.LatestRelease?.Version ?? "?";
Log.Warning("⬆ Update verfügbar: v{Latest} (installiert: v{Current}, Kanal {Channel}){Critical}",
latest, DcConfig.AppVersion, Options.DcUpdateChannel, result.IsCritical ? " — KRITISCH" : "");
BuildVersionText = $"v{DcConfig.AppVersion} — Update v{latest} verfügbar";
if (silent && !result.IsCritical) return;
var notes = result.LatestRelease?.Changelog;
var agentPresent = DcUpdateService.FindUpdateAgent() is not null;
var text = $"Neues Release v{latest} verfügbar (installiert: v{DcConfig.AppVersion}).\n" +
(result.IsCritical ? "\nDieses Update ist als kritisch markiert.\n" : "") +
(string.IsNullOrWhiteSpace(notes) ? "" : $"\n{notes}\n") +
(agentPresent
? "\nJetzt installieren? Predictalytics wird dazu beendet."
: "\nDer Update-Agent liegt nicht neben der Anwendung — bitte manuell einspielen.");
if (!agentPresent)
{
if (ShowInfo != null) await ShowInfo("Deployment Center", text);
return;
}
if (ShowConfirm == null || !await ShowConfirm("Deployment Center", text)) return;
// The agent replaces the running installation, so announce the shutdown first —
// otherwise the monitor reports a crash a few minutes later.
_heartbeat?.NotifyStopping();
if (!DcUpdateService.LaunchAgent(Options.DcUpdateChannel, License?.LicenseKey) && ShowError != null)
{
await ShowError("Deployment Center",
"Der Update-Agent konnte nicht gestartet werden. Details stehen im Log.");
}
}
catch (Exception ex)
{
Log.Warning(ex, "Update-Prüfung fehlgeschlagen");
}
}
[RelayCommand]
private async Task ShowLicenseStatusAsync()
{
if (ShowInfo == null) return;
var hardware = LicenseGuard.GetHardwareInfo();
var result = License?.LastResult;
var grace = result?.CacheExpiresAt is { } expiresAt && expiresAt > 0
? $"\nOffline-Gnadenfrist bis: {DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime:yyyy-MM-dd HH:mm} UTC"
: "";
await ShowInfo("Lizenzstatus",
$"Produkt: {DcConfig.ProductSlug}\n" +
$"Version: {DcConfig.AppVersion} ({DcConfig.GitCommitShort})\n" +
$"Server: {DcConfig.BaseUrl}\n" +
$"Hardware-ID: {hardware.HardwareId}\n" +
$"Quelle: {hardware.HwidSource}\n\n" +
$"Letzte Prüfung: {result?.Status ?? "unbekannt"}" +
(result?.IsCached == true ? " (aus Offline-Cache)" : "") +
$"\n{result?.Message}{grace}");
}
// ─── Befehle ───
[RelayCommand]
private async Task ToggleServerAsync()
{
if (!_host.WorkersRunning)
{
_workerCts = new CancellationTokenSource();
Log.Information("🚀 Starte Hintergrund-Worker...");
try
{
await _host.StartWorkersAsync(_workerCts.Token);
}
catch (OperationCanceledException) { }
catch (Exception ex) { Log.Error(ex, "Worker error"); }
}
else
{
Log.Information("⏹ Stoppe Hintergrund-Worker...");
_workerCts?.Cancel();
Log.Information("Worker gestoppt.");
}
UpdateStatus();
}
[RelayCommand]
private async Task ToggleWebserverAsync()
{
if (!_host.WebServerRunning)
{
try
{
Log.Information("🌐 Starte Kestrel auf {Url}...", Options.WebserverUrl);
await _host.StartWebServerAsync();
Log.Information("✅ WebUI erreichbar unter {Url}", Options.WebserverUrl);
Log.Information("📄 Swagger unter {Url}/swagger", Options.WebserverUrl);
}
catch (Exception ex)
{
Log.Error(ex, "Webserver konnte nicht gestartet werden");
if (ShowError != null) await ShowError("Fehler", $"Webserver konnte nicht gestartet werden:\n{ex.Message}");
}
}
else
{
Log.Information("⏹ Stoppe Webserver...");
await _host.StopWebServerAsync();
Log.Information("Webserver gestoppt.");
}
UpdateStatus();
}
[RelayCommand]
private void SaveSettings()
{
Options.Save();
DcErrorReporter.Configure(Options.DcToken, Options.DcErrorReportingEnabled);
RestartHeartbeat();
Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
UpdateStatus();
}
[RelayCommand]
private void OpenWebUi() => OpenInShell(Options.WebserverUrl);
[RelayCommand]
private void OpenLogFolder()
{
var path = LoggingSetup.DefaultLogDirectory;
OpenInShell(Directory.Exists(path) ? path : AppContext.BaseDirectory);
}
[RelayCommand]
private async Task SyncMarketsAsync()
{
if (!await GuardWorkersStoppedAsync("Der Markt-Sync")) return;
try
{
IsBusy = true;
Log.Information("Manueller Markt-Sync gestartet...");
using var cts = new CancellationTokenSource();
await _host.RunSingleMarketSyncAsync(cts.Token);
Log.Information("Manueller Markt-Sync abgeschlossen.");
if (ShowInfo != null) await ShowInfo("Fertig", "Markt-Sync abgeschlossen.");
}
catch (Exception ex)
{
Log.Error(ex, "Manueller Markt-Sync fehlgeschlagen");
if (ShowError != null) await ShowError("Fehler", $"Markt-Sync fehlgeschlagen:\n{ex.Message}");
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task UpdateDatabaseAsync()
{
if (!await GuardWorkersStoppedAsync("Das Datenbank-Update")) return;
try
{
IsBusy = true;
Log.Information("Manuelles Datenbank-Update gestartet...");
await _host.UpdateDatabaseAsync();
Log.Information("Datenbank aktualisiert.");
if (ShowInfo != null) await ShowInfo("Fertig", "Datenbank-Update abgeschlossen.");
}
catch (Exception ex)
{
Log.Error(ex, "Manuelles Datenbank-Update fehlgeschlagen");
if (ShowError != null) await ShowError("Fehler", $"Datenbank-Update fehlgeschlagen:\n{ex.Message}");
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task RecalculateAllAsync()
{
if (!await GuardWorkersStoppedAsync("Die Neuberechnung")) return;
if (ShowConfirm == null) return;
var confirmed = await ShowConfirm("Alle Trader neu berechnen",
"Dies löscht alle ABGELEITETEN Analysedaten (Positionen, Tages-Snapshots, " +
"Kategorie-Statistiken) und markiert jeden Trader zur vollständigen Neuberechnung.\n\n" +
"Rohdaten (Trades und Märkte) bleiben unberührt.\n\n" +
"Danach den Server starten: der Analytics-Worker baut jeden Trader mit der " +
"aktuellen Engine neu auf (läuft im Hintergrund, kann bei vielen Tradern " +
"mehrere Stunden dauern).\n\nFortfahren?");
if (!confirmed) return;
try
{
IsBusy = true;
Log.Information("Vollständige Neuberechnung angestoßen...");
using var cts = new CancellationTokenSource();
var summary = await _host.RunRecalculateAllTradersAsync(cts.Token);
if (ShowInfo != null)
await ShowInfo("Alle Trader neu berechnen",
$"Zurücksetzen abgeschlossen:\n\n{summary}\n\nJetzt den Server starten, um die Analytik neu aufzubauen.");
}
catch (Exception ex)
{
Log.Error(ex, "Vollständige Neuberechnung fehlgeschlagen");
if (ShowError != null) await ShowError("Fehler", $"Neuberechnung fehlgeschlagen:\n{ex.Message}");
}
finally { IsBusy = false; }
}
// ─── Hilfsfunktionen ───
private async Task<bool> GuardWorkersStoppedAsync(string action)
{
if (!_host.WorkersRunning) return true;
if (ShowError != null)
await ShowError("Worker aktiv", $"{action} kann nicht laufen, während die Hintergrund-Worker aktiv sind. Bitte zuerst den Server stoppen.");
return false;
}
private async Task RefreshDbSizeAsync()
{
var sizeMb = await _host.GetDatabaseSizeMbAsync();
_lastDbSizeMb = sizeMb;
DbSizeText = sizeMb.HasValue ? $"DB Size: {sizeMb.Value:F2} MB" : "DB Size: —";
}
/// <summary>
/// Oeffnet Pfad oder URL mit der Standardanwendung des Systems.
/// UseShellExecute funktioniert unter Windows wie unter Linux (dort ueber xdg-open).
/// </summary>
private static void OpenInShell(string target)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true });
}
catch (Exception ex)
{
Log.Error(ex, "Konnte {Target} nicht öffnen", target);
}
}
/// <summary>Beim Beenden: Monitor abmelden, Worker und Webserver stoppen.</summary>
public void Shutdown()
{
_heartbeat?.NotifyStopping();
_heartbeat?.Dispose();
_heartbeat = null;
_workerCts?.Cancel();
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
}
}
@@ -1,22 +0,0 @@
using MySqlConnector;
namespace Predictalytics.Shell.ViewModels;
/// <summary>Feste Auswahllisten fuer die Einstellungsansicht.</summary>
public static class OptionSources
{
/// <summary>
/// Die praktisch relevanten SSL-Modi. Die uebrigen Werte des Enums
/// (VerifyCA, VerifyFull) erfordern hinterlegte Zertifikate und wuerden
/// hier nur zu Fehlkonfiguration einladen.
/// </summary>
public static MySqlSslMode[] SslModes { get; } =
[
MySqlSslMode.None,
MySqlSslMode.Preferred,
MySqlSslMode.Required
];
/// <summary>Release-Kanaele des UpdateService.</summary>
public static string[] UpdateChannels { get; } = ["prod", "beta", "dev"];
}
-79
View File
@@ -1,79 +0,0 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace Predictalytics.Shell.Views;
/// <summary>
/// Minimale Ersatzdialoge fuer <c>MessageBox.Show</c>. Bewusst im Code aufgebaut
/// und ohne Drittanbieterpaket — es sind nur zwei Varianten noetig.
/// </summary>
public static class Dialogs
{
public static Task ShowInfoAsync(Window owner, string title, string message) =>
ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
public static Task ShowErrorAsync(Window owner, string title, string message) =>
ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
public static async Task<bool> ShowConfirmAsync(Window owner, string title, string message) =>
await ShowAsync(owner, title, message, ["Ja", "Abbrechen"]) == 0;
private static Task<int> ShowAsync(Window owner, string title, string message, string[] buttons)
{
var tcs = new TaskCompletionSource<int>();
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
var dialog = new Window
{
Title = title,
SizeToContent = SizeToContent.WidthAndHeight,
MaxWidth = 640,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false,
ShowInTaskbar = false
};
for (var i = 0; i < buttons.Length; i++)
{
var index = i;
var button = new Button
{
Content = buttons[i],
MinWidth = 90,
IsDefault = index == 0,
IsCancel = index == buttons.Length - 1
};
button.Click += (_, _) => { tcs.TrySetResult(index); dialog.Close(); };
buttonPanel.Children.Add(button);
}
dialog.Content = new StackPanel
{
Margin = new Avalonia.Thickness(20),
Spacing = 16,
Children =
{
new TextBlock
{
Text = message,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 580
},
buttonPanel
}
};
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
dialog.Closed += (_, _) => tcs.TrySetResult(buttons.Length - 1);
_ = dialog.ShowDialog(owner);
return tcs.Task;
}
}
@@ -1,44 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Predictalytics.Shell.Views.LicenseWindow"
Title="Predictalytics — Lizenzaktivierung"
Width="580" SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
<StackPanel Margin="24" Spacing="16">
<TextBlock TextWrapping="Wrap" FontSize="15" FontWeight="SemiBold"
Text="Diese Installation benötigt eine gültige Lizenz." />
<TextBlock TextWrapping="Wrap" Opacity="0.8"
Text="Bitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):" />
<TextBox Name="KeyBox"
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
FontSize="14"
PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" />
<TextBlock Name="StatusText" TextWrapping="Wrap" MinHeight="34" IsVisible="False" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="12">
<ProgressBar Name="Busy" IsIndeterminate="True" Width="100"
VerticalAlignment="Center" IsVisible="False" />
<Button Name="ActivateButton" Content="Aktivieren" MinWidth="110" IsDefault="True"
Click="OnActivateClick" />
<Button Content="Beenden" MinWidth="100" IsCancel="True" Click="OnExitClick" />
</StackPanel>
<!-- Die Aktivierung wird am Server an diese Hardware-ID gebunden. -->
<Border BorderThickness="0,1,0,0" Padding="0,12,0,0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="4">
<TextBlock Text="Hardware-ID dieser Maschine" FontSize="11" Opacity="0.6" />
<SelectableTextBlock Name="HardwareIdText"
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
FontSize="11" Opacity="0.8" TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</Window>
@@ -1,111 +0,0 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Deploymentcenter.Client;
using Predictalytics.Hosting;
namespace Predictalytics.Shell.Views;
/// <summary>
/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
/// <see cref="Completion"/> liefert die Sitzung, sobald das Deployment Center die
/// Aktivierung fuer diese Maschine bestaetigt hat; null, wenn der Benutzer abbricht.
/// </summary>
public partial class LicenseWindow : Window
{
private readonly LicenseClient _client = null!;
private readonly TaskCompletionSource<LicenseSession?> _completion = new();
/// <summary>Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.</summary>
public Task<LicenseSession?> Completion => _completion.Task;
public LicenseWindow()
{
InitializeComponent();
// Die Aktivierung wird am Server an diese Hardware-ID gebunden — ohne sie kann
// der Support nicht sagen, welcher Platz beim Maschinenwechsel freizugeben ist.
try
{
var hw = LicenseGuard.GetHardwareInfo();
HardwareIdText.Text = $"{hw.HardwareId}\nQuelle: {hw.HwidSource}";
}
catch (Exception ex)
{
HardwareIdText.Text = $"nicht ermittelbar: {ex.Message}";
}
}
public LicenseWindow(LicenseClient client, LicenseValidationResult? lastResult) : this()
{
_client = client;
if (lastResult is null) return;
// IsTransient heisst: der Server hat gar kein Urteil gefaellt. Dem Benutzer zu
// sagen, seine Lizenz sei schlecht, waere falsch — die Verbindung ist es.
SetStatus(lastResult.IsTransient
? "Das Deployment Center ist derzeit nicht erreichbar und es liegt keine gültige " +
$"Offline-Prüfung mehr vor. Bitte Verbindung prüfen und erneut versuchen.\n{lastResult.Message}"
: $"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}",
isError: true);
}
private async void OnActivateClick(object? sender, RoutedEventArgs e)
{
var key = KeyBox.Text ?? "";
if (string.IsNullOrWhiteSpace(key))
{
SetStatus("Bitte einen Lizenzschlüssel eingeben.", isError: true);
return;
}
ActivateButton.IsEnabled = false;
Busy.IsVisible = true;
SetStatus("Prüfe Lizenz am Deployment Center…", isError: false);
try
{
var (session, result) = await LicenseGuard.ActivateAsync(_client, key);
if (session is not null)
{
_completion.TrySetResult(session);
Close();
return;
}
SetStatus($"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}", isError: true);
}
catch (Exception ex)
{
SetStatus($"Fehler bei der Prüfung: {ex.Message}", isError: true);
}
finally
{
ActivateButton.IsEnabled = true;
Busy.IsVisible = false;
}
}
private void OnExitClick(object? sender, RoutedEventArgs e)
{
_completion.TrySetResult(null);
Close();
}
protected override void OnClosed(EventArgs e)
{
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
_completion.TrySetResult(null);
base.OnClosed(e);
}
private void SetStatus(string message, bool isError)
{
StatusText.Text = message;
StatusText.IsVisible = true;
StatusText.Foreground = isError
? new SolidColorBrush(Color.FromRgb(205, 92, 92))
: new SolidColorBrush(Color.FromRgb(150, 150, 150));
}
}
@@ -1,258 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Predictalytics.Shell.ViewModels"
xmlns:hosting="using:Predictalytics.Hosting"
xmlns:sql="using:MySqlConnector"
xmlns:sys="using:System"
x:Class="Predictalytics.Shell.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="Predictalytics Analytics — Backend Server"
Width="1400" Height="900"
MinWidth="900" MinHeight="600"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<!-- Monospace mit Fallback-Kette: Cascadia gibt es unter Linux meist nicht. -->
<FontFamily x:Key="TerminalFont">Cascadia Code,DejaVu Sans Mono,Liberation Mono,Consolas,monospace</FontFamily>
</Window.Resources>
<Window.Styles>
<Style Selector="TextBlock.label">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,12,0" />
</Style>
<Style Selector="TextBlock.hint">
<Setter Property="Opacity" Value="0.6" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Margin" Value="0,2,0,0" />
</Style>
<Style Selector="HeaderedContentControl.group">
<Setter Property="Margin" Value="0,0,0,20" />
</Style>
</Window.Styles>
<DockPanel>
<!-- ─── Menü ─── -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="_Datei">
<MenuItem Header="_Beenden" Click="OnExitClick" />
</MenuItem>
<MenuItem Header="_Ansicht">
<MenuItem Header="Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
<MenuItem Header="WebUI öffnen" Command="{Binding OpenWebUiCommand}" />
</MenuItem>
<MenuItem Header="_Entwicklung">
<MenuItem Header="Märkte synchronisieren" Command="{Binding SyncMarketsCommand}" />
<MenuItem Header="Datenbank aktualisieren" Command="{Binding UpdateDatabaseCommand}" />
<Separator />
<MenuItem Header="Alle Trader neu berechnen…" Command="{Binding RecalculateAllCommand}" />
</MenuItem>
<MenuItem Header="Deployment _Center">
<MenuItem Header="Nach Updates suchen" Command="{Binding CheckForUpdatesInteractiveCommand}" />
<MenuItem Header="Lizenzstatus anzeigen" Command="{Binding ShowLicenseStatusCommand}" />
</MenuItem>
</Menu>
<!-- ─── Werkzeugleiste ─── -->
<Border DockPanel.Dock="Top" Padding="8" BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{Binding ServerButtonText}" Command="{Binding ToggleServerCommand}" MinWidth="150" />
<Button Content="{Binding WebserverButtonText}" Command="{Binding ToggleWebserverCommand}" MinWidth="170" />
<ProgressBar IsIndeterminate="True" Width="120" VerticalAlignment="Center"
IsVisible="{Binding IsBusy}" />
</StackPanel>
</Border>
<!-- ─── Statusleiste ─── -->
<Border DockPanel.Dock="Bottom" Padding="8,4" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusText}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding DbSizeText}" Margin="16,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding BuildVersionText}" VerticalAlignment="Center" Opacity="0.7" />
</Grid>
</Border>
<!-- ─── Inhalt ─── -->
<TabControl>
<TabItem Header="Terminal">
<ListBox Name="LogList"
ItemsSource="{Binding LogLines}"
Background="#0F0F14"
FontFamily="{StaticResource TerminalFont}"
FontSize="12"
SelectionMode="Multiple">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LogLine">
<TextBlock Text="{Binding Text}"
Foreground="{Binding Foreground}"
TextWrapping="NoWrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
<TabItem Header="Einstellungen">
<Grid RowDefinitions="*,Auto">
<ScrollViewer Grid.Row="0" Padding="20">
<StackPanel MaxWidth="760" HorizontalAlignment="Left"
DataContext="{Binding Options}" x:DataType="hosting:PredictalyticsOptions">
<!-- Webserver -->
<HeaderedContentControl Classes="group" Header="Webserver">
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Port" />
<NumericUpDown Grid.Column="1" Value="{Binding WebserverPort}"
Minimum="1" Maximum="65535" FormatString="0" HorizontalAlignment="Left" Width="160" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Bind-Adresse" />
<StackPanel Grid.Column="1">
<TextBox Text="{Binding WebserverHost}" Width="260" HorizontalAlignment="Left" />
<TextBlock Classes="hint"
Text="'localhost' = nur lokal erreichbar. '0.0.0.0' macht die API von außen erreichbar — dann unbedingt absichern." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="API-Token" />
<StackPanel Grid.Column="1">
<TextBox Text="{Binding ApiControlToken}" PasswordChar="•" />
<TextBlock Classes="hint"
Text="Schützt die schreibenden Endpunkte (Jobs, Trader-Steuerung, Dev) über den Header X-Predictalytics-Key. Bei einer Bind-Adresse außerhalb von localhost Pflicht — ohne Token werden diese Endpunkte gar nicht bereitgestellt." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank-Debug" />
<CheckBox Grid.Column="1" IsChecked="{Binding DbConnectionDebug}"
Content="Ausführliche Verbindungsinfos im Terminal" />
</Grid>
</StackPanel>
</HeaderedContentControl>
<!-- Datenbank -->
<HeaderedContentControl Classes="group" Header="Datenbank">
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Server" />
<TextBox Grid.Column="1" Text="{Binding DbServer}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank" />
<TextBox Grid.Column="1" Text="{Binding DbName}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Benutzer" />
<TextBox Grid.Column="1" Text="{Binding DbUser}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Passwort" />
<TextBox Grid.Column="1" Text="{Binding DbPassword}" PasswordChar="•" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="SSL-Modus" />
<StackPanel Grid.Column="1">
<ComboBox SelectedItem="{Binding DbSslMode}" Width="200" HorizontalAlignment="Left"
ItemsSource="{x:Static vm:OptionSources.SslModes}" />
<TextBlock Classes="hint"
Text="Bei entfernten Servern 'Preferred' empfohlen. 'None' überträgt unverschlüsselt." />
</StackPanel>
</Grid>
</StackPanel>
</HeaderedContentControl>
<!-- Deployment Center -->
<HeaderedContentControl Classes="group" Header="Deployment Center">
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Server-URL" />
<StackPanel Grid.Column="1">
<SelectableTextBlock Text="{Binding DcServerUrl}" VerticalAlignment="Center" Opacity="0.8" />
<TextBlock Classes="hint"
Text="Fest einkompiliert — ein einstellbarer Endpoint würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu zeigen." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="API Token" />
<StackPanel Grid.Column="1">
<TextBox Text="{Binding DcToken}" PasswordChar="•" />
<TextBlock Classes="hint"
Text="Benötigte Rechte: 'watchdog:ping' für Heartbeats, 'bugtracker:report' für das Fehler-Reporting." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Heartbeat aktiv" />
<CheckBox Grid.Column="1" IsChecked="{Binding DcHeartbeatEnabled}"
Content="Heartbeats senden (Dead-Man's-Switch)" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Monitor Source" />
<TextBox Grid.Column="1" Text="{Binding DcSource}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Monitor Instance" />
<TextBox Grid.Column="1" Text="{Binding DcInstance}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Intervall (Sek.)" />
<StackPanel Grid.Column="1">
<NumericUpDown Value="{Binding DcHeartbeatIntervalSeconds}"
Minimum="15" Maximum="3600" FormatString="0" HorizontalAlignment="Left" Width="160" />
<TextBlock Classes="hint"
Text="Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Fehler melden" />
<CheckBox Grid.Column="1" IsChecked="{Binding DcErrorReportingEnabled}"
Content="Laufzeitfehler (Error/Fatal) an den Fehler-Stream melden" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Update-Prüfung" />
<CheckBox Grid.Column="1" IsChecked="{Binding DcUpdateCheckEnabled}"
Content="Beim Start auf neuere Releases prüfen (installiert nichts automatisch)" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Update-Kanal" />
<ComboBox Grid.Column="1" SelectedItem="{Binding DcUpdateChannel}" Width="200"
HorizontalAlignment="Left"
ItemsSource="{x:Static vm:OptionSources.UpdateChannels}" />
</Grid>
</StackPanel>
</HeaderedContentControl>
<!-- Egress -->
<HeaderedContentControl Classes="group" Header="Egress (Proxy / Quell-IP)">
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Classes="hint"
Text="Ein Kanal pro Zeile im Format: id|type|value&#10;Beispiel: prox-1|Proxy|http://user:pass@proxy:8080&#10; ip-1|SourceIp|192.168.1.100" />
<TextBox Text="{Binding EgressChannelsText}"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="120"
FontFamily="{StaticResource TerminalFont}"
FontSize="12" />
</StackPanel>
</HeaderedContentControl>
</StackPanel>
</ScrollViewer>
<Border Grid.Row="1" Padding="20,12" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Content="Speichern &amp; übernehmen" Command="{Binding SaveSettingsCommand}" />
<TextBlock VerticalAlignment="Center" Opacity="0.6"
Text="Port und Datenbank wirken beim nächsten Start des Webservers." />
</StackPanel>
</Border>
</Grid>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -1,56 +0,0 @@
using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Predictalytics.Shell.ViewModels;
namespace Predictalytics.Shell.Views;
public partial class MainWindow : Window
{
private MainWindowViewModel? _vm;
public MainWindow()
{
InitializeComponent();
}
public MainWindow(MainWindowViewModel viewModel) : this()
{
_vm = viewModel;
DataContext = viewModel;
// Dialoge kann nur das Fenster zeigen — das ViewModel bekommt sie als Rueckrufe.
viewModel.ShowInfo = (title, message) => Dialogs.ShowInfoAsync(this, title, message);
viewModel.ShowError = (title, message) => Dialogs.ShowErrorAsync(this, title, message);
viewModel.ShowConfirm = (title, message) => Dialogs.ShowConfirmAsync(this, title, message);
// Terminal mitlaufen lassen.
viewModel.LogLines.CollectionChanged += OnLogLinesChanged;
}
private void OnLogLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action != NotifyCollectionChangedAction.Add) return;
// Nach dem Layout ans Ende scrollen, sonst existiert der Container noch nicht.
Dispatcher.UIThread.Post(() =>
{
var list = this.FindControl<ListBox>("LogList");
if (list?.ItemCount > 0) list.ScrollIntoView(list.ItemCount - 1);
}, DispatcherPriority.Background);
}
private void OnExitClick(object? sender, RoutedEventArgs e) => Close();
protected override void OnClosed(EventArgs e)
{
if (_vm != null)
{
_vm.LogLines.CollectionChanged -= OnLogLinesChanged;
_vm.Shutdown();
_vm = null;
}
base.OnClosed(e);
}
}
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
chmod +x "$SCRIPT_DIR/Predictalytics.Shell" "$SCRIPT_DIR/update-agent" 2>/dev/null
exec "$SCRIPT_DIR/Predictalytics.Shell" "$@"
@@ -0,0 +1,137 @@
using System.ComponentModel;
using System.Text.Json;
namespace Predictalytics.WinFormsHost;
public class AppSettings
{
private const string FileName = "settings.json";
[Category("Webserver")]
[DisplayName("Port")]
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
[DefaultValue(5000)]
public int WebserverPort { get; set; } = 5000;
[Category("Webserver")]
[DisplayName("Database Debug")]
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
[DefaultValue(false)]
public bool DbConnectionDebug { get; set; } = false;
private string _egressChannelsText = "";
[Category("Egress (Proxy/IP)")]
[DisplayName("Egress Channels")]
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
[Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
[Category("Watchdog")]
[DisplayName("Enabled")]
[Description("Sendet periodische Heartbeats an den externen Watchdog-Server (Dead-Man's-Switch). Benötigt einen API Key.")]
[DefaultValue(true)]
public bool WatchdogEnabled { get; set; } = true;
[Category("Watchdog")]
[DisplayName("Server URL")]
[Description("Basis-URL des Watchdog-Servers.")]
[DefaultValue("https://watchdog.mhdf.de")]
public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de";
[Category("Watchdog")]
[DisplayName("API Key")]
[Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")]
[PasswordPropertyText(true)]
public string WatchdogApiKey { get; set; } = "";
[Category("Watchdog")]
[DisplayName("Source")]
[Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
[DefaultValue("Predictalytics")]
public string WatchdogSource { get; set; } = "Predictalytics";
[Category("Watchdog")]
[DisplayName("Instance")]
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
[DefaultValue("default")]
public string WatchdogInstance { get; set; } = "default";
[Category("Watchdog")]
[DisplayName("Interval (Sekunden)")]
[Description("Sende-Takt der Heartbeats. Der Watchdog alarmiert, wenn ~1,5× dieses Intervall + 30 s ohne Heartbeat vergehen.")]
[DefaultValue(60)]
public int WatchdogIntervalSeconds { get; set; } = 60;
private string _dbServer = "localhost";
private string _dbName = "";
private string _dbUser = "";
private string _dbPassword = "";
[Category("Database")]
[DisplayName("Server")]
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
[Category("Database")]
[DisplayName("Database")]
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
[Category("Database")]
[DisplayName("User")]
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
[Category("Database")]
[DisplayName("Password")]
[PasswordPropertyText(true)]
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
[Browsable(false)]
public string ConnectionString
{
get
{
var builder = new MySqlConnector.MySqlConnectionStringBuilder
{
Server = DbServer?.Trim(),
Database = DbName?.Trim(),
UserID = DbUser?.Trim(),
Password = DbPassword?.Trim(),
AllowPublicKeyRetrieval = true,
SslMode = MySqlConnector.MySqlSslMode.None,
Pooling = true,
MinimumPoolSize = 0,
MaximumPoolSize = 100
};
return builder.ConnectionString;
}
}
public static AppSettings Load()
{
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
if (!File.Exists(filePath))
{
var settings = new AppSettings();
settings.Save();
return settings;
}
try
{
var json = File.ReadAllText(filePath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
public void Save()
{
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(this, options);
File.WriteAllText(filePath, json);
}
}
+274
View File
@@ -0,0 +1,274 @@
namespace Predictalytics.WinFormsHost;
partial class MainForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
toolStrip1 = new ToolStrip();
btn_serverstart = new ToolStripButton();
btn_localWebserver = new ToolStripButton();
statusStrip1 = new StatusStrip();
label_apiRatelimit = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
label_dbSize = new ToolStripStatusLabel();
tabControl1 = new TabControl();
tabPage_terminal = new TabPage();
rtb_terminal = new RichTextBox();
tabPage2 = new TabPage();
pg_settings = new PropertyGrid();
menuStrip1 = new MenuStrip();
filesToolStripMenuItem = new ToolStripMenuItem();
editToolStripMenuItem = new ToolStripMenuItem();
btn_logfolder = new ToolStripMenuItem();
btn_openbrowser = new ToolStripMenuItem();
developmentToolStripMenuItem = new ToolStripMenuItem();
btn_dbReset = new ToolStripMenuItem();
btn_syncmarkets = new ToolStripMenuItem();
btn_dbUpdate = new ToolStripMenuItem();
btn_recalcAll = new ToolStripMenuItem();
toolStrip1.SuspendLayout();
statusStrip1.SuspendLayout();
tabControl1.SuspendLayout();
tabPage_terminal.SuspendLayout();
tabPage2.SuspendLayout();
menuStrip1.SuspendLayout();
SuspendLayout();
//
// toolStrip1
//
toolStrip1.ImageScalingSize = new Size(24, 24);
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_serverstart, btn_localWebserver });
toolStrip1.Location = new Point(0, 33);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(1864, 34);
toolStrip1.TabIndex = 0;
//
// btn_serverstart
//
btn_serverstart.ImageTransparentColor = Color.Magenta;
btn_serverstart.Name = "btn_serverstart";
btn_serverstart.Size = new Size(127, 29);
btn_serverstart.Text = "▶ Start Server";
//
// btn_localWebserver
//
btn_localWebserver.ImageTransparentColor = Color.Magenta;
btn_localWebserver.Name = "btn_localWebserver";
btn_localWebserver.Size = new Size(161, 29);
btn_localWebserver.Text = "▶ Start Webserver";
//
// statusStrip1
//
statusStrip1.ImageScalingSize = new Size(24, 24);
statusStrip1.Items.AddRange(new ToolStripItem[] { label_apiRatelimit, label_dbSize, label_buildVersion });
statusStrip1.Location = new Point(0, 1000);
statusStrip1.Name = "statusStrip1";
statusStrip1.Size = new Size(1864, 32);
statusStrip1.TabIndex = 1;
//
// label_apiRatelimit
//
label_apiRatelimit.Name = "label_apiRatelimit";
label_apiRatelimit.Size = new Size(1600, 25);
label_apiRatelimit.Spring = true;
label_apiRatelimit.Text = "API: OK";
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
//
// label_buildVersion
//
label_buildVersion.Name = "label_buildVersion";
label_buildVersion.Size = new Size(67, 25);
label_buildVersion.Text = "Build: -";
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
//
// label_dbSize
//
label_dbSize.Name = "label_dbSize";
label_dbSize.Size = new Size(150, 25);
label_dbSize.Text = "DB Size: -";
label_dbSize.TextAlign = ContentAlignment.MiddleRight;
//
// tabControl1
//
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
tabControl1.Controls.Add(tabPage_terminal);
tabControl1.Controls.Add(tabPage2);
tabControl1.Location = new Point(0, 61);
tabControl1.Name = "tabControl1";
tabControl1.SelectedIndex = 0;
tabControl1.Size = new Size(1864, 946);
tabControl1.TabIndex = 2;
//
// tabPage_terminal
//
tabPage_terminal.Controls.Add(rtb_terminal);
tabPage_terminal.Location = new Point(4, 34);
tabPage_terminal.Name = "tabPage_terminal";
tabPage_terminal.Padding = new Padding(3);
tabPage_terminal.Size = new Size(1856, 908);
tabPage_terminal.TabIndex = 0;
tabPage_terminal.Text = "Terminal";
tabPage_terminal.UseVisualStyleBackColor = true;
//
// rtb_terminal
//
rtb_terminal.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
rtb_terminal.Location = new Point(3, 6);
rtb_terminal.Name = "rtb_terminal";
rtb_terminal.Size = new Size(1847, 896);
rtb_terminal.TabIndex = 0;
rtb_terminal.Text = "";
//
// tabPage2
//
tabPage2.Controls.Add(pg_settings);
tabPage2.Location = new Point(4, 34);
tabPage2.Name = "tabPage2";
tabPage2.Padding = new Padding(3);
tabPage2.Size = new Size(1856, 908);
tabPage2.TabIndex = 1;
tabPage2.Text = "Settings";
tabPage2.UseVisualStyleBackColor = true;
//
// pg_settings
//
pg_settings.Location = new Point(3, 6);
pg_settings.Name = "pg_settings";
pg_settings.Size = new Size(1850, 896);
pg_settings.TabIndex = 0;
//
// menuStrip1
//
menuStrip1.ImageScalingSize = new Size(24, 24);
menuStrip1.Items.AddRange(new ToolStripItem[] { filesToolStripMenuItem, editToolStripMenuItem, developmentToolStripMenuItem });
menuStrip1.Location = new Point(0, 0);
menuStrip1.Name = "menuStrip1";
menuStrip1.Size = new Size(1864, 33);
menuStrip1.TabIndex = 3;
//
// filesToolStripMenuItem
//
filesToolStripMenuItem.Name = "filesToolStripMenuItem";
filesToolStripMenuItem.Size = new Size(62, 29);
filesToolStripMenuItem.Text = "Files";
//
// editToolStripMenuItem
//
editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_logfolder, btn_openbrowser });
editToolStripMenuItem.Name = "editToolStripMenuItem";
editToolStripMenuItem.Size = new Size(58, 29);
editToolStripMenuItem.Text = "Edit";
//
// btn_logfolder
//
btn_logfolder.Name = "btn_logfolder";
btn_logfolder.Size = new Size(261, 34);
btn_logfolder.Text = "Show Logfolder";
btn_logfolder.Click += btn_logfolder_Click;
//
// btn_openbrowser
//
btn_openbrowser.Name = "btn_openbrowser";
btn_openbrowser.Size = new Size(261, 34);
btn_openbrowser.Text = "Show Local WebUI";
btn_openbrowser.Click += btn_openbrowser_Click;
//
// developmentToolStripMenuItem
//
developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets, btn_dbUpdate, btn_recalcAll });
developmentToolStripMenuItem.Name = "developmentToolStripMenuItem";
developmentToolStripMenuItem.Size = new Size(135, 29);
developmentToolStripMenuItem.Text = "Development";
//
// btn_dbReset
//
btn_dbReset.Name = "btn_dbReset";
btn_dbReset.Size = new Size(286, 34);
btn_dbReset.Text = "reset TradesDB";
//
// btn_syncmarkets
//
btn_syncmarkets.Name = "btn_syncmarkets";
btn_syncmarkets.Size = new Size(286, 34);
btn_syncmarkets.Text = "Sync Markets";
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
//
// btn_dbUpdate
//
btn_dbUpdate.Name = "btn_dbUpdate";
btn_dbUpdate.Size = new Size(286, 34);
btn_dbUpdate.Text = "UpdateDB";
btn_dbUpdate.Click += btn_dbUpdate_Click;
//
// btn_recalcAll
//
btn_recalcAll.Name = "btn_recalcAll";
btn_recalcAll.Size = new Size(286, 34);
btn_recalcAll.Text = "Recalculate All Traders";
btn_recalcAll.Click += btn_recalcAll_Click;
//
// MainForm
//
AutoScaleDimensions = new SizeF(10F, 25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(1864, 1032);
Controls.Add(tabControl1);
Controls.Add(statusStrip1);
Controls.Add(toolStrip1);
Controls.Add(menuStrip1);
MainMenuStrip = menuStrip1;
MaximumSize = new Size(1886, 1088);
MinimumSize = new Size(1886, 1088);
Name = "MainForm";
Text = "Predictalytics";
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
statusStrip1.ResumeLayout(false);
statusStrip1.PerformLayout();
tabControl1.ResumeLayout(false);
tabPage_terminal.ResumeLayout(false);
tabPage2.ResumeLayout(false);
menuStrip1.ResumeLayout(false);
menuStrip1.PerformLayout();
ResumeLayout(false);
PerformLayout();
}
#endregion
private ToolStrip toolStrip1;
private ToolStripButton btn_serverstart;
private ToolStripButton btn_localWebserver;
private StatusStrip statusStrip1;
private TabControl tabControl1;
private TabPage tabPage_terminal;
private RichTextBox rtb_terminal;
private TabPage tabPage2;
private MenuStrip menuStrip1;
private ToolStripMenuItem filesToolStripMenuItem;
private ToolStripMenuItem editToolStripMenuItem;
private ToolStripMenuItem btn_logfolder;
private ToolStripMenuItem btn_openbrowser;
private ToolStripMenuItem developmentToolStripMenuItem;
private ToolStripMenuItem btn_dbReset;
private ToolStripMenuItem btn_syncmarkets;
private PropertyGrid pg_settings;
private ToolStripStatusLabel label_apiRatelimit;
private ToolStripStatusLabel label_dbSize;
private ToolStripStatusLabel label_buildVersion;
private ToolStripMenuItem btn_dbUpdate;
private ToolStripMenuItem btn_recalcAll;
}
+344
View File
@@ -0,0 +1,344 @@
using System.Reflection;
using Predictalytics.WinFormsHost.Services;
using Serilog;
namespace Predictalytics.WinFormsHost;
public partial class MainForm : Form
{
private EmbeddedWebServer? _webServer;
private CancellationTokenSource? _workerCts;
private bool _workerRunning;
private bool _webServerRunning;
private AppSettings _settings = null!;
private WatchdogHeartbeatService? _watchdog;
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
public RichTextBox Terminal => rtb_terminal;
public MainForm()
{
InitializeComponent();
this.Text = "Predictalytics Analytics — Backend Server";
rtb_terminal.BackColor = System.Drawing.Color.FromArgb(15, 15, 20);
rtb_terminal.ForeColor = System.Drawing.Color.FromArgb(180, 180, 180);
rtb_terminal.Font = new Font("Cascadia Code", 9.5f, FontStyle.Regular);
rtb_terminal.ReadOnly = true;
}
/// <summary>
/// Called after Serilog is configured. Initializes the embedded web server.
/// </summary>
public void Initialize()
{
_settings = AppSettings.Load();
pg_settings.SelectedObject = _settings;
pg_settings.PropertyValueChanged += (s, e) => {
_settings.Save();
if (_webServer != null)
{
_webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText;
}
RestartWatchdog();
};
_webServer = new EmbeddedWebServer();
_webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText;
// Build Version (Date of compilation/file creation)
try {
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
} catch {
label_buildVersion.Text = "Build: Unknown";
}
UpdateStatusBar();
// Wire up button events
btn_serverstart.Click += Btn_serverstart_Click;
btn_localWebserver.Click += Btn_localWebserver_Click;
Log.Information("MainForm initialized. Ready.");
Log.Information("Press 'Start Server' to begin polling & discovery.");
Log.Information("Press 'Start Local Webserver' to launch the WebUI on http://localhost:{Port}", _settings.WebserverPort);
_ = UpdateDbSizeAsync();
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync();
dbSizeTimer.Start();
RestartWatchdog();
}
/// <summary>
/// (Re-)creates the Watchdog heartbeat sender from the current settings.
/// Called at startup and whenever settings change.
/// </summary>
private void RestartWatchdog()
{
_watchdog?.Dispose();
_watchdog = null;
if (!_settings.WatchdogEnabled) return;
if (string.IsNullOrWhiteSpace(_settings.WatchdogApiKey) || string.IsNullOrWhiteSpace(_settings.WatchdogUrl))
{
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Settings eintragen.");
return;
}
_watchdog = new WatchdogHeartbeatService(
_settings.WatchdogUrl,
_settings.WatchdogApiKey,
_settings.WatchdogSource,
_settings.WatchdogInstance,
_settings.WatchdogIntervalSeconds,
metadataProvider: () => new
{
workersRunning = _workerRunning,
webserverRunning = _webServerRunning
});
_watchdog.Start();
}
private async void Btn_serverstart_Click(object? sender, EventArgs e)
{
if (!_workerRunning)
{
// Start workers
_workerCts = new CancellationTokenSource();
_workerRunning = true;
btn_serverstart.Text = "⏹ Stop Server";
Log.Information("🚀 Starting background workers...");
try
{
_webServer!.ConnectionString = _settings.ConnectionString;
_webServer!.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer!.EgressChannelsText = _settings.EgressChannelsText;
await _webServer!.StartWorkersAsync(_workerCts.Token);
}
catch (OperationCanceledException) { }
catch (Exception ex) { Log.Error(ex, "Worker error"); }
}
else
{
// Stop workers
Log.Information("⏹ Stopping background workers...");
_workerCts?.Cancel();
_workerRunning = false;
btn_serverstart.Text = "▶ Start Server";
Log.Information("Workers stopped.");
}
UpdateStatusBar();
}
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
{
if (!_webServerRunning)
{
try
{
Log.Information("🌐 Starting embedded Kestrel webserver on http://localhost:{Port}...", _settings.WebserverPort);
await _webServer!.StartWebServerAsync(_settings.WebserverPort);
_webServerRunning = true;
btn_localWebserver.Text = "⏹ Stop Webserver";
Log.Information("✅ WebUI available at http://localhost:{Port}", _settings.WebserverPort);
Log.Information("📄 Swagger API docs at http://localhost:{Port}/swagger", _settings.WebserverPort);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to start webserver");
_webServerRunning = false;
}
}
else
{
Log.Information("⏹ Stopping webserver...");
await _webServer!.StopWebServerAsync();
_webServerRunning = false;
btn_localWebserver.Text = "▶ Start Webserver";
Log.Information("Webserver stopped.");
}
UpdateStatusBar();
}
private void UpdateStatusBar()
{
var workerStatus = _workerRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
var serverStatus = _webServerRunning ? $"[RUNNING] Webserver :{_settings.WebserverPort}" : "[STOPPED] Webserver";
this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}";
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
_watchdog?.NotifyStopping();
_watchdog?.Dispose();
_watchdog = null;
_workerCts?.Cancel();
_webServer?.StopWebServerAsync().GetAwaiter().GetResult();
base.OnFormClosing(e);
}
private void btn_openbrowser_Click(object sender, EventArgs e)
{
try
{
System.Diagnostics.Process.Start("explorer.exe", $"\"http://localhost:{_settings.WebserverPort}\"");
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Fehler beim Öffnen des Browsers");
MessageBox.Show("Browser konnte nicht gestartet werden.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btn_logfolder_Click(object sender, EventArgs e)
{
try
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs");
if (Directory.Exists(logPath))
System.Diagnostics.Process.Start("explorer.exe", logPath);
else
System.Diagnostics.Process.Start("explorer.exe", Environment.CurrentDirectory);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
}
}
private async void syncMarketsaToolStripMenuItem_Click(object? sender, EventArgs e)
{
if (_workerRunning)
{
MessageBox.Show("Market sync cannot be started while background workers are running.",
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
btn_syncmarkets.Enabled = false;
Log.Information("Manual market sync triggered...");
// Use a temporary CTS for this operation
using var cts = new CancellationTokenSource();
await _webServer!.RunSingleMarketSyncAsync(cts.Token);
Log.Information("Manual market sync completed successfully.");
MessageBox.Show("Market sync completed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Manual market sync failed");
MessageBox.Show($"Error syncing markets: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btn_syncmarkets.Enabled = true;
}
}
private async void btn_dbUpdate_Click(object? sender, EventArgs e)
{
if (_workerRunning)
{
MessageBox.Show("Database update cannot be run while background workers are running.",
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
btn_dbUpdate.Enabled = false;
Log.Information("Manual database update triggered...");
await _webServer!.UpdateDatabaseAsync();
Log.Information("Database updated successfully.");
MessageBox.Show("Database update completed successfully.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Manual database update failed");
MessageBox.Show($"Database update failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btn_dbUpdate.Enabled = true;
}
}
private async void btn_recalcAll_Click(object? sender, EventArgs e)
{
if (_workerRunning)
{
MessageBox.Show("Recalculation cannot be started while background workers are running. Stop the server first.",
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var confirm = MessageBox.Show(
"This deletes all DERIVED analytics data (positions, daily snapshots, category stats) " +
"and marks every trader for full recalculation.\n\n" +
"Raw trades and markets are NOT touched.\n\n" +
"After this, start the server: the analytics worker rebuilds every trader with the current " +
"engine (runs in the background, can take several hours for large trader counts).\n\nContinue?",
"Recalculate All Traders", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (confirm != DialogResult.Yes) return;
try
{
btn_recalcAll.Enabled = false;
Log.Information("Manual full recalculation reset triggered...");
using var cts = new CancellationTokenSource();
var summary = await _webServer!.RunRecalculateAllTradersAsync(cts.Token);
MessageBox.Show($"Reset complete:\n\n{summary}\n\nNow start the server to rebuild the analytics.",
"Recalculate All Traders", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Full recalculation reset failed");
MessageBox.Show($"Recalculation reset failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btn_recalcAll.Enabled = true;
}
}
private async Task UpdateDbSizeAsync()
{
try
{
// Build the connection string
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(_settings.ConnectionString);
if (string.IsNullOrWhiteSpace(csBuilder.Database))
return; // Not ready or valid yet
using var conn = new MySqlConnector.MySqlConnection(_settings.ConnectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();";
var result = await cmd.ExecuteScalarAsync();
if (result != DBNull.Value && result != null)
{
var sizeMb = Convert.ToDouble(result);
this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB");
}
}
catch (Exception ex)
{
this.Invoke(() => label_dbSize.Text = "DB Size: Error");
Log.Debug(ex, "Failed to fetch DB size for status bar");
}
}
}
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>162, 17</value>
</metadata>
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>322, 17</value>
</metadata>
</root>
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>Predictalytics.WinFormsHost</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
<ApplicationVisualStyles>true</ApplicationVisualStyles>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. -->
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
</ItemGroup>
<Target Name="CleanupLocalization" AfterTargets="Build">
<ItemGroup>
<LanguageFolders Include="$(TargetDir)cs;$(TargetDir)de;$(TargetDir)es;$(TargetDir)fr;$(TargetDir)it;$(TargetDir)ja;$(TargetDir)ko;$(TargetDir)pl;$(TargetDir)pt-BR;$(TargetDir)ru;$(TargetDir)tr;$(TargetDir)zh-Hans;$(TargetDir)zh-Hant" />
</ItemGroup>
<RemoveDir Directories="@(LanguageFolders)" />
</Target>
</Project>
@@ -1,61 +1,38 @@
using Predictalytics.Infrastructure.Logging; using Predictalytics.Infrastructure.Logging;
using Predictalytics.WinFormsHost.Services;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
namespace Predictalytics.Hosting; namespace Predictalytics.WinFormsHost;
/// <summary> internal static class Program
/// Serilog-Konfiguration der Anwendung. Plattformneutral: der optionale
/// Terminal-Sink bekommt lediglich eine Schreib-Action, das Marshalling auf den
/// UI-Thread liegt beim jeweiligen Host.
/// </summary>
public static class LoggingSetup
{ {
private const string TextTemplate = [STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
// ─── License gate: no usable license, no app ───
var licenseClient = LicenseGuard.EnsureLicensed();
if (licenseClient == null)
{
return;
}
var mainForm = new MainForm();
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
// ─── Output template matching terminal format ───
const string textTemplate =
"[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; "[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
private const string SimpleTemplate = const string simpleTemplate =
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
/// <summary>Umgebungsvariable, mit der das Logverzeichnis festgelegt werden kann.</summary> // ─── Log directory ───
public const string LogDirectoryVariable = "PREDICTALYTICS_LOG_DIR"; var logBaseDir = Path.Combine(AppContext.BaseDirectory, "logs");
/// <summary> Log.Logger = new LoggerConfiguration()
/// Standard-Logverzeichnis: neben den Einstellungen, also
/// <c>%APPDATA%\Predictalytics\logs</c> bzw. <c>~/.config/Predictalytics/logs</c>.
/// <para>
/// Bewusst nicht mehr neben der Programmdatei: deren Verzeichnis ist unter Linux
/// ueblicherweise nicht beschreibbar, und beim Update wuerde es ueberschrieben.
/// Ueber <see cref="LogDirectoryVariable"/> uebersteuerbar — etwa auf
/// <c>/var/log/predictalytics</c> im systemd-Betrieb.
/// </para>
/// </summary>
public static string DefaultLogDirectory
{
get
{
var configured = Environment.GetEnvironmentVariable(LogDirectoryVariable);
return string.IsNullOrWhiteSpace(configured)
? Path.Combine(PredictalyticsOptions.SettingsDirectory, "logs")
: configured;
}
}
/// <summary>
/// Richtet den globalen Serilog-Logger ein.
/// </summary>
/// <param name="terminalSink">
/// Optionale Schreib-Action fuer eine Terminalanzeige im Host. Bekommt die
/// fertig formatierte Zeile und den Level; muss selbst auf den UI-Thread wechseln.
/// </param>
/// <param name="logDirectory">Basisverzeichnis der Logdateien. Standard: <see cref="DefaultLogDirectory"/>.</param>
public static void Configure(
Action<string, LogEventLevel>? terminalSink = null,
string? logDirectory = null)
{
var logBaseDir = logDirectory ?? DefaultLogDirectory;
var config = new LoggerConfiguration()
.MinimumLevel.Information() .MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
@@ -66,20 +43,11 @@ public static class LoggingSetup
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry")) .Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
// ── Console (simple) ── // ── Console (simple) ──
.WriteTo.Console(outputTemplate: SimpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning) .WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
// ── Deployment Center error stream (Error/Fatal) ── // ── RichTextBox Terminal ──
// Wird hier registriert, lange bevor die Einstellungen bekannt sind: der Sink .WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning)
// fragt DcErrorReporter zur Sendezeit, ob das Melden eingeschaltet ist.
.WriteTo.Sink(new DcErrorSink(), restrictedToMinimumLevel: LogEventLevel.Error);
// ── Terminalanzeige des Hosts (optional) ──
if (terminalSink != null)
{
config = config.WriteTo.Sink(new DelegateSink(terminalSink), restrictedToMinimumLevel: LogEventLevel.Warning);
}
config = config
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// FILE SINKS — By Level // FILE SINKS — By Level
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
@@ -88,7 +56,7 @@ public static class LoggingSetup
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "all", "all-.log"), Path.Combine(logBaseDir, "all", "all-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 30, retainedFileCountLimit: 30,
fileSizeLimitBytes: 50_000_000, fileSizeLimitBytes: 50_000_000,
shared: true) shared: true)
@@ -99,7 +67,7 @@ public static class LoggingSetup
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "info", "info-.log"), Path.Combine(logBaseDir, "info", "info-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 14, retainedFileCountLimit: 14,
shared: true)) shared: true))
@@ -109,7 +77,7 @@ public static class LoggingSetup
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "warning", "warning-.log"), Path.Combine(logBaseDir, "warning", "warning-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 30, retainedFileCountLimit: 30,
shared: true)) shared: true))
@@ -119,37 +87,47 @@ public static class LoggingSetup
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "error", "error-.log"), Path.Combine(logBaseDir, "error", "error-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 60, retainedFileCountLimit: 60,
shared: true)) shared: true))
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// FILE SINKS — By Platform // FILE SINKS — By Platform
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// Polymarket
.WriteTo.Logger(lc => lc .WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => HasPlatform(e, "Polymarket")) .Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("Platform") &&
e.Properties["Platform"].ToString().Contains("Polymarket"))
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "platforms", "polymarket-.log"), Path.Combine(logBaseDir, "platforms", "polymarket-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 30, retainedFileCountLimit: 30,
shared: true)) shared: true))
// Limitless
.WriteTo.Logger(lc => lc .WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => HasPlatform(e, "Limitless")) .Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("Platform") &&
e.Properties["Platform"].ToString().Contains("Limitless"))
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "platforms", "limitless-.log"), Path.Combine(logBaseDir, "platforms", "limitless-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 30, retainedFileCountLimit: 30,
shared: true)) shared: true))
// Azuro
.WriteTo.Logger(lc => lc .WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => HasPlatform(e, "Azuro")) .Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("Platform") &&
e.Properties["Platform"].ToString().Contains("Azuro"))
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "platforms", "azuro-.log"), Path.Combine(logBaseDir, "platforms", "azuro-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 30, retainedFileCountLimit: 30,
shared: true)) shared: true))
@@ -163,23 +141,25 @@ public static class LoggingSetup
.WriteTo.File( .WriteTo.File(
Path.Combine(logBaseDir, "workers", "workers-.log"), Path.Combine(logBaseDir, "workers", "workers-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
outputTemplate: TextTemplate, outputTemplate: textTemplate,
retainedFileCountLimit: 14, retainedFileCountLimit: 14,
shared: true)); shared: true))
Log.Logger = config.CreateLogger(); .CreateLogger();
}
private static bool HasPlatform(LogEvent e, string platform) =>
e.Properties.ContainsKey("Platform") &&
e.Properties["Platform"].ToString().Contains(platform);
/// <summary>Startbanner, identisch fuer alle Hosts.</summary>
public static void LogStartupBanner()
{
Log.Warning("══════════════════════════════════════════════════════"); Log.Warning("══════════════════════════════════════════════════════");
Log.Warning(" 🚀 Predictalytics v{Version} — Data retrieval started!", DcConfig.AppVersion); Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!");
Log.Warning(" 📊 First platform report in 5 minutes."); Log.Warning(" 📊 First platform report in 5 minutes.");
Log.Warning("══════════════════════════════════════════════════════"); Log.Warning("══════════════════════════════════════════════════════");
mainForm.Initialize();
// While running: re-check the license every 12 h (revocation/expiry/offline grace).
using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(licenseClient);
System.Windows.Forms.Application.Run(mainForm);
Log.Information("Application shutting down.");
Log.CloseAndFlush();
} }
} }
@@ -0,0 +1,12 @@
{
"profiles": {
"Predictalytics.WinFormsHost": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62271;http://localhost:62272"
}
}
}
@@ -1,85 +1,36 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Predictalytics.Api; using Predictalytics.Api;
using Predictalytics.Api.Endpoints;
using Predictalytics.Worker; using Predictalytics.Worker;
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.Interfaces;
using Predictalytics.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Serilog; using Serilog;
namespace Predictalytics.Hosting; namespace Predictalytics.WinFormsHost.Services;
/// <summary> /// <summary>
/// Plattformneutraler Kern der Anwendung: Lebenszyklus des eingebetteten /// Manages the lifecycle of the embedded Kestrel web server and background workers.
/// Kestrel-Webservers, der Hintergrund-Worker und der manuellen Wartungsaktionen.
/// <para>
/// Enthaelt bewusst keinerlei UI-Bezug — die Avalonia-Shell und der
/// Headless-Modus setzen gleichermassen darauf auf.
/// </para>
/// </summary> /// </summary>
public sealed class PredictalyticsHost public class EmbeddedWebServer
{ {
private WebApplication? _app; private WebApplication? _app;
private Task? _runTask; private Task? _runTask;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private readonly object _lock = new(); private readonly object _lock = new();
public string? ConnectionString { get; set; }
public PredictalyticsHost(PredictalyticsOptions options) => Options = options; public bool DbConnectionDebug { get; set; }
public string? EgressChannelsText { get; set; }
/// <summary>Aktuelle Konfiguration. Aenderungen wirken beim naechsten Start.</summary>
public PredictalyticsOptions Options { get; set; }
/// <summary>Wird gemeldet, wenn sich der Laufzustand aendert (fuer Statusanzeigen).</summary>
public event Action? StateChanged;
public bool WebServerRunning { get; private set; }
public bool WorkersRunning { get; private set; }
private void RaiseStateChanged() => StateChanged?.Invoke();
// ─────────────────────────────────────────────────────────────────────────
// Wartungsaktionen
// ─────────────────────────────────────────────────────────────────────────
public async Task UpdateDatabaseAsync() public async Task UpdateDatabaseAsync()
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug);
services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Options.ConnectionString, Options.DbConnectionDebug);
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, Options.DbConnectionDebug); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug);
}
/// <summary>
/// Groesse der Datenbank in MB, oder null wenn nicht ermittelbar.
/// </summary>
public async Task<double?> GetDatabaseSizeMbAsync(CancellationToken ct = default)
{
try
{
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(Options.ConnectionString);
if (string.IsNullOrWhiteSpace(csBuilder.Database)) return null;
using var conn = new MySqlConnector.MySqlConnection(Options.ConnectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();";
var result = await cmd.ExecuteScalarAsync(ct);
if (result == null || result == DBNull.Value) return null;
return Convert.ToDouble(result);
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to fetch DB size");
return null;
}
} }
/// <summary> /// <summary>
@@ -94,9 +45,7 @@ public sealed class PredictalyticsHost
Log.Warning("🔄 Full trader recalculation reset requested..."); Log.Warning("🔄 Full trader recalculation reset requested...");
var services = new ServiceCollection(); var services = new ServiceCollection();
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug);
services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Options.ConnectionString, Options.DbConnectionDebug);
using var provider = services.BuildServiceProvider(); using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope(); using var scope = provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
@@ -111,21 +60,15 @@ public sealed class PredictalyticsHost
WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct); WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct);
Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled); Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled);
// 1b. Offline Category/Subcategory backfill: Kategorie aus den Event-Tags neu // 1b. Offline Category/Subcategory backfill:
// ableiten, ohne einen einzigen API-Aufruf. // Query all markets that have events with tags in the DB, and re-classify them
// var dbMarkets = await db.Markets.Include(m => m.Event).ToListAsync(ct);
// Die Einschraenkung gehoert in die Abfrage, nicht in die Schleife: ohne sie holt
// der Aufruf den gesamten Marktbestand samt Event in den Speicher, um den Grossteil
// davon sofort wieder zu verwerfen. Das Ergebnis ist identisch - die Schleife hat
// schon vorher nur Maerkte mit gefuellten Event-Tags angefasst.
var dbMarkets = await db.Markets
.Include(m => m.Event)
.Where(m => m.Event != null && m.Event.Tags != null && m.Event.Tags != "")
.ToListAsync(ct);
int categoryBackfilledCount = 0; int categoryBackfilledCount = 0;
foreach (var m in dbMarkets) foreach (var m in dbMarkets)
{ {
var (newCategory, newSubcategory) = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(string.Empty, m.Event!.Tags, m.Question); if (m.Event != null && !string.IsNullOrEmpty(m.Event.Tags))
{
var (newCategory, newSubcategory) = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(string.Empty, m.Event.Tags, m.Question);
if (m.Category != newCategory || m.Subcategory != newSubcategory) if (m.Category != newCategory || m.Subcategory != newSubcategory)
{ {
m.Category = newCategory; m.Category = newCategory;
@@ -133,6 +76,7 @@ public sealed class PredictalyticsHost
categoryBackfilledCount++; categoryBackfilledCount++;
} }
} }
}
if (categoryBackfilledCount > 0) if (categoryBackfilledCount > 0)
{ {
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -166,11 +110,7 @@ public sealed class PredictalyticsHost
return summary; return summary;
} }
// ───────────────────────────────────────────────────────────────────────── public async Task StartWebServerAsync(int port = 5000)
// Webserver
// ─────────────────────────────────────────────────────────────────────────
public async Task StartWebServerAsync()
{ {
lock (_lock) { if (_app != null) return; } lock (_lock) { if (_app != null) return; }
@@ -179,51 +119,40 @@ public sealed class PredictalyticsHost
try try
{ {
var builder = WebApplication.CreateBuilder(); var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls(Options.WebserverUrl); builder.WebHost.UseUrls($"http://localhost:{port}");
var effectiveConnString = Options.ConnectionString; string? effectiveConnString = ConnectionString;
if (!string.IsNullOrEmpty(effectiveConnString) && if (!string.IsNullOrEmpty(effectiveConnString) && (effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
(effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
{ {
throw new InvalidOperationException("Connection string contains an empty 'Database' value."); throw new InvalidOperationException("Connection string contains an empty 'Database' value.");
} }
Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(effectiveConnString ?? "NULL", "Password=[^;]+", "Password=****"));
MaskPassword(effectiveConnString)); Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(builder.Services, builder.Configuration, effectiveConnString, DbConnectionDebug);
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
builder.Services, builder.Configuration, effectiveConnString, Options.DbConnectionDebug);
ApplyEgressOptions(builder.Services); if (!string.IsNullOrEmpty(EgressChannelsText))
{
var egressOptions = ParseEgressOptions(EgressChannelsText);
builder.Services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
{
options.Channels = egressOptions.Channels;
});
}
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" })); new() { Title = "Predictalytics Analytics API", Version = "v1" }));
// Die eigene Bind-URL ist immer erlaubt; zusaetzliche Herkuenfte koennen var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
// ueber appsettings.json ergaenzt werden. ?? new[] { "http://localhost:5000" };
var allowedOrigins = (builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>() ?? [])
.Append(Options.WebserverUrl)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
builder.Services.AddCors(o => o.AddDefaultPolicy(p => builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader())); p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
builder.Host.UseSerilog(); builder.Host.UseSerilog();
var app = builder.Build(); var app = builder.Build();
app.UseCors(); app.UseCors();
// Swagger legt die gesamte API-Oberflaeche offen — das ist im lokalen
// Betrieb praktisch und bei einer Bindung ans Netz eine Preisgabe.
if (!Options.IsPubliclyBound)
{
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
}
else
{
Log.Information("Swagger ist deaktiviert, weil der Webserver über {Host} erreichbar ist.",
Options.WebserverHost);
}
var wwwrootPath = FindWwwrootPath(); var wwwrootPath = FindWwwrootPath();
if (wwwrootPath != null) if (wwwrootPath != null)
@@ -234,29 +163,26 @@ public sealed class PredictalyticsHost
// Internal dashboard: always revalidate so UI fixes reach the browser immediately. // Internal dashboard: always revalidate so UI fixes reach the browser immediately.
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache" OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
}); });
app.MapGet("/", () => Results.File(Path.Combine(wwwrootPath, "index.html"), "text/html")); app.MapGet("/", () => Results.File(
Path.Combine(wwwrootPath, "index.html"), "text/html"));
} }
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints. // Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
// Do NOT map endpoints individually here. // Do NOT map endpoints individually here.
app.MapPredictalyticsReadEndpoints(); app.MapPredictalyticsReadEndpoints();
MapControlEndpointsGuarded(app); app.MapPredictalyticsControlEndpoints();
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, Options.DbConnectionDebug); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);
lock (_lock) { _app = app; } lock (_lock) { _app = app; }
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
_runTask = app.RunAsync(_cts.Token); _runTask = app.RunAsync(_cts.Token);
WebServerRunning = true; Log.Information("Kestrel webserver started on port {Port}", port);
RaiseStateChanged();
Log.Information("Kestrel webserver started on {Url}", Options.WebserverUrl);
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Failed to start embedded web server"); Log.Error(ex, "Failed to start embedded web server");
WebServerRunning = false;
RaiseStateChanged();
throw; throw;
} }
}); });
@@ -272,51 +198,36 @@ public sealed class PredictalyticsHost
_cts?.Cancel(); _cts?.Cancel();
try { await app.StopAsync(TimeSpan.FromSeconds(5)); } try { await app.StopAsync(TimeSpan.FromSeconds(5)); }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
await ((IAsyncDisposable)app).DisposeAsync(); await (app as IAsyncDisposable).DisposeAsync();
Log.Information("Kestrel webserver stopped"); Log.Information("Kestrel webserver stopped");
} }
WebServerRunning = false;
RaiseStateChanged();
} }
// ─────────────────────────────────────────────────────────────────────────
// Worker
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Startet die Hintergrund-Worker und laeuft, bis <paramref name="ct"/> ausgeloest wird.
/// </summary>
public async Task StartWorkersAsync(CancellationToken ct) public async Task StartWorkersAsync(CancellationToken ct)
{ {
Log.Information("Starting workers (no web server)..."); Log.Information("Starting workers (no web server)...");
WorkersRunning = true;
RaiseStateChanged();
try
{
var host = Host.CreateDefaultBuilder() var host = Host.CreateDefaultBuilder()
.UseSerilog() .UseSerilog()
.ConfigureServices((ctx, services) => .ConfigureServices((ctx, services) =>
{ {
Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", Serilog.Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(ConnectionString ?? "NULL", "Password=[^;]+", "Password=****"));
MaskPassword(Options.ConnectionString)); Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( if (!string.IsNullOrEmpty(EgressChannelsText))
services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug); {
ApplyEgressOptions(services); var egressOptions = ParseEgressOptions(EgressChannelsText);
services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
{
options.Channels = egressOptions.Channels;
});
}
services.AddWorkerServices(); services.AddWorkerServices();
}) })
.Build(); .Build();
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, Options.DbConnectionDebug); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, DbConnectionDebug);
await host.RunAsync(ct); await host.RunAsync(ct);
} }
finally
{
WorkersRunning = false;
RaiseStateChanged();
}
}
/// <summary> /// <summary>
/// Performs a one-time full market sync for all supported platforms. /// Performs a one-time full market sync for all supported platforms.
@@ -329,8 +240,7 @@ public sealed class PredictalyticsHost
.UseSerilog() .UseSerilog()
.ConfigureServices((ctx, services) => .ConfigureServices((ctx, services) =>
{ {
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug);
}) })
.Build(); .Build();
@@ -395,73 +305,19 @@ public sealed class PredictalyticsHost
} }
} }
// ─────────────────────────────────────────────────────────────────────────
// Intern
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Entscheidet, ob und wie die schreibenden Endpunkte bereitgestellt werden.
/// <para>
/// Bei einer Bindung an localhost schuetzt das Betriebssystem — dort bleibt es beim
/// bisherigen Verhalten. Sobald der Dienst ueber das Netz erreichbar ist, gilt:
/// ohne Token keine Steuerbefehle. Bewusst so herum, damit eine unbedachte Umstellung
/// der Bind-Adresse nicht stillschweigend Jobs, Trader-Steuerung und die
/// Dev-Endpunkte oeffnet.
/// </para>
/// </summary>
private void MapControlEndpointsGuarded(WebApplication app)
{
var token = Options.ApiControlToken?.Trim();
var hasToken = !string.IsNullOrWhiteSpace(token);
if (!Options.IsPubliclyBound)
{
app.MapPredictalyticsControlEndpoints(hasToken ? token : null);
Log.Information("Steuerendpunkte aktiv ({Protection}) — Bindung an {Host}.",
hasToken ? "tokengeschützt" : "ungeschützt, nur lokal erreichbar", Options.WebserverHost);
return;
}
if (!hasToken)
{
Log.Error("🔒 Der Webserver ist an {Host} gebunden und damit über das Netz erreichbar, " +
"aber es ist kein API-Token gesetzt. Die schreibenden Endpunkte (Jobs, " +
"Trader-Steuerung, Dev) werden deshalb NICHT bereitgestellt. Token in den " +
"Einstellungen hinterlegen oder wieder an localhost binden.", Options.WebserverHost);
return;
}
app.MapPredictalyticsControlEndpoints(token);
Log.Warning("🔒 Webserver ist über {Host} erreichbar. Steuerendpunkte verlangen den Header {Header}.",
Options.WebserverHost, Predictalytics.Api.Security.ApiTokenFilter.HeaderName);
}
private static string MaskPassword(string? connectionString) =>
System.Text.RegularExpressions.Regex.Replace(connectionString ?? "NULL", "Password=[^;]+", "Password=****");
private void ApplyEgressOptions(IServiceCollection services)
{
if (string.IsNullOrEmpty(Options.EgressChannelsText)) return;
var egressOptions = ParseEgressOptions(Options.EgressChannelsText);
services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(o =>
{
o.Channels = egressOptions.Channels;
});
}
/// <summary>
/// Sucht das wwwroot-Verzeichnis. Im Deployment liegt es neben der Programmdatei
/// (siehe Content-Eintrag in der Host-csproj); der zweite Kandidat greift beim
/// Entwicklungslauf direkt aus bin/&lt;config&gt;/&lt;tfm&gt;/ heraus.
/// </summary>
private static string? FindWwwrootPath() private static string? FindWwwrootPath()
{ {
var baseDir = AppContext.BaseDirectory; var baseDir = AppContext.BaseDirectory;
// Try to find the src root by traversing up from the build output
// Typical: src/Predictalytics.WinFormsHost/bin/Debug/net8.0-windows/
var candidates = new[] var candidates = new[]
{ {
Path.Combine(baseDir, "wwwroot"), Path.Combine(baseDir, "wwwroot"),
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")) // From bin/Debug/net8.0-windows/ up to src/, then into Api/wwwroot
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")),
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Api", "wwwroot")),
// Absolute fallback for this specific workspace
@"j:\Softwareprojekte\Predictalytics\Predictalytics\src\Predictalytics.Api\wwwroot"
}; };
foreach (var path in candidates) foreach (var path in candidates)
@@ -478,7 +334,7 @@ public sealed class PredictalyticsHost
return null; return null;
} }
private static Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text) private Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text)
{ {
var options = new Predictalytics.Infrastructure.Configuration.EgressOptions(); var options = new Predictalytics.Infrastructure.Configuration.EgressOptions();
if (string.IsNullOrWhiteSpace(text)) return options; if (string.IsNullOrWhiteSpace(text)) return options;
@@ -0,0 +1,118 @@
using LicenseLabrador.Client;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Modal dialog shown at startup when no usable license is present.
/// Lets the user enter/activate a license key; closes with OK only after a
/// successful, checksum-verified validation.
/// </summary>
public sealed class LicenseDialog : Form
{
private readonly LicenseClient _client;
private readonly Label _lblStatus;
private readonly TextBox _txtKey;
private readonly Button _btnActivate;
private readonly Button _btnExit;
public LicenseResult? Result { get; private set; }
public LicenseDialog(LicenseClient client, LicenseResult? lastResult)
{
_client = client;
Text = "Predictalytics — Lizenzaktivierung";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
ClientSize = new Size(460, 190);
var lblInfo = new Label
{
Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):",
Location = new Point(12, 12),
Size = new Size(436, 34)
};
_txtKey = new TextBox
{
Location = new Point(12, 52),
Size = new Size(436, 26),
Font = new Font("Consolas", 11f),
CharacterCasing = CharacterCasing.Upper
};
_lblStatus = new Label
{
Location = new Point(12, 84),
Size = new Size(436, 50),
ForeColor = Color.Firebrick,
Text = FormatInitialStatus(lastResult)
};
_btnActivate = new Button
{
Text = "Aktivieren",
Location = new Point(252, 146),
Size = new Size(96, 30)
};
_btnActivate.Click += async (_, _) => await ActivateAsync();
_btnExit = new Button
{
Text = "Beenden",
Location = new Point(354, 146),
Size = new Size(94, 30),
DialogResult = DialogResult.Cancel
};
AcceptButton = _btnActivate;
CancelButton = _btnExit;
Controls.AddRange(new Control[] { lblInfo, _txtKey, _lblStatus, _btnActivate, _btnExit });
}
private static string FormatInitialStatus(LicenseResult? lastResult)
{
if (lastResult == null || lastResult.State == LicenseState.NoLicense) return "";
return $"Letzte Prüfung: {lastResult.State} — {lastResult.Message}";
}
private async Task ActivateAsync()
{
var key = _txtKey.Text.Trim();
if (string.IsNullOrWhiteSpace(key))
{
_lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben.";
return;
}
_btnActivate.Enabled = false;
_lblStatus.ForeColor = Color.DimGray;
_lblStatus.Text = "Prüfe Lizenz am Server...";
try
{
var result = await _client.ValidateAsync(key);
if (result.IsUsable && _client.VerifyChecksum(result))
{
Result = result;
DialogResult = DialogResult.OK;
Close();
return;
}
_lblStatus.ForeColor = Color.Firebrick;
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.State}):\n{result.Message}";
}
catch (Exception ex)
{
_lblStatus.ForeColor = Color.Firebrick;
_lblStatus.Text = $"Fehler bei der Prüfung: {ex.Message}";
}
finally
{
_btnActivate.Enabled = true;
}
}
}
@@ -0,0 +1,102 @@
using LicenseLabrador.Client;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Startup license gate backed by the LicenseLabrador server.
/// Endpoint, product slug and the Ed25519 public key are deliberately compiled in
/// (not user configuration): a configurable endpoint/key would let anyone point the
/// app at a fake license server.
/// </summary>
public static class LicenseGuard
{
private const string ProductSlug = "predictalytics";
private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U=";
private const string BasicAuthUser = "Labrador";
private const string BasicAuthPassword = "Labrador02763!";
// HTTPS ist Pflicht, nicht Kosmetik: license.mhdf.de leitet http→https um, und .NET
// macht bei einem Redirect aus dem POST ein GET. Der Server antwortet darauf mit 405,
// das SDK wertet das als "unerreichbar" und meldet irrefuehrend NoLicense.
// Ausserdem gingen die BasicAuth-Credentials sonst im Klartext ueber die Leitung.
private static readonly string[] Endpoints = { "https://license.mhdf.de/public/api/v1" };
/// <summary>Re-check interval while the app is running (12 h).</summary>
public const int RevalidationIntervalMs = 12 * 60 * 60 * 1000;
public static LicenseClient CreateClient()
{
var config = new LicenseConfig
{
ProductSlug = ProductSlug,
PublicKeyBase64 = PublicKeyBase64,
Endpoints = Endpoints,
HttpBasicAuthUser = BasicAuthUser,
HttpBasicAuthPassword = BasicAuthPassword,
OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig
};
return new LicenseClient(config);
}
/// <summary>
/// Blocks until a usable license is present. Tries the cached key first; otherwise
/// (or when the cached key is no longer usable) shows the license dialog.
/// Returns null if the user gave up — the app must exit then.
/// </summary>
public static LicenseClient? EnsureLicensed()
{
var client = CreateClient();
var result = client.RevalidateAsync().GetAwaiter().GetResult();
if (result.IsUsable && client.VerifyChecksum(result))
{
return client;
}
using var dialog = new LicenseDialog(client, result);
if (dialog.ShowDialog() != DialogResult.OK)
{
return null;
}
return client;
}
/// <summary>
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app
/// keeps running; on a definitively unusable license the app is shut down.
/// </summary>
public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseClient client)
{
var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs };
timer.Tick += async (_, _) =>
{
try
{
var result = await client.RevalidateAsync();
if (result.IsUsable && client.VerifyChecksum(result))
{
if (result.State == LicenseState.ValidOffline)
{
Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", result.GraceUntil);
}
return;
}
timer.Stop();
Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", result.State, result.Message);
MessageBox.Show(
$"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.",
"Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop);
System.Windows.Forms.Application.Exit();
}
catch (Exception ex)
{
// Transient errors (network etc.) are handled by the SDK's offline grace —
// never kill the app from an unexpected exception here.
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
}
};
timer.Start();
return timer;
}
}
@@ -0,0 +1,141 @@
using System.Text;
using System.Text.Json;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
/// (POST /api/heartbeat) so an outage of this app — or the whole machine — raises
/// an alarm. A Watchdog outage must never impact the app: every call is best effort.
/// </summary>
public sealed class WatchdogHeartbeatService : IDisposable
{
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
private readonly string _baseUrl;
private readonly string _apiKey;
private readonly string _source;
private readonly string _instance;
private readonly int _intervalSeconds;
private readonly Func<object?>? _metadataProvider;
private readonly DateTime _startedUtc = DateTime.UtcNow;
private System.Threading.Timer? _timer;
private bool _lastSendFailed;
public WatchdogHeartbeatService(
string baseUrl,
string apiKey,
string source,
string instance,
int intervalSeconds,
Func<object?>? metadataProvider = null)
{
_baseUrl = baseUrl.TrimEnd('/');
_apiKey = apiKey;
_source = source;
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
_intervalSeconds = Math.Max(15, intervalSeconds);
_metadataProvider = metadataProvider;
}
public void Start()
{
_timer?.Dispose();
_timer = new System.Threading.Timer(
async _ => await SendHeartbeatAsync("ok"),
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
Log.Information("🐕 Watchdog heartbeat started → {Url} (source={Source}, every {Interval}s)",
_baseUrl, _source, _intervalSeconds);
}
/// <summary>
/// Reports a planned shutdown so it is not alarmed as a crash.
/// Must be "stopped_graceful": the server's event_log.kind ENUM has no "stopping"
/// value, and that variant fails the insert with HTTP 500 after the state update.
/// </summary>
public void NotifyStopping()
{
try
{
var payload = new
{
source = _source,
instance = _instance,
kind = "stopped_graceful",
severity = "info",
message = "Predictalytics wird planmäßig beendet."
};
// Synchronous with a short cap: the form is closing and must not hang.
PostAsync("/api/event", payload).Wait(TimeSpan.FromSeconds(4));
}
catch
{
// Best effort only.
}
}
private async Task SendHeartbeatAsync(string status)
{
try
{
var payload = new
{
source = _source,
instance = _instance,
type = "heartbeat",
status,
message = (string?)null,
metrics = new
{
uptimeSec = (long)(DateTime.UtcNow - _startedUtc).TotalSeconds,
app = _metadataProvider?.Invoke()
},
group = "C# Applications",
interval = _intervalSeconds
};
await PostAsync("/api/heartbeat", payload);
if (_lastSendFailed)
{
_lastSendFailed = false;
Log.Information("🐕 Watchdog heartbeat wieder erfolgreich zugestellt.");
}
}
catch (Exception ex)
{
// Log the first failure as warning, subsequent ones quietly (no log flood).
if (!_lastSendFailed)
{
_lastSendFailed = true;
Log.Warning("🐕 Watchdog heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
}
else
{
Log.Debug(ex, "Watchdog heartbeat failed");
}
}
}
private async Task PostAsync(string path, object payload)
{
using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
request.Headers.Add("X-Watchdog-Key", _apiKey);
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Watchdog API HTTP {(int)response.StatusCode}: {body}");
}
}
public void Dispose()
{
_timer?.Dispose();
_timer = null;
_http.Dispose();
}
}
@@ -0,0 +1,52 @@
using Serilog.Events;
namespace Predictalytics.WinFormsHost;
/// <summary>
/// Creates a thread-safe write action for the RichTextBox terminal.
/// </summary>
public static class TerminalHelper
{
public static Action<string, LogEventLevel> CreateWriteAction(RichTextBox rtb, Control owner)
{
int lineCount = 0;
const int maxLines = 500;
return (message, level) =>
{
if (owner.IsDisposed || rtb.IsDisposed) return;
try
{
owner.BeginInvoke(() =>
{
if (rtb.IsDisposed) return;
lineCount++;
if (lineCount > maxLines)
{
rtb.Clear();
lineCount = 0;
rtb.AppendText("[Terminal cleared — log continues]\n");
}
var color = level switch
{
LogEventLevel.Error or LogEventLevel.Fatal => System.Drawing.Color.FromArgb(255, 82, 82),
LogEventLevel.Warning => System.Drawing.Color.FromArgb(255, 193, 7),
LogEventLevel.Debug => System.Drawing.Color.FromArgb(158, 158, 158),
_ => System.Drawing.Color.FromArgb(76, 175, 80)
};
rtb.SelectionStart = rtb.TextLength;
rtb.SelectionLength = 0;
rtb.SelectionColor = color;
rtb.AppendText(message);
rtb.SelectionColor = rtb.ForeColor;
rtb.ScrollToCaret();
});
}
catch { /* UI thread shutting down */ }
};
}
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Predictalytics.Shell"/> <assemblyIdentity version="1.0.0.0" name="Predictalytics.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security> <security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
@@ -13,10 +13,4 @@
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application> </application>
</compatibility> </compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly> </assembly>
@@ -0,0 +1,48 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=Predictalytics_dev;User=root;Password="
},
"WebServer": {
"Port": 5000
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"OpenRouter": {
"ApiKey": "",
"BaseUrl": "https://openrouter.ai/api/v1",
"DefaultModel": "google/gemini-flash-1.5",
"ManualAnalysisModel": "anthropic/claude-3-opus"
},
"PlatformSettings": {
"Polymarket": {
"EnableCrawling": true
},
"Limitless": {
"EnableCrawling": false
},
"Azuro": {
"EnableCrawling": false
}
},
"Egress": {
"Channels": []
},
"ApiSettings": {
"CanControl": true,
"AuthRequired": false,
"AllowedOrigins": [ "http://localhost:5000" ],
"ReadOnlyDatabase": false
},
"RetentionSettings": {
"Enabled": true,
"RetentionDays": 180,
"CompactionDays": 14,
"MaxDatabaseSizeGb": 90.0,
"MinRetentionDays": 30
}
}
@@ -1,11 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Worker</RootNamespace> <RootNamespace>Predictalytics.Worker</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -149,7 +149,7 @@ public class TraderAnalyticsWorker : BackgroundService
else else
{ {
var trades = await db.Trades var trades = await db.Trades
.Include(t => t.MarketOutcome).ThenInclude(o => o!.Market) .Include(t => t.MarketOutcome).ThenInclude(o => o.Market)
.Include(t => t.DbMarket) .Include(t => t.DbMarket)
.Include(t => t.Context) .Include(t => t.Context)
.Where(t => t.TraderId == id && t.DbMarketId != null) .Where(t => t.TraderId == id && t.DbMarketId != null)
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
pre { margin: 0; }
</style>
</helmet>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.22), transparent 60%), #0a0d14; color:#EDEFF5;">
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">{{ brandName }}<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="./Landing.dc.html#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="./Landing.dc.html#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Pricing</a>
<a href="./Landing.dc.html#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<div style="font-size:13.5px; font-weight:700; color:#5b9dff;">API Docs</div>
</nav>
<a href="./Landing.dc.html" style="padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff);">Get API key</a>
</header>
<div style="display:flex; max-width:1280px; margin:0 auto;">
<!-- SIDEBAR -->
<aside style="width:230px; flex:none; padding:36px 16px; position:sticky; top:68px; align-self:flex-start; height:calc(100vh - 68px); overflow-y:auto;">
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:0 12px 8px;">GETTING STARTED</div>
<sc-for list="{{ sidebarTop }}" as="s" hint-placeholder-count="3">
<div onClick="{{ s.onClick }}" style="cursor:pointer; padding:9px 12px; border-radius:9px; font-size:13.5px; font-weight:600; color:{{ s.color }}; background:{{ s.bg }}; margin-bottom:2px;">{{ s.label }}</div>
</sc-for>
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:16px 12px 8px;">ENDPOINTS</div>
<sc-for list="{{ sidebarEndpoints }}" as="s" hint-placeholder-count="6">
<div onClick="{{ s.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:8px; padding:9px 12px; border-radius:9px; font-size:13px; font-weight:600; color:{{ s.color }}; background:{{ s.bg }}; margin-bottom:2px;">
<span style="font-size:10px; font-weight:800; font-family:'Roboto Mono'; color:{{ s.methodColor }};">{{ s.method }}</span>
<span>{{ s.label }}</span>
</div>
</sc-for>
</aside>
<!-- CONTENT -->
<main style="flex:1; min-width:0; padding:36px 40px 100px;">
<h1 style="margin:0 0 10px; font-size:30px; font-weight:800; letter-spacing:-0.02em;">API Reference</h1>
<p style="margin:0 0 32px; color:#8B93A7; font-size:14.5px; max-width:640px; line-height:1.6;">Programmatic access to every trader and market metric in {{ brandName }}. All endpoints return JSON over HTTPS.</p>
<!-- Auth -->
<section style="margin-bottom:40px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Authentication</h2>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.7; margin:0 0 14px;">Pass your API key as a bearer token on every request. Keys are available from your account dashboard after signing up.</p>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:18px 20px; font-family:'Roboto Mono'; font-size:13px; color:#C7CCDA; overflow-x:auto;">
<pre>curl https://api.predictalytics.io/v1/markets \
-H "Authorization: Bearer YOUR_API_KEY"</pre>
</div>
</section>
<!-- Rate limits -->
<section style="margin-bottom:40px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Rate limits</h2>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:14px;">
<sc-for list="{{ rateLimits }}" as="r" hint-placeholder-count="3">
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">{{ r.plan }}</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">{{ r.limit }}</div>
</div>
</sc-for>
</div>
</section>
<!-- Endpoints -->
<sc-for list="{{ endpoints }}" as="ep" hint-placeholder-count="6">
<section id="{{ ep.id }}" style="margin-bottom:40px; scroll-margin-top:80px;">
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;">
<span style="font-size:11px; font-weight:800; font-family:'Roboto Mono'; padding:4px 9px; border-radius:6px; background:{{ ep.methodBg }}; color:{{ ep.methodColor }};">{{ ep.method }}</span>
<span style="font-family:'Roboto Mono'; font-size:14.5px; font-weight:700; color:#EDEFF5;">{{ ep.path }}</span>
</div>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.6; margin:0 0 14px;">{{ ep.desc }}</p>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:14px;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:16px 18px;">
<div style="font-size:11px; font-weight:700; color:#5B6377; text-transform:uppercase; margin-bottom:10px;">Parameters</div>
<sc-for list="{{ ep.params }}" as="p" hint-placeholder-count="2">
<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px; font-size:12.5px;">
<span style="font-family:'Roboto Mono'; font-weight:700; color:#5b9dff;">{{ p.name }}</span>
<span style="color:#5B6377; font-size:11.5px;">{{ p.type }}</span>
<span style="color:#8B93A7;">{{ p.desc }}</span>
</div>
</sc-for>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px; font-family:'Roboto Mono'; font-size:12px; color:#C7CCDA; overflow-x:auto;">
<pre>{{ ep.example }}</pre>
</div>
</div>
</section>
</sc-for>
</main>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;: {&quot;width&quot;: 1440}, &quot;brandName&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;Predictalytics&quot;, &quot;tsType&quot;: &quot;string&quot;}}">
class Component extends DCLogic {
scrollTo(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ block: "start" });
}
renderVals() {
const brandName = this.props.brandName ?? "Predictalytics";
const endpoints = [
{ id: "get-markets", method: "GET", path: "/v1/markets", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "List all markets with current price, volume, and liquidity.",
params: [{ name: "category", type: "string", desc: "Filter by category" }, { name: "status", type: "string", desc: "active | resolved" }],
example: `{\n "id": "fed-sep-cut",\n "question": "Fed cuts rates...",\n "yesPrice": 71.4,\n "volume24h": 2840000\n}` },
{ id: "get-market", method: "GET", path: "/v1/markets/{id}", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Get detailed data for a single market including order book and holders.",
params: [{ name: "id", type: "string", desc: "Market identifier" }],
example: `{\n "orderBook": { "bids": [...], "asks": [...] },\n "holders": [ { "trader": "0xA1...", "size": 420000 } ]\n}` },
{ id: "get-traders", method: "GET", path: "/v1/traders", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Search and list traders with P&L, win rate, and traits.",
params: [{ name: "trait", type: "string", desc: "Bot | Arbitrage Trader | Resolution Farming" }, { name: "sort", type: "string", desc: "pnl | volume" }],
example: `{\n "handle": "quant_owl",\n "traits": ["Arbitrage Trader"],\n "winRate": 68.4\n}` },
{ id: "get-trader", method: "GET", path: "/v1/traders/{id}", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Get a trader's full position history and connected wallets.",
params: [{ name: "id", type: "string", desc: "Trader handle or wallet" }],
example: `{\n "positions": [...],\n "connectedWallets": ["0x88Ac...2f01"]\n}` },
{ id: "post-alerts", method: "POST", path: "/v1/alerts", methodBg: "rgba(22,82,240,0.16)", methodColor: "#5b9dff",
desc: "Create an alert on a trader or market.",
params: [{ name: "target", type: "string", desc: "Trader or market id" }, { name: "condition", type: "string", desc: "price_move | new_position" }],
example: `{\n "id": "alert_881",\n "target": "arb_meridian",\n "condition": "new_position"\n}` },
{ id: "get-agent", method: "GET", path: "/v1/mcp/context", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Enterprise only. MCP-compatible context feed for your own AI agent.",
params: [{ name: "scope", type: "string", desc: "markets | traders | both" }],
example: `{\n "mcp_version": "1.0",\n "resources": ["markets", "traders"]\n}` },
];
const sidebarTop = [
{ label: "Authentication", color: "#C7CCDA", bg: "transparent", onClick: () => this.scrollTo("auth") },
{ label: "Rate limits", color: "#C7CCDA", bg: "transparent", onClick: () => this.scrollTo("rate-limits") },
];
const sidebarEndpoints = endpoints.map((ep) => ({
label: ep.path, method: ep.method, methodColor: ep.methodColor, color: "#C7CCDA", bg: "transparent",
onClick: () => this.scrollTo(ep.id),
}));
const rateLimits = [
{ plan: "Free", limit: "20 / day" },
{ plan: "Basic", limit: "200 / day" },
{ plan: "Enterprise", limit: "2,000 / day" },
];
return { brandName, endpoints, sidebarTop, sidebarEndpoints, rateLimits };
}
}
</script>
</body>
</html>
+306
View File
@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
</style>
</helmet>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5; overflow-x:hidden;">
<!-- NAV -->
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">{{ brandName }}<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Pricing</a>
<a href="#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<a href="./API Docs.dc.html" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">API Docs</a>
</nav>
<div style="display:flex; align-items:center; gap:10px;">
<div onClick="{{ openLogin }}" style="cursor:pointer; padding:9px 18px; border-radius:10px; font-size:13.5px; font-weight:700; color:#EDEFF5;">Log in</div>
<div onClick="{{ openRegister }}" style="cursor:pointer; padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 4px 20px rgba(22,82,240,0.4);">Sign up</div>
</div>
</header>
<!-- HERO -->
<section style="padding:100px 48px 80px; max-width:1080px; margin:0 auto; text-align:center; display:flex; flex-direction:column; align-items:center; gap:22px;">
<div style="padding:6px 14px; border-radius:20px; background:rgba(22,82,240,0.12); border:1px solid rgba(22,82,240,0.3); font-size:12.5px; font-weight:700; color:#5b9dff;">Live on-chain analytics · Polymarket &amp; beyond</div>
<h1 style="margin:0; font-size:52px; font-weight:800; letter-spacing:-0.03em; line-height:1.08; max-width:760px;">See every trader and market before the crowd does</h1>
<p style="margin:0; font-size:17px; color:#8B93A7; max-width:600px; line-height:1.6;">Deep trader profiling, wallet clustering, and real-time market analytics for professional prediction market traders.</p>
<div style="display:flex; gap:14px; margin-top:8px;">
<div onClick="{{ openRegister }}" style="cursor:pointer; padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 30px rgba(22,82,240,0.4);">Start free</div>
<a href="#features" style="padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; color:#EDEFF5; border:1px solid rgba(255,255,255,0.14); background:rgba(255,255,255,0.04); white-space:nowrap;">See features</a>
</div>
<!-- product preview mock -->
<div style="margin-top:40px; width:100%; border-radius:20px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 20px 60px rgba(0,0,0,0.4); padding:20px; display:grid; grid-template-columns:1.4fr 1fr; gap:14px; text-align:left;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7; margin-bottom:10px;">Yes Price History</div>
<svg viewBox="0 0 500 140" style="width:100%; height:140px;">
<defs>
<linearGradient id="hfill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
</linearGradient>
</defs>
<path d="{{ heroArea }}" fill="url(#hfill)" stroke="none"></path>
<path d="{{ heroLine }}" fill="none" stroke="#12D48A" stroke-width="2.5"></path>
</svg>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px; display:flex; flex-direction:column; gap:10px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7;">Top Traders</div>
<sc-for list="{{ heroTraders }}" as="t" hint-placeholder-count="3">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:24px; height:24px; border-radius:7px; background:{{ t.bg }};"></div>
<div style="font-size:12.5px; font-weight:700; flex:1;">{{ t.handle }}</div>
<div style="font-size:12px; font-family:'Roboto Mono'; font-weight:700; color:#12D48A;">{{ t.pnl }}</div>
</div>
</sc-for>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" style="padding:80px 48px; max-width:1200px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FEATURES</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Everything you need to read the market</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Built for quants who need signal, not noise.</p>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:18px;">
<sc-for list="{{ features }}" as="f" hint-placeholder-count="6">
<div style="padding:26px; border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px);">
<div style="width:42px; height:42px; border-radius:12px; background:{{ f.iconBg }}; margin-bottom:16px; display:flex; align-items:center; justify-content:center;">
<div style="width:16px; height:16px; border-radius:5px; background:{{ f.dotColor }};"></div>
</div>
<div style="font-size:16px; font-weight:700; margin-bottom:8px;">{{ f.title }}</div>
<div style="font-size:13.5px; color:#8B93A7; line-height:1.6;">{{ f.desc }}</div>
</div>
</sc-for>
</div>
</section>
<!-- PRICING -->
<section id="pricing" style="padding:80px 48px; max-width:1160px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">PRICING</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Simple, usage-based plans</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Upgrade any time as your call volume grows.</p>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px; align-items:stretch;">
<sc-for list="{{ plans }}" as="p" hint-placeholder-count="3">
<div style="{{ p.cardStyle }}">
<sc-if value="{{ p.featured }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; top:-13px; left:50%; transform:translateX(-50%); padding:5px 14px; border-radius:20px; background:linear-gradient(135deg,#1652F0,#4c8dff); font-size:11px; font-weight:800; letter-spacing:0.04em;">MOST POPULAR</div>
</sc-if>
<div style="font-size:15px; font-weight:700; margin-bottom:6px;">{{ p.name }}</div>
<div style="display:flex; align-items:baseline; gap:4px; margin-bottom:18px;">
<div style="font-size:36px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">{{ p.price }}</div>
<div style="font-size:13px; color:#8B93A7;">{{ p.period }}</div>
</div>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
<sc-for list="{{ p.items }}" as="item" hint-placeholder-count="4">
<div style="display:flex; align-items:center; gap:9px; font-size:13.5px; color:#C7CCDA;">
<div style="width:6px; height:6px; border-radius:50%; background:#12D48A; flex:none;"></div>
<span>{{ item }}</span>
</div>
</sc-for>
</div>
<div onClick="{{ openRegister }}" style="{{ p.btnStyle }}">{{ p.btnLabel }}</div>
</div>
</sc-for>
</div>
</section>
<!-- FAQ -->
<section id="faq" style="padding:80px 48px 100px; max-width:820px; margin:0 auto;">
<div style="text-align:center; margin-bottom:44px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FAQ</div>
<h2 style="margin:0; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Common questions</h2>
</div>
<div style="display:flex; flex-direction:column; gap:10px;">
<sc-for list="{{ faqs }}" as="q" hint-placeholder-count="6">
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); overflow:hidden;">
<div onClick="{{ q.onToggle }}" style="cursor:pointer; padding:18px 22px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="font-size:14.5px; font-weight:700;">{{ q.question }}</div>
<div style="flex:none; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:16px; color:#5b9dff; transform:{{ q.iconTransform }};">+</div>
</div>
<sc-if value="{{ q.open }}" hint-placeholder-val="{{ false }}">
<div style="padding:0 22px 18px; font-size:13.5px; color:#8B93A7; line-height:1.7;">{{ q.answer }}</div>
</sc-if>
</div>
</sc-for>
</div>
</section>
<!-- FOOTER -->
<footer style="padding:32px 48px; border-top:1px solid rgba(255,255,255,0.07); display:flex; align-items:center; justify-content:space-between; color:#5B6377; font-size:12.5px;">
<div>© 2026 {{ brandName }}. All rights reserved.</div>
<div style="display:flex; gap:20px;">
<a href="./API Docs.dc.html" style="color:#5B6377;">API Docs</a>
<a href="#faq" style="color:#5B6377;">FAQ</a>
<a href="#pricing" style="color:#5B6377;">Pricing</a>
</div>
</footer>
<!-- AUTH MODAL -->
<sc-if value="{{ authOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeAuth }}" style="position:fixed; inset:0; z-index:100; background:rgba(6,8,13,0.7); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center;">
<div onClick="{{ stopProp }}" style="width:400px; max-width:92vw; border-radius:20px; background:rgba(16,20,29,0.9); border:1px solid rgba(255,255,255,0.1); backdrop-filter:blur(30px); box-shadow:0 30px 80px rgba(0,0,0,0.5); padding:32px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
<div style="font-size:19px; font-weight:800;">{{ authTitle }}</div>
<div onClick="{{ closeAuth }}" style="cursor:pointer; width:28px; height:28px; border-radius:8px; background:rgba(255,255,255,0.06); display:flex; align-items:center; justify-content:center; font-size:14px; color:#8B93A7;"></div>
</div>
<div style="display:flex; flex-direction:column; gap:14px;">
<sc-if value="{{ isRegisterMode }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Full name</label>
<input placeholder="Jane Trader" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
</sc-if>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Email</label>
<input placeholder="you@domain.com" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Password</label>
<input type="password" placeholder="••••••••" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="margin-top:6px; padding:13px; border-radius:11px; text-align:center; font-size:14px; font-weight:700; cursor:pointer; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4);">{{ authSubmitLabel }}</div>
<div style="text-align:center; font-size:12.5px; color:#8B93A7; margin-top:4px;">
{{ authSwitchPrompt }} <span onClick="{{ switchAuthMode }}" style="color:#5b9dff; font-weight:700; cursor:pointer;">{{ authSwitchAction }}</span>
</div>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;: {&quot;width&quot;: 1440}, &quot;brandName&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;Predictalytics&quot;, &quot;tsType&quot;: &quot;string&quot;}}">
class Component extends DCLogic {
state = {
authOpen: false,
authMode: "login",
openFaq: 0,
};
openLogin = () => this.setState({ authOpen: true, authMode: "login" });
openRegister = () => this.setState({ authOpen: true, authMode: "register" });
closeAuth = () => this.setState({ authOpen: false });
switchAuthMode = () => this.setState((s) => ({ authMode: s.authMode === "login" ? "register" : "login" }));
stopProp = (e) => e.stopPropagation();
toggleFaq = (i) => this.setState((s) => ({ openFaq: s.openFaq === i ? -1 : i }));
buildPath(series, w, h, pad) {
const min = Math.min(...series);
const max = Math.max(...series);
const range = max - min || 1;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const pts = series.map((v, i) => {
const x = pad + (i / (series.length - 1)) * innerW;
const y = pad + innerH - ((v - min) / range) * innerH;
return [x, y];
});
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
return { line, area };
}
renderVals() {
const brandName = this.props.brandName ?? "Predictalytics";
const heroSeries = [40, 44, 41, 48, 52, 50, 58, 55, 62, 66, 63, 70, 68, 74, 71.4];
const { line, area } = this.buildPath(heroSeries, 500, 140, 8);
const features = [
{ title: "Trader Tracking", desc: "Follow any wallet's full position history, P&L, and behavior patterns across every market.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
{ title: "Wallet Clustering", desc: "Detect connected wallets and coordinated accounts behind a single trading strategy.", iconBg: "rgba(124,92,255,0.14)", dotColor: "#7c5cff" },
{ title: "Market Analysis", desc: "Order book depth, liquidity, and holder concentration for every active market.", iconBg: "rgba(18,212,138,0.14)", dotColor: "#12D48A" },
{ title: "Custom Alerts", desc: "Get notified the moment a tracked trader opens a position or a market shifts sharply.", iconBg: "rgba(255,157,76,0.14)", dotColor: "#ff9d4c" },
{ title: "Trader Traits", desc: "Automatic tagging — arbitrage traders, resolution farmers, bots — at a glance.", iconBg: "rgba(246,70,93,0.14)", dotColor: "#F6465D" },
{ title: "API Access", desc: "Pull every metric programmatically into your own models and dashboards.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
];
const baseCard = "position:relative; padding:30px 26px; border-radius:20px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); display:flex; flex-direction:column;";
const featuredCard = "position:relative; padding:30px 26px; border-radius:20px; background:rgba(22,82,240,0.08); border:1px solid rgba(22,82,240,0.35); backdrop-filter:blur(24px); display:flex; flex-direction:column; box-shadow:0 20px 50px rgba(22,82,240,0.2);";
const btnPrimary = "cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4);";
const btnSecondary = "cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.12); color:#EDEFF5;";
const plans = [
{ name: "Free", price: "$0", period: "/ month", featured: false, cardStyle: baseCard, btnStyle: btnSecondary, btnLabel: "Get started",
items: ["20 API calls / day", "Restricted web access", "Market overview only", "Community support"] },
{ name: "Pro", price: "$99", period: "/ month", featured: true, cardStyle: featuredCard, btnStyle: btnPrimary, btnLabel: "Start Pro",
items: ["2,000 API calls / day", "Enhanced AI trader analytics", "MCP access for your AI agent", "Priority support"] },
{ name: "Enterprise", price: "Contact us", period: "", featured: false, cardStyle: baseCard, btnStyle: btnSecondary, btnLabel: "Contact sales",
items: ["10,000+ API calls / day", "Ready to use AI analytics with your individual prompts", "MCP access for your AI agent", "Priority support"] },
];
const faqDefs = [
{ q: "What data sources power your analytics?", a: "We index on-chain activity directly from Polymarket and related prediction market contracts in real time, plus supplementary off-chain resolution data." },
{ q: "How do you identify traders as bots or arbitrageurs?", a: "We apply behavioral heuristics — trade timing, position sizing, cross-market correlation — to automatically tag wallets with traits like Bot, Arbitrage Trader, or Resolution Farming." },
{ q: "Can I track a specific wallet or trader?", a: "Yes. Search any wallet address or handle to pull up its full position history, P&L, and connected wallets." },
{ q: "What's included in the API?", a: "Market and trader endpoints covering prices, order books, holders, and trader metrics. Call limits scale with your plan, from 20 to 2,000 requests per day." },
{ q: "Do you support alerts?", a: "Yes, set alerts on tracked traders or markets and get notified the moment a position opens or a price moves sharply." },
{ q: "Can I cancel or change my plan anytime?", a: "Yes, upgrade, downgrade, or cancel at any time from your account settings — changes apply at the next billing cycle." },
];
const faqs = faqDefs.map((f, i) => ({
question: f.q,
answer: f.a,
open: this.state.openFaq === i,
iconTransform: this.state.openFaq === i ? "rotate(45deg)" : "rotate(0deg)",
onToggle: () => this.toggleFaq(i),
}));
const heroTraders = [
{ handle: "quant_owl", pnl: "+$216K", bg: "linear-gradient(135deg,#1652F0,#4c8dff)" },
{ handle: "arb_meridian", pnl: "+$454K", bg: "linear-gradient(135deg,#12D48A,#0a8f5f)" },
{ handle: "resolvr", pnl: "+$105K", bg: "linear-gradient(135deg,#7c5cff,#4c8dff)" },
];
return {
brandName,
heroLine: line,
heroArea: area,
heroTraders,
features,
plans,
faqs,
authOpen: this.state.authOpen,
isRegisterMode: this.state.authMode === "register",
authTitle: this.state.authMode === "login" ? "Log in" : "Create your account",
authSubmitLabel: this.state.authMode === "login" ? "Log in" : "Create account",
authSwitchPrompt: this.state.authMode === "login" ? "Don't have an account?" : "Already have an account?",
authSwitchAction: this.state.authMode === "login" ? "Sign up" : "Log in",
openLogin: this.openLogin,
openRegister: this.openRegister,
closeAuth: this.closeAuth,
switchAuthMode: this.switchAuthMode,
stopProp: this.stopProp,
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,875 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; overflow-x: auto; }
* { box-sizing: border-box; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
table { border-collapse: collapse; }
.dual-range { -webkit-appearance: none; appearance: none; background: transparent; pointer-events: none; margin: 0; position: absolute; top: 0; left: 0; width: 100%; height: 20px; }
.dual-range::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 15px; height: 15px; border-radius: 50%; background: #5b9dff; border: 2px solid #0a0d14; cursor: pointer; box-shadow: 0 0 6px rgba(91,157,255,0.7); margin-top: 0; }
.dual-range::-moz-range-thumb { pointer-events: auto; width: 15px; height: 15px; border-radius: 50%; background: #5b9dff; border: 2px solid #0a0d14; cursor: pointer; box-shadow: 0 0 6px rgba(91,157,255,0.7); }
.dual-range::-webkit-slider-runnable-track { background: transparent; }
.dual-range::-moz-range-track { background: transparent; }
</style>
</helmet>
<div style="position:relative; min-height:100vh; min-width:1440px; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5;">
<!-- decorative glow layer -->
<div style="position:fixed; inset:0; pointer-events:none; background: radial-gradient(600px 400px at 80% 85%, rgba(76,141,255,0.10), transparent 60%); z-index:0;"></div>
<div style="position:relative; z-index:1; display:flex; min-height:100vh;">
<!-- SIDEBAR -->
<aside style="width:236px; flex:none; padding:24px 16px; display:flex; flex-direction:column; gap:28px; border-right:1px solid rgba(255,255,255,0.07); background:rgba(255,255,255,0.015);">
<div style="display:flex; align-items:center; gap:10px; padding:4px 8px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">Lucid<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; flex-direction:column; gap:2px;">
<div style="font:600 10px/1 'Manrope'; letter-spacing:0.08em; color:#5B6377; padding:0 12px 8px;">NAVIGATION</div>
<sc-for list="{{ navItems }}" as="item" hint-placeholder-count="4">
<div onClick="{{ item.onClick }}" style="{{ item.style }}">
<div style="{{ item.dotStyle }}"></div>
<span>{{ item.label }}</span>
</div>
</sc-for>
</nav>
<div style="margin-top:auto; display:flex; flex-direction:column; gap:10px;">
<div style="padding:14px; border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(20px);">
<div style="font-size:11px; color:#8B93A7; margin-bottom:6px;">Data feed</div>
<div style="display:flex; align-items:center; gap:7px;">
<div style="width:7px; height:7px; border-radius:50%; background:#12D48A; box-shadow:0 0 8px #12D48A;"></div>
<div style="font-size:12.5px; font-weight:600;">Live · on-chain</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:10px; padding:8px 10px;">
<div style="width:30px; height:30px; border-radius:50%; background:linear-gradient(135deg,#2a2f3d,#1a1e28); border:1px solid rgba(255,255,255,0.1);"></div>
<div>
<div style="font-size:12.5px; font-weight:700;">Analyst</div>
<div style="font-size:11px; color:#5B6377;">Pro workspace</div>
</div>
</div>
</div>
</aside>
<!-- MAIN -->
<main style="flex:1; min-width:0; padding:22px 32px 60px;">
<!-- TOPBAR -->
<div style="display:flex; align-items:center; gap:16px; margin-bottom:22px;">
<div style="flex:1; position:relative; max-width:420px;">
<input placeholder="Search markets, traders, wallets…" style="width:100%; padding:11px 14px 11px 38px; border-radius:11px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.09); color:#EDEFF5; font-size:13px; font-family:'Manrope'; outline:none;" />
<div style="position:absolute; left:13px; top:50%; transform:translateY(-50%); width:14px; height:14px; border-radius:50%; border:2px solid #5B6377;"></div>
</div>
<div style="margin-left:auto; display:flex; align-items:center; gap:10px;">
<div style="padding:8px 14px; border-radius:10px; background:rgba(18,212,138,0.10); border:1px solid rgba(18,212,138,0.25); font-size:12px; font-weight:700; color:#12D48A; font-family:'Roboto Mono';">24H VOL {{ headerVolume }}</div>
<div style="width:38px; height:38px; border-radius:11px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.09); display:flex; align-items:center; justify-content:center;">
<div style="width:8px; height:8px; border-radius:50%; background:#F6465D;"></div>
</div>
</div>
</div>
<!-- breadcrumb -->
<div style="display:flex; align-items:center; gap:8px; font-size:12.5px; margin-bottom:18px; font-weight:600;">
<sc-for list="{{ breadcrumbItems }}" as="crumb" hint-placeholder-count="1">
<span onClick="{{ crumb.onClick }}" style="{{ crumb.style }}">{{ crumb.label }}</span>
<sc-if value="{{ crumb.sep }}" hint-placeholder-val="{{ false }}">
<span style="color:#3a4152;"></span>
</sc-if>
</sc-for>
</div>
<sc-if value="{{ isOverview }}" hint-placeholder-val="{{ true }}">
<!-- ================= OVERVIEW ================= -->
<div>
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:20px;">
<div>
<h1 style="margin:0 0 4px; font-size:26px; font-weight:800; letter-spacing:-0.02em;">Markets Overview</h1>
<div style="font-size:13px; color:#8B93A7;">Real-time analytics across all active prediction markets</div>
</div>
</div>
<!-- stat cards -->
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px;">
<sc-for list="{{ globalStats }}" as="stat" hint-placeholder-count="4">
<div style="padding:18px 20px; border-radius:16px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 8px 30px rgba(0,0,0,0.25);">
<div style="font-size:12px; color:#8B93A7; font-weight:600; margin-bottom:10px;">{{ stat.label }}</div>
<div style="font-size:24px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">{{ stat.value }}</div>
<div style="font-size:12px; font-weight:700; margin-top:6px; font-family:'Roboto Mono'; color:{{ stat.deltaColor }};">{{ stat.delta }}</div>
</div>
</sc-for>
</div>
<div style="display:grid; grid-template-columns:1.7fr 1fr; gap:16px; align-items:start;">
<!-- markets table -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:18px 22px 14px; font-size:15px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">All Markets</div>
<table style="width:100%; font-size:13px;">
<thead>
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
<td style="padding:10px 22px;">Market</td>
<td style="padding:10px 10px; text-align:right;">Yes %</td>
<td style="padding:10px 10px; text-align:right;">24h</td>
<td style="padding:10px 10px; text-align:right;">Volume</td>
<td style="padding:10px 22px; text-align:right;">Traders</td>
</tr>
</thead>
<tbody>
<sc-for list="{{ marketRows }}" as="m" hint-placeholder-count="6">
<tr onClick="{{ m.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<td style="padding:13px 22px;">
<div style="font-weight:700; font-size:13.5px; margin-bottom:3px;">{{ m.question }}</div>
<div style="font-size:11.5px; color:#5B6377;">{{ m.category }} · {{ m.statusLabel }}</div>
</td>
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700;">{{ m.yesPriceLabel }}</td>
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ m.changeColor }};">{{ m.changeLabel }}</td>
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ m.volumeLabel }}</td>
<td style="padding:13px 22px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ m.tradersLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
<!-- leaderboard -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:18px 22px 14px; font-size:15px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Top Traders</div>
<div style="display:flex; flex-direction:column;">
<sc-for list="{{ leaderboard }}" as="t" hint-placeholder-count="6">
<div onClick="{{ t.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:12px; padding:12px 22px; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<div style="width:30px; height:30px; border-radius:9px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:800;">{{ t.initial }}</div>
<div style="min-width:0; flex:1;">
<div style="font-weight:700; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ t.handle }}</div>
<div style="font-size:11px; color:#5B6377;">{{ t.trait }}</div>
</div>
<div style="text-align:right; flex:none;">
<div style="font-family:'Roboto Mono'; font-weight:700; font-size:13px; color:#12D48A;">{{ t.pnlLabel }}</div>
<div style="font-size:10.5px; color:#5B6377;">P&amp;L</div>
</div>
</div>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isMarket }}" hint-placeholder-val="{{ false }}">
<!-- ================= MARKET DETAIL ================= -->
<div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; gap:20px;">
<div>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:rgba(22,82,240,0.14); border:1px solid rgba(22,82,240,0.3); color:#5b9dff;">{{ marketDetail.category }}</div>
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:{{ marketDetail.statusBg }}; color:{{ marketDetail.statusColor }};">{{ marketDetail.status }}</div>
</div>
<h1 style="margin:0 0 6px; font-size:24px; font-weight:800; letter-spacing:-0.02em; max-width:640px;">{{ marketDetail.question }}</h1>
<div style="font-size:12.5px; color:#8B93A7;">Resolves {{ marketDetail.resolves }}</div>
</div>
<div style="text-align:right; flex:none;">
<div style="font-size:34px; font-weight:800; font-family:'Roboto Mono'; color:#12D48A;">{{ marketDetail.yesPriceBig }}</div>
<div style="font-size:12px; color:#8B93A7; font-weight:600;">YES probability</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:18px;">
<sc-for list="{{ marketStats }}" as="stat" hint-placeholder-count="4">
<div style="padding:16px 18px; border-radius:14px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px);">
<div style="font-size:11.5px; color:#8B93A7; font-weight:600; margin-bottom:8px;">{{ stat.label }}</div>
<div style="font-size:19px; font-weight:800; font-family:'Roboto Mono';">{{ stat.value }}</div>
</div>
</sc-for>
</div>
<div style="display:grid; grid-template-columns:1.6fr 1fr; gap:16px; margin-bottom:16px; align-items:start;">
<!-- price chart -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px 22px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:14px;">
<div style="font-size:14px; font-weight:700;">Yes Price History</div>
<div style="display:flex; gap:6px;">
<div style="font-size:11px; padding:5px 10px; border-radius:7px; background:rgba(22,82,240,0.18); color:#5b9dff; font-weight:700;">30D</div>
</div>
</div>
<svg viewBox="0 0 600 220" style="width:100%; height:220px; overflow:visible;">
<defs>
<linearGradient id="fillGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
</linearGradient>
<filter id="glow"><feGaussianBlur stdDeviation="3.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<path d="{{ marketDetail.areaPath }}" fill="url(#fillGrad)" stroke="none"></path>
<path d="{{ marketDetail.linePath }}" fill="none" stroke="#12D48A" stroke-width="2.5" filter="url(#glow)"></path>
</svg>
</div>
<!-- order book -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:18px 20px;">
<div style="font-size:14px; font-weight:700; margin-bottom:12px;">Order Book Depth</div>
<div style="font-size:10.5px; color:#5B6377; font-weight:700; text-transform:uppercase; margin-bottom:6px;">Asks</div>
<sc-for list="{{ marketDetail.asks }}" as="row" hint-placeholder-count="5">
<div style="position:relative; margin-bottom:3px; height:20px;">
<div style="position:absolute; right:0; top:0; bottom:0; width:{{ row.barWidth }}; background:rgba(246,70,93,0.16); border-radius:4px;"></div>
<div style="position:relative; display:flex; justify-content:space-between; padding:0 8px; font-size:11.5px; font-family:'Roboto Mono'; line-height:20px;">
<span style="color:#F6465D; font-weight:700;">{{ row.priceLabel }}</span>
<span style="color:#8B93A7;">{{ row.sizeLabel }}</span>
</div>
</div>
</sc-for>
<div style="height:1px; background:rgba(255,255,255,0.08); margin:8px 0;"></div>
<div style="font-size:10.5px; color:#5B6377; font-weight:700; text-transform:uppercase; margin-bottom:6px;">Bids</div>
<sc-for list="{{ marketDetail.bids }}" as="row" hint-placeholder-count="5">
<div style="position:relative; margin-bottom:3px; height:20px;">
<div style="position:absolute; right:0; top:0; bottom:0; width:{{ row.barWidth }}; background:rgba(18,212,138,0.16); border-radius:4px;"></div>
<div style="position:relative; display:flex; justify-content:space-between; padding:0 8px; font-size:11.5px; font-family:'Roboto Mono'; line-height:20px;">
<span style="color:#12D48A; font-weight:700;">{{ row.priceLabel }}</span>
<span style="color:#8B93A7;">{{ row.sizeLabel }}</span>
</div>
</div>
</sc-for>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<!-- biggest holders -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 20px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Biggest Holders</div>
<table style="width:100%; font-size:12.5px;">
<tbody>
<sc-for list="{{ marketDetail.holders }}" as="h" hint-placeholder-count="5">
<tr onClick="{{ h.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<td style="padding:11px 20px; font-family:'Roboto Mono'; font-weight:700;">{{ h.trader }}</td>
<td style="padding:11px 6px; text-align:center;"><span style="padding:3px 9px; border-radius:6px; font-size:11px; font-weight:700; background:{{ h.sideBg }}; color:{{ h.sideColor }};">{{ h.side }}</span></td>
<td style="padding:11px 20px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ h.valueLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
<!-- top traders in market -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 20px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Top Traders Here</div>
<div style="display:flex; flex-direction:column;">
<sc-for list="{{ marketDetail.topTraders }}" as="t" hint-placeholder-count="4">
<div onClick="{{ t.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:12px; padding:12px 20px; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<div style="width:28px; height:28px; border-radius:8px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:800;">{{ t.initial }}</div>
<div style="font-weight:700; font-size:13px; flex:1;">{{ t.handle }}</div>
<div style="font-size:11px; color:#8B93A7;">{{ t.trait }}</div>
</div>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isTraderSearch }}" hint-placeholder-val="{{ false }}">
<!-- ================= TRADER SEARCH ================= -->
<div>
<div style="margin-bottom:20px;">
<h1 style="margin:0 0 4px; font-size:26px; font-weight:800; letter-spacing:-0.02em;">Trader Search</h1>
<div style="font-size:13px; color:#8B93A7;">Filter by traits and metric ranges to find the traders you care about</div>
</div>
<div style="display:grid; grid-template-columns:300px 1fr; gap:16px; align-items:start;">
<!-- filter panel -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px; display:flex; flex-direction:column; gap:22px; position:sticky; top:22px;">
<div>
<input value="{{ searchText }}" onChange="{{ onSearchTextChange }}" placeholder="Handle or wallet…" style="width:100%; padding:10px 13px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13px; font-family:'Manrope'; outline:none;" />
</div>
<div>
<div style="font-size:12px; font-weight:700; color:#C7CCDA; margin-bottom:10px;">Traits</div>
<div style="display:flex; flex-wrap:wrap; gap:7px;">
<sc-for list="{{ traitFilterChips }}" as="chip" hint-placeholder-count="5">
<div onClick="{{ chip.onClick }}" style="{{ chip.style }}">{{ chip.label }}</div>
</sc-for>
</div>
</div>
<div>
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Win Rate</span>
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ winRateSlider.minLabel }} {{ winRateSlider.maxLabel }}</span>
</div>
<div style="position:relative; height:20px; margin-bottom:10px;">
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ winRateSlider.fillLeft }}; right:{{ winRateSlider.fillRight }};"></div>
<input class="dual-range" type="range" min="{{ winRateSlider.lo }}" max="{{ winRateSlider.hi }}" value="{{ winRateSlider.minVal }}" onInput="{{ winRateSlider.onMinInput }}" />
<input class="dual-range" type="range" min="{{ winRateSlider.lo }}" max="{{ winRateSlider.hi }}" value="{{ winRateSlider.maxVal }}" onInput="{{ winRateSlider.onMaxInput }}" />
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input value="{{ winRateSlider.minVal }}" onBlur="{{ winRateSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
<span style="color:#5B6377; font-size:11px;"></span>
<input value="{{ winRateSlider.maxVal }}" onBlur="{{ winRateSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
</div>
</div>
<div>
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Volume</span>
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ volumeSlider.minLabel }} {{ volumeSlider.maxLabel }}</span>
</div>
<div style="position:relative; height:20px; margin-bottom:10px;">
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ volumeSlider.fillLeft }}; right:{{ volumeSlider.fillRight }};"></div>
<input class="dual-range" type="range" min="{{ volumeSlider.lo }}" max="{{ volumeSlider.hi }}" step="500000" value="{{ volumeSlider.minVal }}" onInput="{{ volumeSlider.onMinInput }}" />
<input class="dual-range" type="range" min="{{ volumeSlider.lo }}" max="{{ volumeSlider.hi }}" step="500000" value="{{ volumeSlider.maxVal }}" onInput="{{ volumeSlider.onMaxInput }}" />
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input value="{{ volumeSlider.minVal }}" onBlur="{{ volumeSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
<span style="color:#5B6377; font-size:11px;"></span>
<input value="{{ volumeSlider.maxVal }}" onBlur="{{ volumeSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
</div>
</div>
<div>
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Total P&amp;L</span>
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ pnlSlider.minLabel }} {{ pnlSlider.maxLabel }}</span>
</div>
<div style="position:relative; height:20px; margin-bottom:10px;">
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ pnlSlider.fillLeft }}; right:{{ pnlSlider.fillRight }};"></div>
<input class="dual-range" type="range" min="{{ pnlSlider.lo }}" max="{{ pnlSlider.hi }}" step="10000" value="{{ pnlSlider.minVal }}" onInput="{{ pnlSlider.onMinInput }}" />
<input class="dual-range" type="range" min="{{ pnlSlider.lo }}" max="{{ pnlSlider.hi }}" step="10000" value="{{ pnlSlider.maxVal }}" onInput="{{ pnlSlider.onMaxInput }}" />
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input value="{{ pnlSlider.minVal }}" onBlur="{{ pnlSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
<span style="color:#5B6377; font-size:11px;"></span>
<input value="{{ pnlSlider.maxVal }}" onBlur="{{ pnlSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
</div>
</div>
<div onClick="{{ onResetFilters }}" style="cursor:pointer; text-align:center; padding:10px; border-radius:10px; font-size:12.5px; font-weight:700; color:#8B93A7; border:1px solid rgba(255,255,255,0.1);">Reset filters</div>
</div>
<!-- results -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 22px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06); display:flex; justify-content:space-between;">
<span>Results</span><span style="color:#5B6377; font-weight:600;">{{ searchResultsCount }} traders</span>
</div>
<table style="width:100%; font-size:13px;">
<thead>
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
<td style="padding:10px 22px;">Trader</td>
<td style="padding:10px 10px;">Traits</td>
<td style="padding:10px 10px; text-align:right;">Win Rate</td>
<td style="padding:10px 10px; text-align:right;">Volume</td>
<td style="padding:10px 22px; text-align:right;">P&amp;L</td>
</tr>
</thead>
<tbody>
<sc-for list="{{ searchResults }}" as="t" hint-placeholder-count="6">
<tr onClick="{{ t.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<td style="padding:12px 22px;">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:26px; height:26px; border-radius:8px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:800;">{{ t.initial }}</div>
<span style="font-weight:700;">{{ t.handle }}</span>
</div>
</td>
<td style="padding:12px 10px;">
<div style="display:flex; gap:5px; flex-wrap:wrap;">
<sc-for list="{{ t.traitChips }}" as="chip" hint-placeholder-count="2">
<div style="font-size:10.5px; font-weight:700; padding:3px 8px; border-radius:6px; background:{{ chip.bg }}; color:{{ chip.color }};">{{ chip.label }}</div>
</sc-for>
</div>
</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700;">{{ t.winRateLabel }}</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ t.volumeLabel }}</td>
<td style="padding:12px 22px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ t.pnlColor }};">{{ t.pnlLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isTrader }}" hint-placeholder-val="{{ false }}">
<!-- ================= TRADER DETAIL ================= -->
<div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; gap:20px;">
<div style="display:flex; align-items:center; gap:16px;">
<div style="width:56px; height:56px; border-radius:16px; background:{{ traderDetail.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:20px; font-weight:800; box-shadow:0 0 24px rgba(22,82,240,0.3);">{{ traderDetail.initial }}</div>
<div>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;">
<h1 style="margin:0; font-size:22px; font-weight:800; letter-spacing:-0.02em;">{{ traderDetail.handle }}</h1>
<div style="font-size:12px; font-family:'Roboto Mono'; color:#5B6377;">{{ traderDetail.wallet }}</div>
</div>
<div style="display:flex; gap:6px;">
<sc-for list="{{ traderDetail.traitChips }}" as="chip" hint-placeholder-count="2">
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:{{ chip.bg }}; color:{{ chip.color }}; border:1px solid {{ chip.border }};">{{ chip.label }}</div>
</sc-for>
</div>
</div>
</div>
<div style="text-align:right;">
<div style="font-size:11.5px; color:#8B93A7; margin-bottom:2px;">Member since {{ traderDetail.joined }}</div>
<div style="font-size:11.5px; color:#8B93A7;">{{ traderDetail.activePositionsLabel }} active positions</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:16px; margin-bottom:18px;">
<sc-for list="{{ traderStats }}" as="stat" hint-placeholder-count="5">
<div style="padding:16px 18px; border-radius:14px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px);">
<div style="font-size:11px; color:#8B93A7; font-weight:600; margin-bottom:8px;">{{ stat.label }}</div>
<div style="font-size:18px; font-weight:800; font-family:'Roboto Mono'; color:{{ stat.color }};">{{ stat.value }}</div>
</div>
</sc-for>
</div>
<div style="display:grid; grid-template-columns:1.6fr 1fr; gap:16px; margin-bottom:16px; align-items:start;">
<!-- pnl chart -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px 22px;">
<div style="font-size:14px; font-weight:700; margin-bottom:14px;">P&amp;L Over Time</div>
<svg viewBox="0 0 600 200" style="width:100%; height:200px; overflow:visible;">
<defs>
<linearGradient id="pnlFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5b9dff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#5b9dff" stop-opacity="0"/>
</linearGradient>
<filter id="glow2"><feGaussianBlur stdDeviation="3.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<path d="{{ traderDetail.areaPath }}" fill="url(#pnlFill)" stroke="none"></path>
<path d="{{ traderDetail.linePath }}" fill="none" stroke="#5b9dff" stroke-width="2.5" filter="url(#glow2)"></path>
</svg>
</div>
<!-- network -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:18px 20px;">
<div style="font-size:14px; font-weight:700; margin-bottom:10px;">Connected Wallets</div>
<sc-if value="{{ traderDetail.hasConnections }}" hint-placeholder-val="{{ true }}">
<sc-for list="{{ traderDetail.connections }}" as="w" hint-placeholder-count="2">
<div style="display:flex; align-items:center; gap:10px; padding:10px 0; border-top:1px solid rgba(255,255,255,0.05);">
<div style="width:8px; height:8px; border-radius:50%; background:#F6465D; box-shadow:0 0 6px #F6465D;"></div>
<div style="font-family:'Roboto Mono'; font-size:12.5px; color:#C7CCDA;">{{ w }}</div>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ traderDetail.noConnections }}" hint-placeholder-val="{{ false }}">
<div style="font-size:12.5px; color:#5B6377; padding:10px 0;">No linked wallets detected.</div>
</sc-if>
</div>
</div>
<!-- positions table -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 22px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Positions</div>
<table style="width:100%; font-size:12.5px;">
<thead>
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
<td style="padding:10px 22px;">Market</td>
<td style="padding:10px 10px; text-align:center;">Side</td>
<td style="padding:10px 10px; text-align:right;">Size</td>
<td style="padding:10px 10px; text-align:right;">Entry</td>
<td style="padding:10px 10px; text-align:right;">Current</td>
<td style="padding:10px 22px; text-align:right;">P&amp;L</td>
</tr>
</thead>
<tbody>
<sc-for list="{{ traderDetail.positions }}" as="p" hint-placeholder-count="4">
<tr style="border-top:1px solid rgba(255,255,255,0.05);">
<td style="padding:12px 22px; font-weight:600;">{{ p.market }}</td>
<td style="padding:12px 10px; text-align:center;"><span style="padding:3px 9px; border-radius:6px; font-size:11px; font-weight:700; background:{{ p.sideBg }}; color:{{ p.sideColor }};">{{ p.side }}</span></td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono';">{{ p.sizeLabel }}</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ p.entryLabel }}</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ p.currentLabel }}</td>
<td style="padding:12px 22px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ p.pnlColor }};">{{ p.pnlLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
</div>
</sc-if>
</main>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = {
view: "overview",
selectedMarketId: null,
selectedTraderId: null,
markets: null,
traders: null,
searchText: "",
filterTraits: [],
winRateMin: 0,
winRateMax: 100,
volumeMin: 0,
volumeMax: 25000000,
pnlMin: -50000,
pnlMax: 500000,
};
componentDidMount() {
import("./market-data.js").then((m) => {
this.setState({ markets: m.MARKETS, traders: m.TRADERS });
});
}
goOverview = () => this.setState({ view: "overview" });
goMarket = (id) => this.setState({ view: "market", selectedMarketId: id });
goTrader = (id) => this.setState({ view: "trader", selectedTraderId: id });
goTraderSearch = () => this.setState({ view: "traderSearch" });
setSearchText = (e) => this.setState({ searchText: e.target.value });
toggleTrait = (trait) => this.setState((s) => ({
filterTraits: s.filterTraits.includes(trait) ? s.filterTraits.filter((t) => t !== trait) : [...s.filterTraits, trait],
}));
setRange = (key) => (e) => this.setState({ [key]: Number(e.target.value) });
resetFilters = () => this.setState({
searchText: "", filterTraits: [], winRateMin: 0, winRateMax: 100,
volumeMin: 0, volumeMax: 25000000, pnlMin: -50000, pnlMax: 500000,
});
parseClamp(raw, lo, hi) {
const n = Number(String(raw).replace(/[^0-9.\-]/g, ""));
if (Number.isNaN(n)) return null;
return Math.max(lo, Math.min(hi, n));
}
onMinTextBlur = (key, lo, hi) => (e) => {
const n = this.parseClamp(e.target.value, lo, hi);
this.setState({ [key]: n === null ? lo : n });
};
onMaxTextBlur = (key, lo, hi) => (e) => {
const n = this.parseClamp(e.target.value, lo, hi);
this.setState({ [key]: n === null ? hi : n });
};
fmtMoney(n) {
if (n >= 1000000) return "$" + (n / 1000000).toFixed(2) + "M";
if (n >= 1000) return "$" + (n / 1000).toFixed(1) + "K";
return "$" + Math.round(n);
}
fmtNum(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(Math.round(n));
}
fmtSigned(n, suffix) {
const s = n >= 0 ? "+" : "";
return s + n.toFixed(1) + (suffix || "");
}
traitStyle(trait) {
const map = {
"Arbitrage Bot": { bg: "rgba(22,82,240,0.14)", color: "#5b9dff", border: "rgba(22,82,240,0.3)" },
"Resolution Farming": { bg: "rgba(246,70,93,0.12)", color: "#F6465D", border: "rgba(246,70,93,0.28)" },
"Trading Bot": { bg: "rgba(255,255,255,0.08)", color: "#C7CCDA", border: "rgba(255,255,255,0.15)" },
"Human": { bg: "rgba(124,92,255,0.12)", color: "#a78bff", border: "rgba(124,92,255,0.28)" },
"High Volume": { bg: "rgba(18,212,138,0.12)", color: "#12D48A", border: "rgba(18,212,138,0.28)" },
};
return map[trait] || { bg: "rgba(255,255,255,0.08)", color: "#C7CCDA", border: "rgba(255,255,255,0.15)" };
}
ALL_TRAITS = ["Human", "Trading Bot", "Arbitrage Bot", "Resolution Farming", "High Volume"];
avatarBg(seed) {
const palettes = [
"linear-gradient(135deg,#1652F0,#4c8dff)",
"linear-gradient(135deg,#12D48A,#0a8f5f)",
"linear-gradient(135deg,#F6465D,#a8283a)",
"linear-gradient(135deg,#7c5cff,#4c8dff)",
"linear-gradient(135deg,#ff9d4c,#F6465D)",
];
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
return palettes[h % palettes.length];
}
buildDualSlider(minKey, maxKey, lo, hi, fmt) {
const minVal = this.state[minKey];
const maxVal = this.state[maxKey];
const pctMin = ((minVal - lo) / (hi - lo)) * 100;
const pctMax = ((maxVal - lo) / (hi - lo)) * 100;
return {
lo, hi, minVal, maxVal,
minLabel: fmt(minVal), maxLabel: fmt(maxVal),
fillLeft: pctMin.toFixed(1) + "%",
fillRight: (100 - pctMax).toFixed(1) + "%",
onMinInput: (e) => this.setState({ [minKey]: Math.min(Number(e.target.value), this.state[maxKey]) }),
onMaxInput: (e) => this.setState({ [maxKey]: Math.max(Number(e.target.value), this.state[minKey]) }),
onMinTextBlur: this.onMinTextBlur(minKey, lo, hi),
onMaxTextBlur: this.onMaxTextBlur(maxKey, lo, hi),
};
}
buildPath(series, w, h, pad) {
if (!series || series.length < 2) return { line: "", area: "" };
const min = Math.min(...series);
const max = Math.max(...series);
const range = max - min || 1;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const pts = series.map((v, i) => {
const x = pad + (i / (series.length - 1)) * innerW;
const y = pad + innerH - ((v - min) / range) * innerH;
return [x, y];
});
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
return { line, area };
}
renderVals() {
const markets = this.state.markets || [];
const traders = this.state.traders || [];
const view = this.state.view;
const navDefs = [
{ key: "overview", label: "Markets" },
{ key: "traders", label: "Traders" },
{ key: "watchlist", label: "Watchlist" },
{ key: "settings", label: "Settings" },
];
const navItems = navDefs.map((n) => {
const active = (n.key === "overview" && view === "overview") ||
(n.key === "traders" && (view === "trader" || view === "traderSearch")) ||
(n.key === "watchlist" && false) ||
(n.key === "settings" && false);
return {
label: n.label,
onClick: n.key === "overview" ? this.goOverview : (n.key === "traders" ? this.goTraderSearch : this.goOverview),
style: `display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:10px; font-size:13.5px; font-weight:600; cursor:pointer; color:${active ? "#EDEFF5" : "#8B93A7"}; background:${active ? "rgba(22,82,240,0.14)" : "transparent"};`,
dotStyle: `width:6px; height:6px; border-radius:50%; background:${active ? "#5b9dff" : "#3a4152"};`,
};
});
// ---- global stats
const totalVol24 = markets.reduce((s, m) => s + m.volume24h, 0);
const openMarkets = markets.filter((m) => m.status === "Active").length;
const totalTraders = new Set(traders.map((t) => t.id)).size + markets.reduce((s, m) => s + m.traders, 0) * 0; // display sum below
const totalActiveTraders = markets.reduce((s, m) => s + m.traders, 0);
const avgLiquidity = markets.length ? markets.reduce((s, m) => s + m.liquidity, 0) / markets.length : 0;
const globalStats = [
{ label: "24H Volume", value: this.fmtMoney(totalVol24), delta: this.fmtSigned(3.4, "%"), deltaColor: "#12D48A" },
{ label: "Open Markets", value: String(openMarkets), delta: "+2 today", deltaColor: "#12D48A" },
{ label: "Active Traders", value: this.fmtNum(totalActiveTraders), delta: this.fmtSigned(1.8, "%"), deltaColor: "#12D48A" },
{ label: "Avg. Liquidity", value: this.fmtMoney(avgLiquidity), delta: this.fmtSigned(-0.6, "%"), deltaColor: "#F6465D" },
];
const marketRows = markets.map((m) => ({
question: m.question,
category: m.category,
statusLabel: m.status,
yesPriceLabel: m.yesPrice.toFixed(1) + "%",
changeLabel: this.fmtSigned(m.change24h, "%"),
changeColor: m.change24h >= 0 ? "#12D48A" : "#F6465D",
volumeLabel: this.fmtMoney(m.volume24h),
tradersLabel: this.fmtNum(m.traders),
onClick: () => this.goMarket(m.id),
}));
const leaderboard = [...traders].sort((a, b) => (b.pnlRealized + b.pnlUnrealized) - (a.pnlRealized + a.pnlUnrealized)).slice(0, 6).map((t) => ({
handle: t.handle,
trait: t.traits[0],
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
pnlLabel: this.fmtMoney(t.pnlRealized + t.pnlUnrealized),
onClick: () => this.goTrader(t.id),
}));
const headerVolume = this.fmtMoney(totalVol24);
const crumbDefs = view === "overview" ? [["Markets Overview", null]] :
view === "market" ? [["Markets Overview", this.goOverview], ["Market Detail", null]] :
view === "traderSearch" ? [["Trader Search", null]] :
[["Trader Search", this.goTraderSearch], ["Trader Detail", null]];
const breadcrumbItems = crumbDefs.map(([label, onClick], i) => ({
label, onClick,
style: onClick ? "cursor:pointer; color:#8B93A7;" : "color:#EDEFF5; cursor:default;",
sep: i < crumbDefs.length - 1,
}));
// ---- trader search
let searchResults = [];
let traitFilterChips = [];
if (view === "traderSearch") {
const q = this.state.searchText.trim().toLowerCase();
traitFilterChips = this.ALL_TRAITS.map((tr) => {
const active = this.state.filterTraits.includes(tr);
const s = this.traitStyle(tr);
return {
label: tr,
onClick: () => this.toggleTrait(tr),
style: `cursor:pointer; font-size:12px; font-weight:700; padding:7px 13px; border-radius:9px; background:${active ? s.bg : "rgba(255,255,255,0.04)"}; color:${active ? s.color : "#8B93A7"}; border:1px solid ${active ? s.border : "rgba(255,255,255,0.09)"};`,
};
});
searchResults = traders.filter((t) => {
if (q && !t.handle.toLowerCase().includes(q) && !t.wallet.toLowerCase().includes(q)) return false;
if (this.state.filterTraits.length && !this.state.filterTraits.every((tr) => t.traits.includes(tr))) return false;
if (t.winRate < this.state.winRateMin || t.winRate > this.state.winRateMax) return false;
if (t.volume < this.state.volumeMin || t.volume > this.state.volumeMax) return false;
const totalPnl = t.pnlRealized + t.pnlUnrealized;
if (totalPnl < this.state.pnlMin || totalPnl > this.state.pnlMax) return false;
return true;
}).map((t) => ({
id: t.id,
handle: t.handle,
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
traitChips: t.traits.map((tr) => {
const s = this.traitStyle(tr);
return { label: tr, bg: s.bg, color: s.color, border: s.border };
}),
winRateLabel: t.winRate.toFixed(1) + "%",
volumeLabel: this.fmtMoney(t.volume),
pnlLabel: ((t.pnlRealized + t.pnlUnrealized) >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlRealized + t.pnlUnrealized)),
pnlColor: (t.pnlRealized + t.pnlUnrealized) >= 0 ? "#12D48A" : "#F6465D",
onClick: () => this.goTrader(t.id),
}));
}
// ---- market detail
let marketDetail = null;
let marketStats = [];
if (view === "market") {
const m = markets.find((x) => x.id === this.state.selectedMarketId) || markets[0];
if (m) {
const { line, area } = this.buildPath(m.yesSeries, 600, 220, 14);
const maxBookSize = Math.max(1, ...m.orderBook.bids.map((b) => b[1]), ...m.orderBook.asks.map((a) => a[1]));
marketDetail = {
question: m.question,
category: m.category,
status: m.status,
statusBg: m.status === "Active" ? "rgba(18,212,138,0.12)" : "rgba(255,255,255,0.08)",
statusColor: m.status === "Active" ? "#12D48A" : "#8B93A7",
resolves: m.resolves,
yesPriceBig: m.yesPrice.toFixed(1) + "%",
linePath: line,
areaPath: area,
asks: [...m.orderBook.asks].reverse().map(([p, s]) => ({ priceLabel: p.toFixed(1) + "%", sizeLabel: this.fmtMoney(s), barWidth: Math.round((s / maxBookSize) * 100) + "%" })),
bids: m.orderBook.bids.map(([p, s]) => ({ priceLabel: p.toFixed(1) + "%", sizeLabel: this.fmtMoney(s), barWidth: Math.round((s / maxBookSize) * 100) + "%" })),
holders: m.holders.map((h) => ({
trader: h.trader,
side: h.side,
sideBg: h.side === "Yes" ? "rgba(18,212,138,0.14)" : "rgba(246,70,93,0.14)",
sideColor: h.side === "Yes" ? "#12D48A" : "#F6465D",
valueLabel: this.fmtMoney(h.value),
onClick: () => {
const t = traders.find((tr) => tr.handle === h.trader || tr.wallet === h.trader);
if (t) this.goTrader(t.id);
},
})),
topTraders: m.topTraders.map((handle) => {
const t = traders.find((tr) => tr.handle === handle);
return t ? {
handle: t.handle,
trait: t.traits[0],
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
onClick: () => this.goTrader(t.id),
} : { handle, trait: "", initial: handle[0].toUpperCase(), avatarBg: "#333", onClick: () => {} };
}),
};
marketStats = [
{ label: "24H Volume", value: this.fmtMoney(m.volume24h) },
{ label: "Total Volume", value: this.fmtMoney(m.volumeTotal) },
{ label: "Liquidity", value: this.fmtMoney(m.liquidity) },
{ label: "Open Interest", value: this.fmtMoney(m.openInterest) },
];
}
}
// ---- trader detail
let traderDetail = null;
let traderStats = [];
if (view === "trader") {
const t = traders.find((x) => x.id === this.state.selectedTraderId) || traders[0];
if (t) {
const { line, area } = this.buildPath(t.pnlSeries, 600, 200, 14);
const totalPnl = t.pnlRealized + t.pnlUnrealized;
traderDetail = {
handle: t.handle,
wallet: t.wallet,
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
joined: t.joined,
activePositionsLabel: String(t.activePositions),
traitChips: t.traits.map((tr) => {
const s = this.traitStyle(tr);
return { label: tr, bg: s.bg, color: s.color, border: s.border };
}),
linePath: line,
areaPath: area,
hasConnections: t.connectedWallets.length > 0,
noConnections: t.connectedWallets.length === 0,
connections: t.connectedWallets,
positions: t.positions.map((p) => ({
market: p.market,
side: p.side,
sideBg: p.side === "Yes" ? "rgba(18,212,138,0.14)" : "rgba(246,70,93,0.14)",
sideColor: p.side === "Yes" ? "#12D48A" : "#F6465D",
sizeLabel: this.fmtMoney(p.size),
entryLabel: p.entry.toFixed(1) + "%",
currentLabel: p.current.toFixed(1) + "%",
pnlLabel: this.fmtSigned(0, "").slice(0,0) + (p.pnl >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(p.pnl)),
pnlColor: p.pnl >= 0 ? "#12D48A" : "#F6465D",
})),
};
traderStats = [
{ label: "Realized P&L", value: (t.pnlRealized >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlRealized)), color: t.pnlRealized >= 0 ? "#12D48A" : "#F6465D" },
{ label: "Unrealized P&L", value: (t.pnlUnrealized >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlUnrealized)), color: t.pnlUnrealized >= 0 ? "#12D48A" : "#F6465D" },
{ label: "Win Rate", value: t.winRate.toFixed(1) + "%", color: "#EDEFF5" },
{ label: "Total Volume", value: this.fmtMoney(t.volume), color: "#EDEFF5" },
{ label: "Avg Position", value: this.fmtMoney(t.avgPositionSize), color: "#EDEFF5" },
];
}
}
return {
navItems,
headerVolume,
breadcrumbItems,
isOverview: view === "overview",
isMarket: view === "market",
isTrader: view === "trader",
isTraderSearch: view === "traderSearch",
searchResultsCount: String(searchResults.length),
searchResults,
traitFilterChips,
searchText: this.state.searchText,
onSearchTextChange: this.setSearchText,
winRateSlider: this.buildDualSlider("winRateMin", "winRateMax", 0, 100, (v) => v.toFixed(0) + "%"),
volumeSlider: this.buildDualSlider("volumeMin", "volumeMax", 0, 25000000, (v) => this.fmtMoney(v)),
pnlSlider: this.buildDualSlider("pnlMin", "pnlMax", -50000, 500000, (v) => (v >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(v))),
onResetFilters: this.resetFilters,
globalStats,
marketRows,
leaderboard,
marketDetail,
marketStats,
traderDetail,
traderStats,
};
}
}
</script>
</body>
</html>
+290
View File
@@ -0,0 +1,290 @@
// Mock data for prediction market analytics prototype
function genSeries(n, start, vol, drift) {
const pts = [start];
for (let i = 1; i < n; i++) {
const next = Math.max(2, Math.min(98, pts[i - 1] + (Math.random() - 0.5 + drift) * vol));
pts.push(Math.round(next * 10) / 10);
}
return pts;
}
export const MARKETS = [
{
id: "fed-sep-cut",
question: "Fed cuts rates at September meeting?",
category: "Economics",
status: "Active",
resolves: "Sep 18, 2026",
yesPrice: 71.4,
change24h: 4.2,
volume24h: 2840000,
volumeTotal: 41200000,
liquidity: 6120000,
openInterest: 18400000,
traders: 8340,
yesSeries: genSeries(40, 55, 4, 0.06),
noSeries: null,
orderBook: {
bids: [[71.0, 182000], [70.5, 240000], [70.0, 310000], [69.5, 198000], [69.0, 145000]],
asks: [[71.5, 165000], [72.0, 221000], [72.5, 290000], [73.0, 176000], [73.5, 130000]]
},
holders: [
{ trader: "0xA13f…9c2", side: "Yes", size: 420000, value: 300000 },
{ trader: "quant_owl", side: "Yes", size: 318000, value: 227000 },
{ trader: "0x77Bd…41a", side: "No", size: 275000, value: 78600 },
{ trader: "resolvr", side: "Yes", size: 210000, value: 150000 },
{ trader: "0x9F02…c88", side: "No", size: 188000, value: 53700 }
],
topTraders: ["quant_owl", "resolvr", "arb_meridian", "0xA13f…9c2"]
},
{
id: "btc-150k",
question: "Bitcoin above $150,000 by year end?",
category: "Crypto",
status: "Active",
resolves: "Dec 31, 2026",
yesPrice: 38.2,
change24h: -2.8,
volume24h: 5120000,
volumeTotal: 88900000,
liquidity: 11400000,
openInterest: 34200000,
traders: 21300,
yesSeries: genSeries(40, 45, 5, -0.02),
noSeries: null,
orderBook: {
bids: [[38.0, 410000], [37.5, 380000], [37.0, 512000], [36.5, 290000], [36.0, 210000]],
asks: [[38.5, 390000], [39.0, 460000], [39.5, 330000], [40.0, 280000], [40.5, 190000]]
},
holders: [
{ trader: "satoshi_fan", side: "Yes", size: 890000, value: 340000 },
{ trader: "0x4Ac1…d02", side: "No", size: 720000, value: 445000 },
{ trader: "bot_delta_9", side: "No", size: 610000, value: 377000 },
{ trader: "arb_meridian", side: "Yes", size: 540000, value: 206000 },
{ trader: "0xE221…7b4", side: "Yes", size: 480000, value: 183000 }
],
topTraders: ["satoshi_fan", "bot_delta_9", "arb_meridian", "0x4Ac1…d02"]
},
{
id: "ucl-winner",
question: "Real Madrid win Champions League 2027?",
category: "Sports",
status: "Active",
resolves: "May 30, 2027",
yesPrice: 22.6,
change24h: 1.1,
volume24h: 980000,
volumeTotal: 14300000,
liquidity: 3210000,
openInterest: 7600000,
traders: 5120,
yesSeries: genSeries(40, 20, 3, 0.03),
noSeries: null,
orderBook: {
bids: [[22.0, 92000], [21.5, 110000], [21.0, 88000], [20.5, 64000], [20.0, 51000]],
asks: [[22.5, 87000], [23.0, 101000], [23.5, 76000], [24.0, 55000], [24.5, 40000]]
},
holders: [
{ trader: "resolvr", side: "Yes", size: 210000, value: 47000 },
{ trader: "0xB901…5e3", side: "No", size: 198000, value: 153000 },
{ trader: "quant_owl", side: "Yes", size: 155000, value: 35000 },
{ trader: "farmer_jo", side: "Yes", size: 121000, value: 27000 }
],
topTraders: ["resolvr", "quant_owl", "0xB901…5e3"]
},
{
id: "us-election-28",
question: "Democratic candidate wins 2028 election?",
category: "Politics",
status: "Active",
resolves: "Nov 7, 2028",
yesPrice: 54.8,
change24h: 0.6,
volume24h: 3620000,
volumeTotal: 61500000,
liquidity: 9870000,
openInterest: 28100000,
traders: 14700,
yesSeries: genSeries(40, 50, 3.5, 0.01),
noSeries: null,
orderBook: {
bids: [[54.5, 260000], [54.0, 310000], [53.5, 220000], [53.0, 190000], [52.5, 140000]],
asks: [[55.0, 245000], [55.5, 300000], [56.0, 210000], [56.5, 175000], [57.0, 120000]]
},
holders: [
{ trader: "0x2Ff8…a10", side: "Yes", size: 610000, value: 334000 },
{ trader: "arb_meridian", side: "No", size: 540000, value: 244000 },
{ trader: "night_owl_88", side: "Yes", size: 410000, value: 224000 },
{ trader: "0xC450…9d1", side: "Yes", size: 380000, value: 208000 }
],
topTraders: ["0x2Ff8…a10", "arb_meridian", "night_owl_88"]
},
{
id: "openai-ipo",
question: "OpenAI IPOs before end of 2027?",
category: "Business",
status: "Active",
resolves: "Dec 31, 2027",
yesPrice: 16.9,
change24h: -0.4,
volume24h: 740000,
volumeTotal: 9800000,
liquidity: 2140000,
openInterest: 5300000,
traders: 3980,
yesSeries: genSeries(40, 18, 2.5, -0.01),
noSeries: null,
orderBook: {
bids: [[16.5, 61000], [16.0, 72000], [15.5, 58000], [15.0, 40000], [14.5, 33000]],
asks: [[17.0, 55000], [17.5, 63000], [18.0, 47000], [18.5, 36000], [19.0, 28000]]
},
holders: [
{ trader: "bot_delta_9", side: "No", size: 310000, value: 258000 },
{ trader: "0x9F02…c88", side: "Yes", size: 190000, value: 32000 },
{ trader: "farmer_jo", side: "No", size: 140000, value: 116000 }
],
topTraders: ["bot_delta_9", "0x9F02…c88"]
},
{
id: "fifa-worldcup",
question: "Brazil wins 2026 FIFA World Cup?",
category: "Sports",
status: "Resolved · No",
resolves: "Jul 19, 2026",
yesPrice: 0,
change24h: 0,
volume24h: 0,
volumeTotal: 27600000,
liquidity: 0,
openInterest: 0,
traders: 19200,
yesSeries: genSeries(40, 30, 5, 0.1).map((v,i,a)=> i>34 ? Math.max(0, 30 - (i-34)*6) : v),
noSeries: null,
orderBook: { bids: [], asks: [] },
holders: [
{ trader: "night_owl_88", side: "No", size: 520000, value: 520000 },
{ trader: "0xC450…9d1", side: "Yes", size: 300000, value: 0 }
],
topTraders: ["night_owl_88", "0xC450…9d1"]
}
];
export const TRADERS = [
{
id: "quant_owl",
handle: "quant_owl",
wallet: "0x1a4F…88bC",
traits: ["Arbitrage Bot", "High Volume"],
joined: "Mar 2024",
pnlRealized: 184200,
pnlUnrealized: 32100,
winRate: 68.4,
volume: 4820000,
avgPositionSize: 41200,
activePositions: 14,
pnlSeries: genSeries(30, 40, 6, 0.08),
positions: [
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 318000, entry: 62.1, current: 71.4, pnl: 29580, status: "Open" },
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 155000, entry: 18.4, current: 22.6, pnl: 6510, status: "Open" },
{ market: "US Presidential Election 2028", side: "No", size: 96000, entry: 48.2, current: 45.2, pnl: 2880, status: "Open" },
{ market: "Bitcoin above $100k Jan 2026", side: "Yes", size: 210000, entry: 55.0, current: 100, pnl: 94500, status: "Resolved" }
],
connectedWallets: ["0x88Ac…2f01", "0x5b0D…e410"]
},
{
id: "bot_delta_9",
handle: "bot_delta_9",
wallet: "0x5C90…14fA",
traits: ["Trading Bot", "Resolution Farming"],
joined: "Jan 2025",
pnlRealized: 61400,
pnlUnrealized: -8200,
winRate: 54.1,
volume: 12400000,
avgPositionSize: 18300,
activePositions: 62,
pnlSeries: genSeries(30, 30, 3, 0.02),
positions: [
{ market: "Bitcoin above $150,000 by year end?", side: "No", size: 610000, entry: 41.0, current: 38.2, pnl: 17080, status: "Open" },
{ market: "OpenAI IPOs before end of 2027?", side: "No", size: 310000, entry: 84.2, current: 83.1, pnl: 3410, status: "Open" },
{ market: "Brazil wins 2026 FIFA World Cup?", side: "No", size: 520000, entry: 71.0, current: 100, pnl: 150800, status: "Resolved" }
],
connectedWallets: ["0x14fA…C90D", "0x77b2…9e11", "0x0021…aabc"]
},
{
id: "arb_meridian",
handle: "arb_meridian",
wallet: "0x902D…7Ac1",
traits: ["Arbitrage Bot"],
joined: "Aug 2023",
pnlRealized: 402800,
pnlUnrealized: 51200,
winRate: 71.9,
volume: 22100000,
avgPositionSize: 68400,
activePositions: 9,
pnlSeries: genSeries(30, 60, 5, 0.05),
positions: [
{ market: "Bitcoin above $150,000 by year end?", side: "Yes", size: 540000, entry: 30.5, current: 38.2, pnl: 41580, status: "Open" },
{ market: "Democratic candidate wins 2028 election?", side: "No", size: 540000, entry: 47.0, current: 45.2, pnl: 9720, status: "Open" },
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 180000, entry: 58.0, current: 71.4, pnl: 24120, status: "Open" }
],
connectedWallets: []
},
{
id: "resolvr",
handle: "resolvr",
wallet: "0xF001…22Cd",
traits: ["Human", "Resolution Farming"],
joined: "Nov 2024",
pnlRealized: 92300,
pnlUnrealized: 12800,
winRate: 59.7,
volume: 6740000,
avgPositionSize: 27600,
activePositions: 22,
pnlSeries: genSeries(30, 35, 4, 0.03),
positions: [
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 210000, entry: 60.0, current: 71.4, pnl: 23940, status: "Open" },
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 210000, entry: 15.2, current: 22.6, pnl: 15540, status: "Open" }
],
connectedWallets: ["0x22Cd…F001"]
},
{
id: "night_owl_88",
handle: "night_owl_88",
wallet: "0x3E77…B420",
traits: ["Human", "High Volume"],
pnlRealized: 128900,
pnlUnrealized: 4100,
winRate: 62.2,
volume: 9120000,
avgPositionSize: 35200,
activePositions: 11,
pnlSeries: genSeries(30, 42, 4, 0.04),
positions: [
{ market: "Democratic candidate wins 2028 election?", side: "Yes", size: 410000, entry: 51.0, current: 54.8, pnl: 15580, status: "Open" },
{ market: "Brazil wins 2026 FIFA World Cup?", side: "No", size: 520000, entry: 68.0, current: 100, pnl: 166400, status: "Resolved" }
],
connectedWallets: []
},
{
id: "farmer_jo",
handle: "farmer_jo",
wallet: "0xA210…6Bf0",
traits: ["Human", "Resolution Farming"],
pnlRealized: 21400,
pnlUnrealized: -3100,
winRate: 48.9,
volume: 1840000,
avgPositionSize: 9200,
activePositions: 31,
pnlSeries: genSeries(30, 20, 3, -0.01),
positions: [
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 121000, entry: 24.0, current: 22.6, pnl: -1690, status: "Open" },
{ market: "OpenAI IPOs before end of 2027?", side: "No", size: 140000, entry: 80.0, current: 83.1, pnl: -4340, status: "Open" }
],
connectedWallets: ["0x6Bf0…A210", "0x9021…4Ccb"]
}
];
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB