11 Commits
Author SHA1 Message Date
RichardandClaude Opus 5 c863bf5fce Plattform-Fundament im Core: Vertraege, Registry, Global-Adapter (#623)
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Neue neutrale Handelsvertraege (Maerkte, Orderbuch, Orders, Portfolio,
Gebuehren) hinter IPlatformAdapter, aufgeloest ueber IPlatformRegistry je
Konto. Polymarket Global ist eine duenne Huelle um die bestehenden Dienste;
PolymarketClobClient bleibt unveraendert. Das Gateway lehnt ab, was die heutige
Anbindung nicht sauber kann (Post-only, IOC/GTD, falsche Plattform), bevor
etwas an den CLOB geht.

core_accounts und core_markets bekommen die Spalte Platform (Vorgabe
polymarket-global), core_markets den Schluessel (Platform, Id). core_positions
bleibt: die Plattform folgt aus dem Konto.

Noch nutzt kein Modul die Vertraege - das Handelsverhalten ist unveraendert.
Vor dem ersten Start auf dem Server: dotnet ef database update --project
src/PolyTrader.Core (Migration AddPlatformColumns).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 10:54:00 +02:00
RichardandClaude Opus 5 05979406c7 Konzept Mehrere Handelsplattformen: Polymarket US, spaeter Kalshi (#622-#629)
Ein Kunde mit US-Wohnsitz soll ueber Polymarket US handeln - eine eigene,
CFTC-regulierte Boerse, an der die heutige CLOB-Anbindung nicht verwendbar ist.
Das Konzept macht Plattform zu einem Begriff im Core, laesst Module ihre
Plattformen erklaeren und haelt Strategievarianten je Plattform im Modul.

Begruendungen zu #622-#629 in docs/ROADMAP.md, Bauanleitung in
docs/archiv/konzepte/KONZEPT-Multi-Plattform.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 10:54:00 +02:00
RichardandClaude Opus 5 94716157cf Adapter-Selbsttest: 16 Werkzeuge statt 15
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
dc_finding_summary ist dazugekommen; die Zahl in der Anleitung war
veraltet und macht beim Pruefen unnoetig stutzig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:13:28 +02:00
RichardandClaude Opus 5 4142d0c1c9 Begruendung zu #621: Management-Center-Anbindung
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Die Betriebsfreigabe wird im Projekt polytrader-managementcenter gebaut;
wirken muss sie hier. #621 ist ausschliesslich der Einbau auf der
Client-Seite und steht auf deferred, bis SDK (#613) und Freigabe (#614)
dort stehen.

Damit faellt zugleich die Entscheidung vom 31.07.2026 gegen eine
Laufzeit-Revalidierung - fuer die Lizenz bleibt sie gueltig, fuer die
Betriebsfreigabe nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:33:57 +02:00
RichardandClaude Opus 5 5419348a38 Version 0.3.0
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Release vor dem naechsten Test (Richard, 15.09.2026): enthaelt #582, #583, #587, #600, #155, #50, #410 und die WebUI-Paritaet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:38:55 +02:00
RichardandClaude Opus 5 c343dd57f8 Fenstersymbol unter Linux: _NET_WM_ICON selbst setzen
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
#410: Avalonia 11.3.19 laedt Icon="..." korrekt, schreibt _NET_WM_ICON aber nie auf
das X11-Fenster - Fensterliste und Alt-Tab zeigten kein Symbol. Richards
Entscheidung vom 15.09.2026: nicht auf Avalonia 12 warten.

LinuxWindowIcon haengt sich klassenweit an Window.WindowOpenedEvent und setzt die
Eigenschaft ueber eine eigene libX11-Verbindung (64 und 32 px aus icon.png). Nur
Linux, nur X11; scheitert es, einmal eine Warnung ins Log, nie ein Abbruch. Zu
entfernen, sobald Avalonia es selbst tut.

Auf der VM geprueft: Hauptfenster und Beenden-Dialog tragen _NET_WM_ICON (64 x 64),
keine Warnung im Log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:38:33 +02:00
RichardandClaude Opus 5 cec8b91ebd Desktop beendet sich auf SIGTERM, statt bis zum SIGKILL weiterzulaufen
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
#600: Die ConsoleLifetime des Hosts faengt SIGTERM ab und ruft StopApplication.
Die Avalonia-Oberflaeche hing nicht daran - Main wartete weiter auf sie, und
systemctl stop, Abmelden oder Herunterfahren endeten mit SIGKILL.

App.axaml.cs haengt AvaloniaUiHost.ShutdownFromHost an ApplicationStopping:
Die Oberflaeche schliesst sich ohne Sicherheitsabfrage (der Stopp von aussen ist
selbst die Anweisung), danach laeuft der geordnete Dienst-Stopp aus Program.Main.

Auf der Linux-VM geprueft: SIGTERM -> beendet nach 1 s, "Dienste in 0,1 s beendet";
Gegenprobe "Beenden" in der Oberflaeche ebenso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:09:43 +02:00
RichardandClaude Opus 5 23794304e6 Build ohne Warnungen, Kalendersymbol unter Linux sichtbar
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
#155: Die 15 Build-Warnungen sind beseitigt, keine davon war ein echter Fehler.
- CS8848 (DashboardWindow): Klammern um "as string" vor dem switch; die
  Auswertung war schon richtig, jetzt ist sie auch eindeutig lesbar
- CS8602 (PdfExporter): Style "Normal" legt MigraDoc immer an
- 13x CS8618: Felder, die nur der parameterlose XAML-Konstruktor offen laesst

#50: Fluent zeichnet das Kalenderblatt im CalendarDatePicker als 0,5-px-Rahmen.
Unter Linux (Skalierung 1,0) fiel er weg, uebrig blieben Tageszahl und Punkt.
Eigener Style mit Kalenderblatt aus ganzen Pixeln. Auf der VM in beiden
Farbschemata geprueft; der Kalender klappt weiterhin auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:03:34 +02:00
RichardandClaude Opus 5 b4d8a2a647 Update-Pruefung regelmaessig und ab Werk an, Installation nur auf Klick
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Richards Vorgabe vom 14.09.2026 (#587): Updates nie automatisch installieren,
aber regelmaessig pruefen und darauf hinweisen.

Bisher pruefte der Desktop genau einmal beim Start, das WebUI nur beim
Seitenaufruf, und ab Werk gar nicht (UpdateCheckEnabled=false). Eine
wochenlang laufende oder frisch installierte Anwendung erfuhr nie von einem
neuen Release.

- UpdateCheckEnabled ab Werk true, neu UpdateCheckIntervalHours (6, 1..168)
- UpdateCheckBackgroundService: erste Pruefung nach 30 s, dann im Takt; laeuft
  auch ohne WebUI und kopflos; Hinweis im Log einmal je neuer Fassung
- Registrierung ueber AddPolyTraderUpdateCheck, nicht mehr am WebUI
- Desktop-Banner haengt an UpdateStatusService.Checked; "Spaeter" blendet bis
  zur naechsten neueren Fassung aus
- WebUI-Banner holt alle 10 Minuten den vorgehaltenen Stand

Auf der Linux-VM geprueft: 0.1.9 ohne Handeinstellung erkennt 0.2.0, ein
Hinweis im Log, Banner erscheint, "Spaeter" blendet aus, Beenden in 0,1 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:16:33 +02:00
RichardandClaude Opus 5 14c89aea7d Beenden haengt nicht mehr, Update-Agent startet erst nach Bestaetigung
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Beim Updatetest auf der Linux-VM (14.09.2026) zeigte sich zweierlei:

1. Nach "Beenden" blieb der Prozess unbegrenzt stehen - Fenster offen, Port zu,
   Main-Thread in host.StopAsync(...).GetResult(), selbst SIGTERM ohne Wirkung.
   Betraf jedes Beenden der Oberflaeche, nicht nur den Update-Weg. Der Main-Thread
   traegt nach StartWithClassicDesktopLifetime noch Avalonias SynchronizationContext,
   dessen Dispatcher nicht mehr pumpt. Der Stopp laeuft jetzt per Task.Run ohne
   diesen Kontext, mit Obergrenze 45 s und Log der Dauer.

2. Der update-agent startete schon beim Klick auf "Installieren". Seine 40 s
   Wartefrist verstrichen waehrend Download und 10-Sekunden-Sperre, er brach ohne
   Update ab. Er startet jetzt erst nach bestaetigtem Beenden, Frist 120 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:52:06 +02:00
RichardandClaude Opus 5 0f18f562c7 Release 0.2.0 vorbereiten: update-agent ins Paket, Release-Skripte
Das 0.1.0-Paket enthielt keinen update-agent. "Jetzt installieren" sucht ihn
ueber UpdateClient.ResolveAgentPath() neben der Anwendung - ohne ihn konnte
sich keine Installation selbst aktualisieren.

- BundleUpdateAgent (App-csproj): legt den Agenten beim Publish fuer eine
  Laufzeitkennung neben die Anwendung; fuer ein Release ein Abbruch, wenn er fehlt
- scripts/release.ps1: unveraenderte Vorlage des Deploymentcenters
- scripts/publish-release.ps1: holt den Agenten mit Pruefsumme aus installer.json
  und legt die Packager-Konfiguration neben pack-and-deploy
- Version 0.2.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 10:55:43 +02:00
47 changed files with 3137 additions and 99 deletions
+4
View File
@@ -43,6 +43,10 @@ settings-password.json
# Packager-Zugangsdaten (FTP, updateservice:publish-Token) – Vorlage in deploy/packager.config.example.json
deploy/packager.config.json
# Release (scripts/release.ps1): geholte Werkzeuge samt Packager-Konfiguration und Publish-Ausgabe
.dc-tools/
artifacts/
# ── Logs & temporäre Dateien ─────────────────────
*.log
*.tmp
+1 -1
View File
@@ -172,7 +172,7 @@ Danach `DC_TOKEN` eintragen und Claude Code neu starten. Prüfen ohne Claude:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node J:/Softwareprojekte/Deploymentcenter/mcp/deploymentcenter-mcp.mjs
```
Erwartet: 15 Werkzeuge.
Erwartet: 16 Werkzeuge (seit `dc_finding_summary` dazugekommen ist).
**Das Token muss ein Werkbank-Token sein** (`owner_type: dev_agent`, gebunden an
`polytrader`, Scopes `bugtracker:manage`, `supervisor:read`, `watchdog:read`).
+1 -1
View File
@@ -17,7 +17,7 @@
docs/archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md (D-4).
-->
<PropertyGroup>
<Version>0.1.0</Version>
<Version>0.3.0</Version>
</PropertyGroup>
</Project>
+121
View File
@@ -192,6 +192,32 @@ Sicherheits-Auflagen von selbst — siehe #174.
→ [archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md](./archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md)
### [A] #621 — Management-Center-Anbindung
**Auslöser: sobald #613 (SDK und Testclient) und #614 (Freigabe vollständig) im Projekt
`polytrader-managementcenter` abgeschlossen sind.**
Aus rechtlichen Gründen darf PolyTrader nur handeln, solange ein Mensch den Weiterbetrieb in
festgelegtem Takt aktiv bestätigt. Bestätigt wird in einem eigenständigen Kundencockpit — eigener
Server, eigenes Repository, eigenes Deploymentcenter-Projekt. Dieses Item ist **ausschließlich der
Einbau auf der Client-Seite**: SDK einbinden, Prüfung in
[`TradingGate`](../src/PolyTrader.Modules.CopyTrading/Logic/TradingGate.cs) hängen — dorthin, wo
seit #570 alle Sperren liegen —, cockpit-verwaltete Parameter lokal nur noch lesbar anzeigen,
regelmäßigen automatischen Neustart einbauen, Kennzahlen täglich melden.
**Damit fällt die Entscheidung vom 31.07.2026** in `Licensing/LicenseGate.cs`: „eine
Laufzeit-Revalidierung gibt es bewusst nicht." Betriebsfreigabe ist nicht Lizenz — zwei Fragen,
zwei Mechanismen, und die neue verlangt eine Prüfung zur Laufzeit. Für die **Lizenz** bleibt die
alte Entscheidung gültig.
Zwei Dinge sind bereits vorhanden und werden nicht neu gebaut: `TradingMode.SellOnly` existiert
samt Semantik, und die Sperren liegen seit #570 an einer Stelle. Ein zweiter Ort für die Prüfung
wäre die Wiederholung von #569 mit höherem Einsatz.
**Ohne dieses Vorhaben kein Live-Betrieb.** Es geht damit allem in Themenbereich B voraus.
→ Konzept im Nachbarprojekt: `PolytraderManagementcenter/docs/KONZEPT.md`
### [A] #137 — Linux-Betrieb auf einer Test-VM
**War bis zum 23.08.2026 fälschlich als „blockiert durch die DC-Abnahme" geführt. Ist es
@@ -382,6 +408,80 @@ Abgleich der unabhängig erhobenen Buchhaltung gegen die eigene Trading-DB. Nied
aber der eigentliche Prüfwert des Moduls: **Abweichungen zwischen beiden Quellen sind das
Signal.**
### Mehrere Handelsplattformen (#622–#627)
**Anlass (16.09.2026):** Ein Kunde mit Wohnsitz in den USA kann KYC machen. polymarket.com sperrt
US-IPs, er muss über **Polymarket US** handeln — eine eigene, CFTC-regulierte Börse mit eigener
API. Von der heutigen CLOB-Anbindung ist dort nichts verwendbar: andere Anmeldung (Ed25519 statt
EIP-712/HMAC), Märkte per Slug statt Token-ID, USD statt USDC, automatische Abwicklung statt
Redeem, andere Gebührenformel, eigener Liquiditätspool.
Richard hat entschieden: Wir bauen nicht nur einen zweiten Adapter, sondern machen **Plattform zu
einem Begriff im Core**, damit später Kalshi ein weiterer Adapter ist und kein weiterer Umbau.
Module erklären ausdrücklich, welche Plattformen sie bedienen; Strategien dürfen sich je Plattform
unterscheiden.
**Warum in B und nicht in C:** Es entsteht kein neues Strategiemodul, sondern ein weiterer Weg, die
gebauten Module an echtes Geld zu bringen. Die Abhängigkeit von A bleibt: Ohne Betriebsfreigabe
(#621) und Feldtest handelt auch der US-Kunde nicht live.
**Die zwei Auflagen, die das Konzept trägt:**
1. **Der Global-CLOB-Code wird umhüllt, nicht umgebaut.** `PolymarketClobClient` bleibt Zeile für
Zeile, wie er ist; der Global-Adapter übersetzt nur. Ein Fundament, das den bewährten Geldpfad
verändert, wäre teurer als der US-Kunde wert ist.
2. **Die Plattform-Erklärung eines Moduls wirkt im Handelspfad**, nicht nur in der Anzeige. Das
Order-Gateway lehnt ab, was das auslösende Modul nicht erklärt hat — dieselbe Lehre wie #569/#570.
→ [archiv/konzepte/KONZEPT-Multi-Plattform.md](./archiv/konzepte/KONZEPT-Multi-Plattform.md)
### [B] #622 — PL-0: Voraussetzungen für den US-Kunden klären
Keine Programmierarbeit, aber ohne sie ist #625 nur lesend baubar: API-Schlüssel des Kunden,
Serverstandort (US-Standort gegen US-IP-Sperre von polymarket.com), ob es für die Retail-API eine
Testumgebung gibt, und unsere Rolle beim automatisierten Handel auf einem fremden KYC-Konto.
### [B] #623 — PL-1: Plattform-Fundament im Core
`PlatformId`, `PlatformCapabilities`, neutrale Verträge mit `InstrumentRef` statt `TokenId`,
Registry, Global-Adapter als Hülle, Spalte `platform` mit Vorgabe `polymarket-global`.
**Akzeptanz: Verhalten unverändert, Diff an `PolymarketClobClient.cs` leer.** `TokenId` steht in
73 Dateien — deshalb bleibt es als Eigenschaft erhalten und zieht schrittweise um.
### [B] #624 — PL-2: Module erklären ihre Plattformen
**Auslöser: sobald #623 abgeschlossen ist.**
`IPolyTraderModule.SupportedPlatforms` mit Vorgabe „nur Global" — ein Modul, das nichts erklärt,
verhält sich wie heute. Kennzeichen in Modul-Tab und Kontenansicht (Desktop und WebUI aus einer
Core-Regel, wie seit #560), Warnung bei Konten ohne Modul, Sperre im Order-Gateway.
Warum Erklärung **und** Fähigkeiten: Dass eine Plattform alles Nötige *hat*, heißt nicht, dass die
Strategie dort *geprüft* ist.
### [B] #625 — PL-3: Adapter Polymarket US
**Auslöser: sobald #623 abgeschlossen ist; echte Orders zusätzlich erst nach #622.**
Erst lesend, dann Demo gegen das echte US-Buch, dann Orders. Die Retail-API hat keine dokumentierte
Sandbox — Demo gegen Produktion ist damit der einzige Weg zur Messphase.
### [B] #626 — PL-4: ResolutionFarming für Polymarket US
**Auslöser: sobald #624 und #625 abgeschlossen sind.**
Erste Strategie für den US-Kunden, weil sie keine fremden Wallet-Daten braucht. Die US-Gebühr
`Θ·p·(1−p)` ist nahe 0,95 sehr klein, Maker bekommen einen Rabatt, und der Redeem (#146) entfällt.
Global-Schwellen sind wegen des getrennten Pools **nicht** übertragbar — Startwerte kommen aus der
Messphase.
### [B] #627 — PL-5: Accounting für Polymarket-US-Konten
**Auslöser: sobald #625 abgeschlossen ist.**
Keine Chain, keine Data-API: Aktivität, Saldo und Balance-Ledger kommen aus der US-API, in USD.
Macht nebenbei #147 (US-Steuerschicht) greifbar — der US-Kunde ist genau dieser Fall.
---
## C — Neue Strategiemodule
@@ -517,6 +617,27 @@ keine API existiert. Berührt auch #151 — die Fingerprint-Baseline wäre von d
Prüfplan: [pruefplaene/PREDICTALYTICS-PRUEFPLAN-Master-Auswahl.md](./pruefplaene/PREDICTALYTICS-PRUEFPLAN-Master-Auswahl.md)
### [D] #628 — PL-6: CopyTrading plattformübergreifend
**Auslöser: sobald #625 abgeschlossen ist und Richard entschieden hat, ob sich das lohnt.**
Auf Polymarket US sind fremde Wallets nicht sichtbar; CopyTrading hat dort keine eigenen Master.
Denkbar wäre: Master weiter auf Polygon beobachten, auf dem passenden US-Markt ausführen.
**Warum nicht beschlossen:** Nicht jeder Markt existiert in den USA, der Pool ist getrennt, Kurse
und Gebühren weichen ab — der Einstieg liegt systematisch anders als beim Master, und genau dieser
Abstand entscheidet heute über die Rentabilität (#163).
→ [archiv/konzepte/KONZEPT-Multi-Plattform.md](./archiv/konzepte/KONZEPT-Multi-Plattform.md)
### [D] #629 — PL-7: Adapter Kalshi
**Auslöser: sobald #623 abgeschlossen ist und ein Kunde Kalshi konkret braucht.**
Vorbereitet, nicht beschlossen. Zugleich der Prüfstein für #623: Kommt der Adapter ohne Änderung an
einem Modul aus, trägt die Abstraktion.
→ [archiv/konzepte/KONZEPT-Multi-Plattform.md](./archiv/konzepte/KONZEPT-Multi-Plattform.md) §7
---
## T — Technische Schuld
+1
View File
@@ -42,6 +42,7 @@ Was hier **nicht** mehr gilt, ist der **Status**. Fortschrittsangaben, Häkchen
| `KONZEPT-Modul-Accounting.md` | A-1/A-2/A-4 ✅, A-3 ist **B6**, A-5 ist **B7** |
| `KONZEPT-Modul-Supervisor.md` | S-0–S-4 ✅, Live-Key-Test ist **B5** |
| `KONZEPT-Modul-DataDriven.md` | **D1** — nicht beschlossen, es gibt keinen Umsetzungsplan |
| `KONZEPT-Multi-Plattform.md` | #622–#627 (B), #628–#629 (D) — Polymarket US, Plattform-Abstraktion, Kalshi |
### Einzeldokument
@@ -0,0 +1,349 @@
# Konzept: Mehrere Handelsplattformen (Polymarket US, später Kalshi)
**Angelegt am 16.09.2026.** Planungsstand und Reihenfolge stehen im Deploymentcenter
(Items #622–#629), nicht hier. Diese Datei ist die Bauanleitung und die Begründung.
---
## 1. Anlass und Ziel
Ein Kunde mit Wohnsitz in den USA soll PolyTrader nutzen. Er darf polymarket.com nicht verwenden
(US-IPs gesperrt), wohl aber **Polymarket US** — eine eigene, CFTC-regulierte Börse (QCX LLC als
Designated Contract Market, QC Clearing LLC als Clearingstelle) mit KYC. Der Kunde kann KYC machen.
Polymarket US ist technisch **ein anderer Handelsplatz**, nicht eine Variante desselben. Keine
Zeile der heutigen CLOB-Anbindung ist dort verwendbar (siehe Abschnitt 2). Daraus folgen drei Ziele:
1. **Ein zweiter Plattform-Adapter** für Polymarket US.
2. **Plattform als Konzept im Core**, sodass ein dritter Handelsplatz (Kalshi) ein weiterer Adapter
ist und kein weiterer Umbau.
3. **Module erklären, welche Plattformen sie bedienen können** — sichtbar in Desktop und WebUI und
wirksam im Handelspfad. Strategien dürfen sich je Plattform unterscheiden.
---
## 2. Was Polymarket US anders macht
Quelle: offizielle Doku `https://docs.polymarket.us` (Stand 16.09.2026). Vor dem Bau erneut prüfen —
die Plattform ist jung und ändert sich (Gebühren wurden am 16.09.2026 angehoben).
| | Polymarket Global (heute) | Polymarket US |
|---|---|---|
| Endpunkte | CLOB, Gamma, Data-API getrennt | `api.polymarket.us` (authentifiziert), `gateway.polymarket.us` (öffentlich) |
| Echtzeit | CLOB-WSS, Alchemy/Polygon | `wss://api.polymarket.us/v1/ws/private` (Orders, Positionen, Saldo), `/v1/ws/markets` (Buch, Trades; max. 10 Märkte je Verbindung) |
| Anmeldung | L1 EIP-712 mit Wallet-Key → L2 HMAC (apiKey/secret/passphrase) | Key-ID + Ed25519-Secret; Header `X-PM-Access-Key`, `X-PM-Timestamp` (ms, ±30 s), `X-PM-Signature` = Base64(Ed25519(`timestamp + method + path`)) |
| Konto | Wallet / Proxy-Wallet, kein KYC | KYC-Person (iOS-App), Schlüssel unter `polymarket.us/developer`, Secret wird **nur einmal** angezeigt |
| Markt-Identität | conditionId + tokenId je Ausgang | `marketSlug`; Ausgang über `intent` bzw. `outcomeSide` YES/NO |
| Order | signierte Order (EIP-712), Preis als Zahl, FOK/GTC/GTD | `POST /v1/orders`: `intent` (`BUY_LONG`, `SELL_LONG`, `BUY_SHORT`, `SELL_SHORT`), `price {value, currency}`, `quantity` oder `cashOrderQty`, TIF `DAY/GTC/GTD/IOC/FOK`, `participateDontInitiate` (Post-only), `manualOrderIndicator`, `synchronousExecution` |
| Weitere Order-Wege | — | Modify, Preview, Close-Position, Sammel-Orders, Cancel-All |
| Geld | USDC auf Polygon | USD per Karte, ACH, Wire |
| Abwicklung | On-Chain-Redeem nötig (#146) | automatisch zu 1 $ / 0 $, kein Redeem |
| Gebühr | Taker in bps je Kategorie (`FeeModel`) | `Θ · C · p · (1−p)`; Taker Θ = 0,0695 (ab 16.09.2026), Maker-Rabatt Θ = −0,0125; Banker's Rounding auf Cent |
| Limits | eigene je Endpunkt | 20 Anfragen/s je Schlüssel bzw. je IP; 429 → Backoff. „Global Rate Limit Exceeded" nach 5 s ist Latenzschutz, **kein** Limit |
| Testumgebung | — | Retail-API: keine dokumentierte Sandbox. Preprod nur für die Exchange-API (OAuth), Zugang über onboarding@polymarket.us |
| Liquidität, Marktangebot | globaler Pool, voll | eigener US-Pool, kleineres Angebot |
**Zwei Folgen, die das Design bestimmen:**
- Die **Gebührenformel** ist eine andere *Form*, nicht nur ein anderer Satz. Ein „Satz in bps" reicht
als Modell nicht. Nahe 0,95 ist die US-Gebühr winzig, bei 0,50 am höchsten.
- **Fremde Wallets sind nicht sichtbar.** Alles, was CopyTrading heute beobachtet (Polygon-Logs,
Data-API-Activity), gibt es für den US-Pool nicht.
---
## 3. Ist-Zustand im Code
PolyTrader ist modular (Core + Module), aber **Polymarket Global ist überall stillschweigend
vorausgesetzt**:
| Stelle | Bindung |
|---|---|
| [`IClobClient`](../../../src/PolyTrader.Core/Services/IClobClient.cs) | `tokenId`, `isNegRisk`, `actualFeeBps`, Decimals-Overrides — CLOB-Begriffe im Vertrag |
| [`PolymarketClobClient`](../../../src/PolyTrader.Core/Services/PolymarketClobClient.cs), [`PolymarketApiService`](../../../src/PolyTrader.Core/Services/PolymarketApiService.cs) | direkt von Modulen aufgelöst; Registrierung von `IClobClient` sogar im CopyTrading-Modul |
| [`AccountState`](../../../src/PolyTrader.Core/Models/AccountState.cs) | `WalletAddress`, `ApiPassphrase`, `PrivateKey` — Global-Zugangsdaten als feste Felder |
| [`Position`](../../../src/PolyTrader.Core/Models/Position.cs), `core_positions` | Primärschlüssel `(AccountId, IsDemo, TokenId)` |
| [`MarketData`](../../../src/PolyTrader.Core/Models/MarketData.cs), `core_markets` | `ConditionId`, `ClobTokenIds`, `NegRisk` |
| [`FeeModel`](../../../src/PolyTrader.Core/Trading/FeeModel.cs) | statisch, bps je Kategorie |
| `Streaming/AlchemyWssClient`, `BlockchainWss` | Polygon |
| CopyTrading | 10 Dateien gegen CLOB/API/Chain |
| ResolutionFarming | hat schon `IFarmingMarketSource`, `IMarketResolutionSource` — **gutes Vorbild** |
| Accounting | hat schon `IActivitySource`, `ITransferSource`, `IBalanceAnchorSource` — ebenso |
| Supervisor | nur `CounterfactualJob` liest über die API |
`TokenId` kommt in 73 Dateien vor. Ein Umbau in einem Schritt ist ausgeschlossen.
---
## 4. Leitentscheidungen
1. **Die Plattform gehört zum Konto, nicht zur Installation.** Eine Installation kann Konten
mehrerer Plattformen führen. Ob der Netzweg das zulässt (US-Standort gegen US-IP-Sperre von
polymarket.com), ist eine Betriebsfrage (#622), keine Architekturfrage.
2. **Der Global-CLOB-Code wird umhüllt, nicht umgebaut.** Signierung, Betragsrechnung und
Orderablauf von `PolymarketClobClient` bleiben Zeile für Zeile, wie sie sind. Der
Global-Adapter übersetzt nur zwischen neutralem Vertrag und bestehendem Client. Das ist die
direkte Anwendung von `.agents/rules/clob.md`.
3. **Module sprechen gegen Plattform-Verträge**, nicht gegen `Polymarket*`-Klassen. Das Muster
existiert schon in ResolutionFarming und Accounting und wird verallgemeinert.
4. **Logik fragt nach Fähigkeiten, nicht nach Namen.** `if (caps.PublicTraderActivity)` statt
`if (platform == PolymarketGlobal)`. Ein neuer Handelsplatz bringt seine Fähigkeiten mit, und
bestehende Module entscheiden richtig, ohne ihn zu kennen.
5. **Module erklären ihre Plattformen ausdrücklich.** Fähigkeiten allein reichen nicht: Dass eine
Plattform alles Nötige *hat*, heißt nicht, dass die Strategie dort *geprüft* ist. Die Erklärung
ist eine Freigabe, die Fähigkeiten sind eine Plausibilitätsprüfung.
6. **Strategievarianten leben im Modul**, als Profil je Plattform — kein „ResolutionFarmingUS"-Modul.
Die fachliche Idee bleibt eine; Gebühren, Schwellen und Marktquelle unterscheiden sich.
7. **Global bleibt die Vorgabe.** Jede neue Spalte bekommt `polymarket-global` als Standardwert.
Bestehende Installationen merken nach der Migration nichts.
---
## 5. Architektur
### 5.1 Plattform und Fähigkeiten
```csharp
namespace PolyTrader.Core.Platforms;
/// Stabile Kennung, wird in der Datenbank gespeichert – nie umbenennen.
public readonly record struct PlatformId(string Value)
{
public static readonly PlatformId PolymarketGlobal = new("polymarket-global");
public static readonly PlatformId PolymarketUs = new("polymarket-us");
public static readonly PlatformId Kalshi = new("kalshi");
}
public sealed record PlatformCapabilities(
bool PublicTraderActivity, // fremde Trades beobachtbar (CopyTrading)
bool OnChainSettlement, // Redeem nötig, Chain-Transfers als Buchungsquelle
bool PostOnlyOrders,
bool OrderModify, // Preis ändern ohne Cancel+Neu
bool MakerRebate,
bool FractionalQuantity,
bool NativeDemoEnvironment, // echte Testumgebung der Plattform
IReadOnlySet<TimeInForce> TimeInForce,
string SettlementCurrency); // "USDC", "USD"
```
### 5.2 Verträge
Neutral formuliert, je Plattform einmal implementiert. Aufgelöst über eine Registry, nie direkt.
| Vertrag | Aufgabe | Global-Adapter hüllt | US-Adapter spricht |
|---|---|---|---|
| `IMarketCatalog` | Märkte suchen, nach Enddatum/Kategorie filtern, Auflösungsstatus | Gamma-API, `MarketSyncService` | `gateway` Markets/Events/Settlement |
| `IOrderBookSource` | Buch, BBO | CLOB `/book` | `gateway` Book/BBO |
| `IOrderGateway` | Platzieren, Ändern, Canceln, offene Orders | `PolymarketClobClient` | `POST/…/v1/orders` |
| `IPortfolioSource` | Positionen, Saldo, Aktivität | Data-API, Chain | Portfolio, Account-Balances |
| `IMarketStream` / `IAccountStream` | Echtzeit | CLOB-WSS, Alchemy | `/v1/ws/markets`, `/v1/ws/private` |
| `IFeeModel` | Gebühr für (Preis, Menge, Rolle) | heutiges `FeeModel` | `Θ·C·p·(1−p)` |
| `ISettlementHandler` | Was nach Auflösung zu tun ist | Redeem (#146) | nichts, nur Abgleich |
| `ITraderActivitySource` | fremde Trades | Polygon, Data-API | **nicht vorhanden** |
> **Stand nach #623 (18.09.2026):** Gebaut sind `IMarketCatalog`, `IOrderBookSource`, `IOrderGateway`,
> `IPortfolioSource`, `IFeeModel`, gebündelt in `IPlatformAdapter` (`src/PolyTrader.Core/Platforms/`).
> Die Streams, `ISettlementHandler` und `ITraderActivitySource` entstehen **mit ihrem ersten zweiten
> Nutzer**, nicht auf Vorrat: Streams mit #625, Abwicklung mit #146 bzw. #625, Trader-Aktivität mit #628.
> Die Registry löst den ganzen Adapter auf (`registry.ForAccount(account).Orders`), nicht einzelne
> Verträge per Typ.
```csharp
public interface IPlatformRegistry
{
IReadOnlyList<PlatformId> Available { get; }
PlatformCapabilities Capabilities(PlatformId p);
T Get<T>(PlatformId p) where T : class; // wirft, wenn Plattform den Vertrag nicht hat
bool TryGet<T>(PlatformId p, out T? service) where T : class;
}
```
`IClobClient` bleibt bestehen, bis CopyTrading umgestellt ist; `IOrderGateway` für Global delegiert
darauf. Es gibt keinen Tag, an dem beides gleichzeitig umgeschrieben wird.
### 5.3 Instrument und Order
```csharp
/// Ein handelbarer Ausgang auf einer Plattform. Key ist innerhalb der Plattform eindeutig.
/// Global: Key = tokenId, MarketKey = conditionId
/// US: Key = "{marketSlug}:YES", MarketKey = marketSlug
/// Kalshi: Key = "{ticker}:YES", MarketKey = ticker
/// Gleichheit nur über (Platform, Key).
public sealed record InstrumentRef(PlatformId Platform, string Key, string MarketKey = "", string Outcome = "");
public sealed record OrderRequest(
InstrumentRef Instrument,
OrderSide Side, // Buy / Sell (US: → intent BUY_LONG usw.)
decimal LimitPrice, // immer Wahrscheinlichkeit 0..1 – Adapter rechnet um (Kalshi: Cent)
decimal? Quantity, // Kontrakte/Shares
decimal? NotionalUsd, // alternativ Betrag
TimeInForce Tif,
bool PostOnly,
string OriginModule, // für die Plattform-Sperre (5.6) und das Journal
string CorrelationId);
```
Plattformspezifisches (Global: `negRisk`, Tick-Size, Decimals; US: `manualOrderIndicator`,
`synchronousExecution`) bleibt **im Adapter**. Der US-Adapter setzt `manualOrderIndicator` fest auf
`AUTOMATIC` — ein Bot, der sich als manuell ausgibt, ist ein Compliance-Fehler, keine Einstellung.
### 5.4 Konten und Zugangsdaten
- `core_accounts.platform` (Vorgabe `polymarket-global`).
- Zugangsdaten je Plattform, verschlüsselt wie F1 (`EncryptedStringConverter`):
- Global: wie heute (Wallet, Key, Secret, Passphrase, PrivateKey).
- US: Key-ID in `ApiKey`, Ed25519-Secret in `ApiSecret`. `WalletAddress`, `PrivateKey`,
`PayoutAddress` leer.
- Kalshi: Key-ID + privater Schlüssel.
Die bestehenden Felder reichen für US und Kalshi. Eine eigene Tabelle je Plattform wäre sauberer,
kostet aber eine zweite Migration der F1-Verschlüsselung. **Entscheidung: vorhandene Felder
wiederverwenden**, die Kontenansicht zeigt je Plattform nur die passenden Felder mit passender
Beschriftung. Neu prüfen, falls eine Plattform mehr als vier Geheimnisse braucht.
- Die Passwortsperre vor Konten und Geheimnissen (#567) gilt unverändert; Geheimnisse bleiben
Desktop-only (WebUI-Parität).
### 5.5 Datenbank
| Tabelle | Änderung |
|---|---|
| `core_accounts` | `+ platform` |
| `core_positions` | **keine Änderung** (Entscheidung in #623): Eine Position gehört zu einem Konto, das Konto zu genau einer Plattform. `(AccountId, IsDemo, TokenId)` bleibt eindeutig, solange `TokenId` den plattformeindeutigen `InstrumentRef.Key` hält. Eine eigene Spalte wäre eine zweite Wahrheit, die auseinanderlaufen kann |
| `core_markets` | `+ platform`; Schlüssel `(Platform, Id)` |
| Modultabellen mit Token-Bezug (`ct_`, `rf_`, `acc_`) | `+ platform` mit Vorgabe, je Modul im jeweiligen Schritt |
`Position.TokenId` bleibt, wie es ist, und hält `InstrumentRef.Key`. Die 73 Fundstellen ziehen
schrittweise um, statt in einem Commit.
Die lesenden Methoden von `IMarketRepository` (`GetById`, `FindByTokenId`, `GetActive`) liefern seit #623
nur Global-Märkte — ihre Aufrufer arbeiten mit Gamma-IDs und Token-IDs.
**Migration `AddPlatformColumns`:** Es gibt keine automatischen Migrationen beim Start. Vor dem ersten
Start einer Fassung mit #623 muss auf dem Server `dotnet ef database update --project src/PolyTrader.Core`
laufen, sonst scheitert jede Abfrage an der fehlenden Spalte `Platform`.
### 5.6 Module erklären ihre Plattformen („Auslöser")
Erweiterung von [`IPolyTraderModule`](../../../src/PolyTrader.Core/Modularity/IPolyTraderModule.cs),
abwärtskompatibel über Default-Implementierungen:
```csharp
/// Plattformen, für die dieses Modul geprüft und freigegeben ist.
/// Vorgabe: nur Polymarket Global – ein Modul, das nichts erklärt, verhält sich wie heute.
IReadOnlySet<PlatformId> SupportedPlatforms => new HashSet<PlatformId> { PlatformId.PolymarketGlobal };
/// Grund, warum das Modul eine erklärte Plattform in dieser Installation trotzdem nicht bedienen kann
/// (fehlende Fähigkeit, fehlender Schlüssel). null = bedienbar.
string? GetPlatformBlocker(PlatformId platform, PlatformCapabilities caps) => null;
```
**Eine Stelle für die Regeln.** Wie `ModuleActivationView` seit #560 liegt die Auswertung im Core
(`ModulePlatformView`), und Desktop und WebUI zeigen dasselbe:
| Wo | Was man sieht |
|---|---|
| Modul-Tab (Desktop, WebUI) | je Modul Plattform-Kennzeichen: ✓ bedienbar, ⚠ erklärt aber blockiert (mit Grund), — nicht erklärt |
| Kontenansicht | je Konto die Module, die es bedienen |
| Dashboard-Warnung | aktives Konto, das kein aktives Modul bedient; aktives Modul ohne passendes Konto |
**Wirksam an zwei Stellen:**
1. **Zuteilung:** Ein Modul bekommt nur Konten, deren Plattform es erklärt und nicht blockiert.
Module filtern nicht selbst — sie fragen den Core nach „meinen Konten".
2. **Handelspfad:** Die Registry reicht jedem Modul ein `IOrderGateway`, das `OriginModule` prüft
und eine Order für eine nicht erklärte Plattform **ablehnt und ins Journal schreibt**. Das ist die
zweite Sicherung nach dem Muster von `TradingGate` (#570): Eine Sperre, die nur in der Oberfläche
existiert, hält keinen Programmierfehler auf.
**Erste Erklärungen:**
| Modul | Global | US | Begründung |
|---|---|---|---|
| CopyTrading | ✓ | — | braucht `PublicTraderActivity`; plattformübergreifend siehe #628 |
| ResolutionFarming | ✓ | ✓ (ab #626) | nur eigene Marktdaten nötig, US-Gebühr nahe 1 sehr klein |
| Accounting | ✓ | ✓ (ab #627) | Quellen je Plattform |
| Supervisor | ✓ | ✓ | liest Journal und Dossiers, handelt nicht; `CounterfactualJob` über `IMarketCatalog` |
### 5.7 Strategieprofile je Plattform
Ein Modul, das mehrere Plattformen erklärt, hält Einstellungen **je Plattform**
(`rf_settings.platform`). Das Profil legt fest:
- Gebührenmodell (aus der Registry, nie hart kodiert) — betrifft jeden Netto-Edge-Check.
- Schwellen (Einstiegspreis, Mindest-Edge, Mindestliquidität). Bei getrenntem, kleinerem US-Pool
sind Global-Werte **nicht** übertragbar; Startwerte stammen aus der Messphase.
- Order-Stil: auf US lohnt Post-only wegen des Maker-Rabatts; auf Global zahlt der Maker 0.
- Abwicklung: Global mit Redeem, US ohne.
Die fachliche Logik (Kandidatenbewertung, Sizing, Risikogrenzen) bleibt gemeinsam und testbar ohne
Plattform.
### 5.8 Demo-Modus
Demo bleibt eine Eigenschaft des Kontos (`IsDemo`). Demo-Fills rechnen gegen **das Buch der Plattform
des Kontos**, mit deren Gebührenmodell. Da die US-Retail-API keine Sandbox hat, ist Demo gegen das
echte US-Buch der einzige Weg zur Messphase — und damit Pflicht vor dem Livegang.
---
## 6. Umsetzungsschritte
| Item | Schritt | Voraussetzung | Ergebnis |
|---|---|---|---|
| #622 | **PL-0** Voraussetzungen US-Kunde | — | KYC, API-Schlüssel, Serverstandort, Rolle geklärt |
| #623 | **PL-1** Plattform-Fundament | — | Verträge, Registry, Global-Adapter, DB-Spalten; **Verhalten unverändert** |
| #624 | **PL-2** Plattform-Erklärung der Module | #623 | Anzeige + Zuteilung + Sperre im Gateway |
| #625 | **PL-3** Adapter Polymarket US | #623; für echte Orders #622 | erst lesend, dann Demo, dann Orders |
| #626 | **PL-4** ResolutionFarming für US | #624, #625 | Profil je Plattform, Messphase |
| #627 | **PL-5** Accounting für US | #625 | US-Quellen statt Data-API/Chain |
| #628 | **PL-6** CopyTrading plattformübergreifend | #625 + Entscheidung | nicht beschlossen |
| #629 | **PL-7** Adapter Kalshi | #623 + konkreter Bedarf | nicht beschlossen |
### Akzeptanzkriterien
**PL-1**
- Alle bestehenden Tests grün, ohne Anpassung ihrer Erwartungen.
- Diff an `PolymarketClobClient.cs` ist leer. Backup trotzdem vor Beginn (Regel).
- Migration auf einer Kopie der Server-Datenbank: Bestandspositionen haben danach
`platform = polymarket-global` und denselben `OutcomeKey` wie vorher `TokenId`.
- Neue Tests: Registry löst je Plattform auf; Global-Adapter reicht Aufrufe 1:1 an den Client durch.
**PL-2**
- Ein Modul ohne Erklärung verhält sich exakt wie heute.
- Test: Order über das Gateway mit nicht erklärter Plattform → abgelehnt, Journal-Eintrag, keine
HTTP-Anfrage.
- Desktop und WebUI zeigen für dieselbe Konfiguration dieselben Kennzeichen (ein gemeinsamer Test
gegen `ModulePlatformView`).
**PL-3**
- Signatur gegen die Beispielwerte der Doku geprüft (Einheitentest mit festem Schlüssel und Zeitstempel).
- Lesend gegen Produktion: Märkte, Buch, Positionen, Saldo des Kundenkontos.
- 429 → Backoff; „Global Rate Limit Exceeded" → **kein** Drosseln (siehe Abschnitt 2).
- Secret nie im Log, nie im WebUI, verschlüsselt at-rest.
- Erste echte Order erst nach PL-4-Messphase und mit Freigabe durch Richard.
**PL-4**
- Netto-Edge-Check nutzt das US-Gebührenmodell (Test mit p = 0,50 und p = 0,97).
- Demo-Messphase mit definiertem Zeitraum und Auswertung, bevor Live freigegeben wird.
---
## 7. Ausblick Kalshi
Kalshi ist der Prüfstein für PL-1: Lässt sich der Adapter bauen, **ohne ein Modul anzufassen**, trägt
die Abstraktion. Bekannte Unterschiede (vor dem Bau gegen die aktuelle Kalshi-Doku prüfen): eigene
Anmeldung mit signierten Anfragen und privatem Schlüssel, Märkte über Ticker, Preise in Cent, eigene
Gebührenformel, eigene Demo-Umgebung. Genau deshalb ist `LimitPrice` im Vertrag eine Wahrscheinlichkeit
0..1 und die Umrechnung Sache des Adapters.
---
## 8. Offene Fragen
| Frage | Wer | Item |
|---|---|---|
| Gibt es für die Retail-API eine Testumgebung? | Anfrage an Polymarket US | #622 |
| Wo läuft die Installation des US-Kunden, und darf sie zugleich Global-Konten führen? | Richard | #622 |
| Rechtliche Einordnung unserer Rolle bei automatisiertem Handel auf einem fremden KYC-Konto | Richard / Rechtsberatung | #622 |
| Lohnt CopyTrading mit Global-Signal und US-Ausführung trotz anderer Kurse? | Entscheidung | #628 |
| Reihenfolge gegenüber Themenbereich A (Abnahme, #621 Betriebsfreigabe) | Richard | — |
+91
View File
@@ -0,0 +1,91 @@
<#
.SYNOPSIS
Veroeffentlicht PolyTrader im Deploymentcenter - der Aufruf fuer ein Release.
.DESCRIPTION
Vorschalt-Skript fuer scripts/release.ps1. Die Vorlage des Deploymentcenters bleibt
unveraendert (so laesst sie sich bei einer neuen Fassung einfach ersetzen); was PolyTrader
zusaetzlich braucht, passiert hier:
1. update-agent je Zielplattform von /installer/ holen und gegen installer.json pruefen.
Das Publish-Ziel BundleUpdateAgent (PolyTrader.App.Avalonia.csproj) legt ihn neben die
Anwendung - ohne ihn kann sich eine Installation nie selbst aktualisieren.
2. deploy/packager.config.json neben pack-and-deploy legen. Dort sucht das Werkzeug seine
Zugangsdaten, und dort stehen die excludePatterns, die appsettings.Local.json, master.key
und server_settings.xml aus dem Paket halten. Beides bleibt in .dc-tools/ (gitignored).
3. DcRequireUpdateAgent=true setzen: fehlt der Agent, bricht das Publish ab statt ein Paket
ohne Update-Weg zu veroeffentlichen.
.EXAMPLE
.\scripts\publish-release.ps1 -Version 0.2.0 -Changelog "WebUI-Paritaet, Passwortsperre"
.EXAMPLE
.\scripts\publish-release.ps1 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string] $Version,
[ValidateSet('prod', 'beta', 'dev')]
[string] $Channel = 'prod',
[string] $Changelog
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # PowerShell 5.1 laedt mit Fortschrittsbalken um ein Vielfaches langsamer
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$toolDir = Join-Path $repoRoot '.dc-tools'
$config = Get-Content (Join-Path $PSScriptRoot 'release.config.json') -Raw | ConvertFrom-Json
$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' }
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
# ------------------------------------------------------------ 1. update-agent
$installer = Invoke-RestMethod -Uri "$baseUrl/installer/installer.json" -UseBasicParsing
Write-Host "update-agent $($installer.version) laut installer.json" -ForegroundColor DarkGray
foreach ($rid in $config.runtimes) {
$name = if ($rid.StartsWith('win')) { "update-agent-$rid.exe" } else { "update-agent-$rid" }
$entry = $installer.binaries | Where-Object { $_.file -eq $name }
if (-not $entry) { throw "installer.json fuehrt keinen update-agent fuer $rid." }
$expected = $entry.sha256.ToLower()
$target = Join-Path $toolDir $name
if ((Test-Path $target) -and (Get-FileHash $target -Algorithm SHA256).Hash.ToLower() -eq $expected) {
Write-Host " $name vorhanden, Pruefsumme in Ordnung" -ForegroundColor DarkGray
continue
}
Write-Host " $name wird geholt ..." -ForegroundColor Cyan
$temp = "$target.download"
Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing
$actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Remove-Item $temp -Force
throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual"
}
Move-Item $temp $target -Force
Write-Host " Pruefsumme in Ordnung" -ForegroundColor DarkGray
}
# ---------------------------------------------------- 2. Packager-Konfiguration
$packagerConfig = Join-Path $repoRoot 'deploy/packager.config.json'
if (-not (Test-Path $packagerConfig)) {
throw "deploy/packager.config.json fehlt. Vorlage: deploy/packager.config.example.json"
}
Copy-Item $packagerConfig (Join-Path $toolDir 'packager.config.json') -Force
# ----------------------------------------------------------- 3. Release-Vorlage
$env:DcRequireUpdateAgent = 'true'
try {
& (Join-Path $PSScriptRoot 'release.ps1') @PSBoundParameters
$code = $LASTEXITCODE
}
finally {
Remove-Item Env:DcRequireUpdateAgent -ErrorAction SilentlyContinue
}
exit $code
+11
View File
@@ -0,0 +1,11 @@
{
"_comment": "Konfiguration fuer scripts/release.ps1 (unveraenderte Vorlage des Deploymentcenters). Enthaelt keine Zugangsdaten. Aufgerufen wird nicht release.ps1 direkt, sondern scripts/publish-release.ps1 - das holt vorher den update-agent und legt die Packager-Konfiguration neben das Werkzeug.",
"project": "polytrader",
"csproj": "src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj",
"runtimes": ["win-x64", "linux-x64"],
"selfContained": true,
"publishSingleFile": false,
"setupJson": "src/PolyTrader.App.Avalonia/setup.json",
"mainAssembly": "PolyTrader.App.Avalonia.dll",
"baseUrl": "https://dc.mhdf.de"
}
+233
View File
@@ -0,0 +1,233 @@
<#
.SYNOPSIS
Veroeffentlicht dieses Projekt im Deploymentcenter.
.DESCRIPTION
Vorlage zum Kopieren nach scripts/release.ps1 des eigenen Projekts.
Anzupassen ist nur der Kopf von release.config.json - dieses Skript
selbst bleibt unveraendert.
Der Ablauf je Zielplattform:
dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
pack-and-deploy uebernimmt dabei Pruefsummen, Dateimanifest, das
Fortschreiben der latest.json und die Anmeldung beim Deploymentcenter.
Das hier nachzubauen waere ein zweiter Ort fuer dieselben Fehler; das
Skript orchestriert nur.
Fehlt das Werkzeug, wird es geholt und die Pruefsumme geprueft.
.EXAMPLE
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
.EXAMPLE
.\scripts\release.ps1 -Version 1.5.0 -Channel beta -WhatIf
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
# Ohne Angabe wird die Version aus Directory.Build.props bzw. der csproj gelesen.
[string] $Version,
[ValidateSet('prod', 'beta', 'dev')]
[string] $Channel = 'prod',
[string] $Changelog,
# Als kritisches Update kennzeichnen (Rollout priorisieren).
[switch] $Critical,
[string] $ConfigFile = (Join-Path $PSScriptRoot 'release.config.json')
)
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------- Konfiguration
if (-not (Test-Path $ConfigFile)) {
throw "Konfiguration fehlt: $ConfigFile`nVorlage kopieren: release.config.example.json -> release.config.json"
}
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
foreach ($required in @('project', 'csproj', 'runtimes')) {
if (-not $config.$required) {
throw "In $ConfigFile fehlt der Eintrag '$required'."
}
}
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$csprojRel = $config.csproj
$csproj = Join-Path $repoRoot $csprojRel
if (-not (Test-Path $csproj)) {
throw "Projektdatei nicht gefunden: $csproj"
}
$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' }
$toolDir = Join-Path $repoRoot '.dc-tools'
# ---------------------------------------------------------------------- Version
function Get-ProjectVersion {
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
# Projekten, laufen die Angaben frueher oder spaeter auseinander - und
# pack-and-deploy bricht dann zu Recht mit einem Versionskonflikt ab.
foreach ($candidate in @(
(Join-Path $repoRoot 'Directory.Build.props'),
$csproj
)) {
if (-not (Test-Path $candidate)) { continue }
$match = [regex]::Match((Get-Content $candidate -Raw), '<Version>\s*([^<]+?)\s*</Version>')
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
}
return $null
}
if (-not $Version) {
$Version = Get-ProjectVersion
if (-not $Version) {
throw "Keine <Version> in Directory.Build.props oder $csprojRel gefunden. Bitte -Version angeben."
}
Write-Host "Version aus dem Projekt gelesen: $Version" -ForegroundColor DarkGray
}
if (-not $Changelog) {
$Changelog = "Release v$Version"
}
# ------------------------------------------------------------------- Werkzeug
function Get-PackAndDeploy {
$exe = Join-Path $toolDir 'pack-and-deploy.exe'
if (Test-Path $exe) { return $exe }
Write-Host "pack-and-deploy wird geholt ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
$name = 'pack-and-deploy-win-x64.exe'
$temp = Join-Path $toolDir 'download.tmp'
Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing
$expectedRaw = (Invoke-WebRequest -Uri "$baseUrl/installer/$name.sha256" -UseBasicParsing).Content
$expected = if ($expectedRaw -is [byte[]]) {
[System.Text.Encoding]::ASCII.GetString($expectedRaw)
} else { [string]$expectedRaw }
$expected = $expected.Trim().ToLower()
$actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Remove-Item $temp -Force
throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual"
}
Move-Item $temp $exe -Force
try { Unblock-File $exe -ErrorAction SilentlyContinue } catch { }
Write-Host " Pruefsumme in Ordnung." -ForegroundColor DarkGray
return $exe
}
$packAndDeploy = Get-PackAndDeploy
# --------------------------------------------------------------------- Ablauf
Write-Host ''
Write-Host "Projekt : $($config.project)" -ForegroundColor White
Write-Host "Version : $Version"
Write-Host "Kanal : $Channel"
Write-Host "Plattform : $($config.runtimes -join ', ')"
Write-Host ''
$results = @()
foreach ($rid in $config.runtimes) {
Write-Host "=== $rid ===" -ForegroundColor Cyan
$publishDir = Join-Path $repoRoot "artifacts/publish/$rid"
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'dotnet publish')) {
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
# sonst mit ins Paket wandern.
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
$publishArgs = @(
'publish', $csproj,
'-c', 'Release',
'-r', $rid,
'-o', $publishDir,
'--nologo'
)
if ($config.selfContained) { $publishArgs += '--self-contained', 'true' }
else { $publishArgs += '--self-contained', 'false' }
if ($config.publishSingleFile) { $publishArgs += '-p:PublishSingleFile=true' }
& dotnet @publishArgs
if ($LASTEXITCODE -ne 0) { throw "dotnet publish fuer $rid ist fehlgeschlagen." }
}
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
# Bewusst kein Ternary-Operator: den gibt es erst ab PowerShell 7, und
# diese Vorlage soll auch mit dem mitgelieferten 5.1 laufen.
$setupRel = if ($config.setupJson) { $config.setupJson } else { 'setup.json' }
$setupJson = Join-Path $repoRoot $setupRel
if (Test-Path $setupJson) {
Copy-Item $setupJson (Join-Path $publishDir 'setup.json') -Force
Write-Host " setup.json mitgenommen" -ForegroundColor DarkGray
}
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'pack-and-deploy')) {
$packArgs = @(
'--project', $config.project,
'--version', $Version,
'--channel', $Channel,
'--platform', $rid,
'--publish-dir', $publishDir,
'--changelog', $Changelog
)
if ($Critical) { $packArgs += '--critical' }
if ($config.mainAssembly) { $packArgs += '--main-assembly', $config.mainAssembly }
& $packAndDeploy @packArgs
$code = $LASTEXITCODE
$results += [pscustomobject]@{ Runtime = $rid; ExitCode = $code }
# 1 = Konfigurationsfehler oder Versionskonflikt: dann stimmt etwas
# Grundsaetzliches, und die weiteren Plattformen wuerden genauso
# scheitern.
if ($code -eq 1) { throw "pack-and-deploy meldet einen Konfigurations- oder Versionsfehler." }
}
Write-Host ''
}
# ------------------------------------------------------------------ Ergebnis
Write-Host '=== Ergebnis ===' -ForegroundColor White
foreach ($r in $results) {
$text = switch ($r.ExitCode) {
0 { 'vollstaendig veroeffentlicht' }
2 { 'TEILWEISE - Upload oder Registrierung fehlgeschlagen' }
default { "unerwarteter Rueckgabewert $($r.ExitCode)" }
}
$color = if ($r.ExitCode -eq 0) { 'Green' } else { 'Yellow' }
Write-Host (" {0,-16} {1}" -f $r.Runtime, $text) -ForegroundColor $color
}
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
if ($failed.Count -gt 0) {
Write-Host ''
Write-Host 'Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen,' -ForegroundColor Yellow
Write-Host 'ob die bereits hochgeladenen Dateien konsistent sind.' -ForegroundColor Yellow
exit 2
}
Write-Host ''
Write-Host "Fertig. $($config.project) $Version ist im Kanal $Channel verfuegbar." -ForegroundColor Green
+28 -7
View File
@@ -181,13 +181,34 @@
einer 1px-Linie. Fluent bringt gerundete, gefüllte Flächen mit – deshalb hier
flächendeckend zurückgesetzt. -->
<!-- Zum Kalenderknopf des CalendarDatePicker (Befund B-9 des Linux-Sichttests, wo statt
eines Kalendersymbols eine "26" stand): Das ist NICHT unsere Formatvorlage. Am
28.08.2026 auf der Linux-VM nachgemessen - die Zahl ist das heutige Datum (am 26.08.
eine "26", am 28.08. eine "28"), und sie bleibt unveraendert, wenn man diese
Vorlage per ":not(#PART_Button)" vom Knopf fernhaelt oder das Farbschema wechselt.
Avalonia zeichnet dort die heutige Tageszahl in ein Kalenderblatt; unter Linux fehlt
der Blatt-Glyph, uebrig bleibt ein Punkt. Rein kosmetisch, siehe Bugtracker-Item #50. -->
<!-- Kalenderknopf des CalendarDatePicker (#50). Fluent zeichnet dort ein Kalenderblatt aus
einem Rahmen mit 0,5 px Linienstaerke, darin die heutige Tageszahl und einen Punkt. Unter
Linux (Skalierung 1,0) faellt der Halbpixel-Rahmen weg - uebrig blieben "26" und ein
Punkt, was wie ein kaputtes Symbol aussah (Befund B-9, 26.08.2026, auf der VM nachgemessen).
Ersetzt durch ein Kalenderblatt aus ganzen Pixeln, ohne Tageszahl: Das Symbol soll nur
sagen "hier klappt ein Kalender auf". Farbe folgt dem Vordergrund des Knopfs und damit
dem Farbschema. -->
<Style Selector="CalendarDatePicker /template/ Button#PART_Button">
<Setter Property="Template">
<ControlTemplate>
<Border Background="Transparent" Width="20" Height="24">
<Grid Width="16" Height="15" RowDefinitions="4,*">
<Border Grid.RowSpan="2" BorderThickness="1" BorderBrush="{TemplateBinding Foreground}" />
<Border Grid.Row="0" Background="{TemplateBinding Foreground}" />
<UniformGrid Grid.Row="1" Rows="2" Columns="3" Margin="3,2,3,3">
<Rectangle Width="2" Height="2" Fill="{TemplateBinding Foreground}" />
<Rectangle Width="2" Height="2" Fill="{TemplateBinding Foreground}" />
<Rectangle Width="2" Height="2" Fill="{TemplateBinding Foreground}" />
<Rectangle Width="2" Height="2" Fill="{TemplateBinding Foreground}" />
<Rectangle Width="2" Height="2" Fill="{TemplateBinding Foreground}" />
<Rectangle Width="2" Height="2" Fill="{TemplateBinding Foreground}" />
</UniformGrid>
</Grid>
</Border>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Button">
<Setter Property="CornerRadius" Value="0" />
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
+17
View File
@@ -2,7 +2,9 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PolyTrader.App.Avalonia.Shell;
using PolyTrader.App.Avalonia.Views;
@@ -34,6 +36,10 @@ namespace PolyTrader.App.Avalonia
Shell.ThemeManager.Initialize(settings, "server_settings.xml",
Services.GetRequiredService<PolyTraderSharp.Services.TerminalLogger>());
// #410: Unter Linux setzt Avalonia 11 kein Fenstersymbol - Umweg, bevor das erste Fenster erscheint.
var terminal = Services.GetRequiredService<PolyTraderSharp.Services.TerminalLogger>();
LinuxWindowIcon.Register(m => terminal.Warning(m));
var uiHost = Services.GetRequiredService<AvaloniaUiHost>();
CoreViews.Register(uiHost, Services);
@@ -50,6 +56,17 @@ namespace PolyTrader.App.Avalonia
// Schliessen der Shell laeuft ueber die Sicherheitsabfrage (wie bisher das X).
desktop.ShutdownMode = global::Avalonia.Controls.ShutdownMode.OnMainWindowClose;
// Stopp von aussen (SIGTERM, systemctl stop, Abmelden): Die ConsoleLifetime des Hosts faengt das
// Signal ab und ruft StopApplication - die Oberflaeche hing daran bisher nicht, Main wartete weiter
// auf sie, und der Prozess endete erst mit SIGKILL. Jetzt schliesst sie sich, danach laeuft der
// geordnete Stopp in Program.Main. Beim eigenen "Beenden" feuert ApplicationStopping erst, wenn die
// Oberflaeche schon zu ist; der Aufruf findet dann ShutdownConfirmed gesetzt vor.
Services.GetService<IHostApplicationLifetime>()?.ApplicationStopping.Register(() =>
{
try { Dispatcher.UIThread.Post(uiHost.ShutdownFromHost); }
catch { /* Dispatcher schon beendet - dann ist die Oberflaeche ohnehin zu */ }
});
// Erste Seite: die Startseite (frühere LauncherForm).
uiHost.ActivateMain();
}
@@ -83,4 +83,24 @@
<Content Include="..\PolyTrader.Web\wwwroot\**" Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<!-- Der update-agent gehört ins Paket, direkt neben die Anwendung. "Jetzt installieren" sucht ihn
über UpdateClient.ResolveAgentPath() genau dort; fehlt er, meldet die Anwendung nur "kein
Update-Agent" und kann sich nie selbst aktualisieren. Das 0.1.0-Paket vom 20.08.2026 hatte
ihn nicht - weder die Release-Vorlage noch pack-and-deploy legen ihn hinein.
Die Binaries holt scripts/publish-release.ps1 mit Prüfsumme nach .dc-tools/. Liegen der
Agent im Paket und in der manifest.json, tauscht er sich beim Update selbst mit aus. -->
<Target Name="BundleUpdateAgent" AfterTargets="Publish" Condition="'$(RuntimeIdentifier)' != ''">
<PropertyGroup>
<_DcAgentExt Condition="$(RuntimeIdentifier.StartsWith('win'))">.exe</_DcAgentExt>
<_DcAgentSource>$(MSBuildThisFileDirectory)..\..\.dc-tools\update-agent-$(RuntimeIdentifier)$(_DcAgentExt)</_DcAgentSource>
</PropertyGroup>
<!-- Für ein Release (DcRequireUpdateAgent=true, gesetzt von publish-release.ps1) ist ein
fehlender Agent ein Abbruch; beim Publish zum Testen nur ein Hinweis. -->
<Error Condition="!Exists('$(_DcAgentSource)') And '$(DcRequireUpdateAgent)' == 'true'"
Text="update-agent für $(RuntimeIdentifier) fehlt ($(_DcAgentSource)). Ohne ihn kann sich die Installation nicht selbst aktualisieren - scripts/publish-release.ps1 holt ihn." />
<Warning Condition="!Exists('$(_DcAgentSource)') And '$(DcRequireUpdateAgent)' != 'true'"
Text="update-agent für $(RuntimeIdentifier) nicht gefunden ($(_DcAgentSource)) - dieses Publish kann sich nicht selbst aktualisieren." />
<Copy Condition="Exists('$(_DcAgentSource)')" SourceFiles="$(_DcAgentSource)" DestinationFiles="$(PublishDir)update-agent$(_DcAgentExt)" />
</Target>
</Project>
+23 -1
View File
@@ -12,6 +12,7 @@ using PolyTrader.Core.Configuration;
using PolyTrader.Core.DependencyInjection;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Notifications;
using PolyTrader.Core.Platforms;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Accounting;
using PolyTrader.Modules.CopyTrading;
@@ -120,7 +121,21 @@ namespace PolyTrader.App.Avalonia
finally
{
// Geordnetes Herunterfahren der Module – erst nach dem Ende der Oberfläche.
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
//
// NICHT direkt auf diesem Thread warten. Nach StartWithClassicDesktopLifetime trägt der
// Main-Thread noch den SynchronizationContext von Avalonia, dessen Dispatcher aber nicht
// mehr pumpt. Eine Fortsetzung im Stop-Pfad, die dorthin zurückwill, läuft dann nie - der
// Prozess blieb nach "Beenden" am 14.09.2026 auf der Linux-VM unbegrenzt stehen: Fenster
// offen, Port zu, Main-Thread in GetResult(), selbst SIGTERM ohne Wirkung. Damit griff
// auch kein Update, der update-agent wartete vergeblich auf das Prozessende.
// Task.Run startet ohne diesen Kontext; die Obergrenze fängt zusätzlich einen Dienst ab,
// der sein Token nicht beachtet.
var sw = System.Diagnostics.Stopwatch.StartNew();
var stop = Task.Run(() => host.StopAsync(TimeSpan.FromSeconds(30)));
if (stop.Wait(TimeSpan.FromSeconds(45)))
bootLog.Info($"Dienste in {sw.Elapsed.TotalSeconds:0.0} s beendet.");
else
bootLog.Warning("Dienste nach 45 s noch nicht beendet - Anwendung wird trotzdem geschlossen.");
host.Dispose();
}
}
@@ -313,6 +328,10 @@ namespace PolyTrader.App.Avalonia
services.AddSingleton(sp => new PolymarketClobClient(
sp.GetRequiredService<TerminalLogger>(), CreatePolymarketHttpClient()));
// #623: Handelsplätze hinter neutralen Verträgen. Heute nur Polymarket Global als Hülle
// um die beiden Dienste oben; Module stellen schrittweise darauf um.
services.AddPlatforms();
// Benachrichtigungen: neutrale Senke, bis RocketChat/Telegram angebunden sind.
services.AddSingleton<INotificationSink>(
sp => new LogNotificationSink(sp.GetRequiredService<TerminalLogger>()));
@@ -354,6 +373,9 @@ namespace PolyTrader.App.Avalonia
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
services.AddHostedService(sp => sp.GetRequiredService<DeploymentcenterErrorReporter>());
// #587: regelmäßige Update-Prüfung mit Hinweis - installiert nie selbst.
PolyTrader.Web.Updates.UpdateCheckServiceCollectionExtensions.AddPolyTraderUpdateCheck(services);
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
services.AddSingleton<AvaloniaUiHost>();
})
@@ -100,7 +100,35 @@ namespace PolyTrader.App.Avalonia.Shell
/// Zeigt die Sicherheitsabfrage (Beenden-Schaltfläche 10 s gesperrt) und fährt bei Bestätigung
/// herunter. Aus jeder Seite aufrufbar – auch aus Modul-Seiten, die die App nicht kennen.
/// </summary>
public async void RequestShutdown()
public void RequestShutdown() => RequestShutdown(onConfirmed: null);
/// <summary>
/// Schließt die Oberfläche OHNE Sicherheitsabfrage – nur für einen Stopp von außen (SIGTERM, systemctl
/// stop, Abmelden, Herunterfahren). Der ist selbst schon die ausdrückliche Anweisung; eine Abfrage, die
/// niemand beantwortet, hielt den Prozess bis zum SIGKILL am Leben. Nach dem Schließen läuft derselbe
/// geordnete Stopp der Dienste wie bei „Beenden" (Program.Main). Muss auf dem UI-Thread laufen.
/// </summary>
public void ShutdownFromHost()
{
if (ShutdownConfirmed) return;
ShutdownConfirmed = true;
if (global::Avalonia.Application.Current?.ApplicationLifetime
is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
}
/// <summary>
/// Wie <see cref="RequestShutdown()"/>, mit einem Schritt zwischen Bestätigung und Herunterfahren.
/// </summary>
/// <param name="onConfirmed">
/// Läuft erst, wenn die Sicherheitsabfrage bestätigt ist - liefert er <c>false</c>, wird nicht
/// beendet. Für den Update-Weg: Der update-agent darf erst starten, wenn feststeht, dass die
/// Anwendung auch endet. Vorher lief seine Wartefrist schon während der 10-Sekunden-Sperre ab,
/// und nach „Abbrechen" wartete er ins Leere.
/// </param>
public async void RequestShutdown(Func<bool>? onConfirmed)
{
if (ShutdownConfirmed || _shutdownDialogOpen) return;
_shutdownDialogOpen = true;
@@ -111,6 +139,7 @@ namespace PolyTrader.App.Avalonia.Shell
? await dlg.ShowDialog<bool>(_shell)
: await dlg.ShowDialog<bool>(new Window());
if (!confirmed) return;
if (onConfirmed != null && !onConfirmed()) return;
ShutdownConfirmed = true;
if (global::Avalonia.Application.Current?.ApplicationLifetime
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Avalonia.Controls;
using Avalonia.Platform;
using SkiaSharp;
namespace PolyTrader.App.Avalonia.Shell
{
/// <summary>
/// Setzt unter Linux das Fenstersymbol (_NET_WM_ICON) selbst - Umweg für #410.
///
/// <para>Avalonia 11.3.19 lädt das Symbol aus <c>Icon="..."</c> zwar korrekt, schreibt die Eigenschaft
/// aber nie auf das X11-Fenster (am 01.09.2026 mit einem Minimalprogramm nachgewiesen). Fensterliste und
/// Alt-Tab zeigen dann kein Symbol. Richards Entscheidung vom 15.09.2026: nicht auf Avalonia 12 warten.</para>
///
/// <para>Nach dem Öffnen jedes Fensters wird die Eigenschaft über eine eigene Verbindung zum X-Server gesetzt;
/// sie gehört dem Fenster und bleibt nach dem Schließen der Verbindung bestehen. Unter Windows trägt
/// ApplicationIcon, dort passiert hier nichts.</para>
///
/// <para><b>Entfernen</b>, sobald Avalonia die Eigenschaft selbst setzt (nachprüfen mit
/// <c>xprop -id &lt;fenster&gt; _NET_WM_ICON</c> nach dem Wechsel auf eine neuere Avalonia-Fassung).</para>
/// </summary>
internal static class LinuxWindowIcon
{
private const string IconUri = "avares://PolyTrader.App.Avalonia/Assets/icon.png";
private static readonly int[] Sizes = { 64, 32 };
private static IntPtr[]? _data;
private static bool _failed;
private static Action<string>? _warn;
/// <summary>Einmal beim Start, bevor das erste Fenster erscheint.</summary>
public static void Register(Action<string> warn)
{
if (!OperatingSystem.IsLinux()) return;
_warn = warn;
Window.WindowOpenedEvent.AddClassHandler<Window>((window, _) => Apply(window));
}
private static void Apply(Window window)
{
if (_failed) return;
try
{
var handle = window.TryGetPlatformHandle();
if (handle == null || handle.HandleDescriptor != "XID") return; // z.B. kein X11
var data = _data ??= BuildData();
IntPtr display = XOpenDisplay(IntPtr.Zero);
if (display == IntPtr.Zero) return;
try
{
IntPtr atom = XInternAtom(display, "_NET_WM_ICON", false);
XChangeProperty(display, handle.Handle, atom, XaCardinal, 32, PropModeReplace, data, data.Length);
XFlush(display);
}
finally
{
XCloseDisplay(display);
}
}
catch (Exception ex)
{
// Das Symbol ist Kosmetik und darf nie den Start oder ein Fenster stören. Einmal melden, dann Ruhe.
_failed = true;
_warn?.Invoke($"Fenstersymbol unter Linux nicht gesetzt ({ex.GetType().Name}: {ex.Message}).");
}
}
/// <summary>
/// _NET_WM_ICON-Format: je Größe Breite, Höhe, dann Breite×Höhe Bildpunkte als ARGB (nicht vormultipliziert).
/// Bei Format 32 erwartet Xlib je Wert ein <c>long</c> - auf 64-Bit also 8 Byte, daher IntPtr.
/// </summary>
private static IntPtr[] BuildData()
{
using var stream = AssetLoader.Open(new Uri(IconUri));
using var source = SKBitmap.Decode(stream)
?? throw new InvalidOperationException("icon.png nicht lesbar");
var values = new List<IntPtr>();
foreach (int size in Sizes)
{
using var scaled = source.Resize(new SKImageInfo(size, size, SKColorType.Bgra8888, SKAlphaType.Unpremul),
SKFilterQuality.High)
?? throw new InvalidOperationException($"icon.png nicht auf {size} px skalierbar");
values.Add((IntPtr)size);
values.Add((IntPtr)size);
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
SKColor c = scaled.GetPixel(x, y);
long argb = ((long)c.Alpha << 24) | ((long)c.Red << 16) | ((long)c.Green << 8) | c.Blue;
values.Add((IntPtr)argb);
}
}
return values.ToArray();
}
private static readonly IntPtr XaCardinal = (IntPtr)6;
private const int PropModeReplace = 0;
[DllImport("libX11.so.6")]
private static extern IntPtr XOpenDisplay(IntPtr displayName);
[DllImport("libX11.so.6")]
private static extern IntPtr XInternAtom(IntPtr display, string atomName, bool onlyIfExists);
[DllImport("libX11.so.6")]
private static extern int XChangeProperty(IntPtr display, IntPtr window, IntPtr property, IntPtr type,
int format, int mode, IntPtr[] data, int nelements);
[DllImport("libX11.so.6")]
private static extern int XFlush(IntPtr display);
[DllImport("libX11.so.6")]
private static extern int XCloseDisplay(IntPtr display);
}
}
@@ -125,7 +125,7 @@ namespace PolyTrader.App.Avalonia.Views
int accId = (this.FindControl<ComboBox>("cbAccount")!.SelectedItem as FilterAccount)?.Id ?? -1;
string module = this.FindControl<ComboBox>("cbModule")!.SelectedItem as string ?? "Alle Module";
string mode = this.FindControl<ComboBox>("cbMode")!.SelectedItem as string ?? "Alle";
int? days = this.FindControl<ComboBox>("cbRange")!.SelectedItem as string switch
int? days = (this.FindControl<ComboBox>("cbRange")!.SelectedItem as string) switch
{
"7 Tage" => 7,
"30 Tage" => 30,
@@ -20,6 +20,7 @@ using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTrader.Web.Updates;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
@@ -39,9 +40,11 @@ namespace PolyTrader.App.Avalonia.Views
/// </summary>
public partial class LauncherWindow : UserControl
{
private readonly IModuleUiHost _uiHost;
private readonly IServiceProvider _services;
private readonly TradingState _state;
// "= null!": Der parameterlose Konstruktor gibt es nur für den XAML-Lader und Previewer; im Betrieb
// setzt der Konstruktor mit Diensten alle drei. Beseitigt drei der 15 Build-Warnungen (#155).
private readonly IModuleUiHost _uiHost = null!;
private readonly IServiceProvider _services = null!;
private readonly TradingState _state = null!;
private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly ObservableCollection<ViewTile> _windowButtons = new();
private readonly ObservableCollection<AccountOverviewRow> _accountRows = new();
@@ -51,8 +54,11 @@ namespace PolyTrader.App.Avalonia.Views
private int _statusTicks;
/// <summary>Ergebnis der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".</summary>
private UpdateCheckResult? _updateCheck;
/// <summary>Fassung aus der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".</summary>
private string? _updateVersion;
/// <summary>Mit „Später" ausgeblendete Fassung – das Banner kommt erst für eine noch neuere wieder.</summary>
private string? _dismissedVersion;
public LauncherWindow() => AvaloniaXamlLoader.Load(this);
@@ -84,16 +90,25 @@ namespace PolyTrader.App.Avalonia.Views
this.FindControl<Button>("btnInstallUpdate")!.Click += async (_, _) => await InstallUpdateAsync();
this.FindControl<Button>("btnDismissUpdate")!.Click += (_, _) =>
{
_dismissedVersion = _updateVersion;
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = false;
};
_statusTimer.Tick += (_, _) => UpdateStatus();
_statusTimer.Start();
UpdateStatus();
RefreshData();
// D-4: einmalige, stille Prüfung beim Start - fire-and-forget, darf den Start nie
// verzögern oder blockieren. Nur aktiv, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist.
_ = CheckForUpdateAsync();
// #587: Geprüft wird regelmäßig im Hintergrund (UpdateCheckBackgroundService), hier nur angezeigt.
// Bis zum 14.09.2026 prüfte diese Seite genau einmal beim Start - eine wochenlang laufende
// Anwendung erfuhr nie von einem neuen Release. Kein Abmelden, siehe ThemeChanged unten.
var updates = services.GetService<UpdateStatusService>();
if (updates != null)
{
updates.Checked += OnUpdateChecked;
if (updates.Last is { } last) OnUpdateChecked(last);
}
// Die Umschalterflaechen werden im Code gesetzt und folgen dem Thema nicht von selbst.
// Kein Abmelden: die Seite lebt in der Einfenster-Shell bis zum Programmende, ein
@@ -185,56 +200,29 @@ namespace PolyTrader.App.Avalonia.Views
// ===== Update-Prüfung (D-4) =====
/// <summary>
/// Prüft beim Start still auf ein neues Release. Installiert NIE automatisch (Leitentscheidung
/// 4 aus dem Umsetzungsplan) - ein `is_critical`-Release ändert nur den Hinweistext, nicht das
/// Verhalten. Wirft nie: ein nicht erreichbares Deploymentcenter darf den Start nicht stören.
/// Zeigt das Ergebnis einer Hintergrundprüfung an. Installiert NIE automatisch (Leitentscheidung 4,
/// bestätigt am 14.09.2026) - ein `is_critical`-Release ändert nur den Hinweistext, nicht das Verhalten.
/// Kein Update, Fehler oder fehlende Lizenz blenden das Banner aus; die Einzelheiten stehen im WebUI
/// unter Einstellungen → Version &amp; Updates.
/// </summary>
private async Task CheckForUpdateAsync()
private void OnUpdateChecked(UpdateCheckSnapshot snapshot)
{
try
Dispatcher.UIThread.Post(() =>
{
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null || !dcOptions.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dcOptions.BaseUrl))
return;
var settings = _services.GetService<ServerSettings>();
string? licenseKey = null;
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
catch { /* nicht lesbarer Schlüssel -> Prüfung läuft ohne Zugangsdaten weiter */ }
var updateClient = new UpdateClient();
var result = await updateClient.CheckForUpdateAsync(
baseUrl: dcOptions.BaseUrl,
projectId: dcOptions.ProjectSlug,
currentVersion: BuildInfo.Version,
channel: dcOptions.Channel,
credentials: ReleaseCredentials.FromLicenseKey(licenseKey));
if (result.Unauthorized)
var banner = this.FindControl<Border>("pnlUpdateBanner")!;
if (!snapshot.UpdateAvailable || string.IsNullOrEmpty(snapshot.LatestVersion))
{
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr für den Paketabruf. Kein
// Alarm im Launcher nötig - die Lizenzprüfung selbst (D-2) hat das schon gemeldet.
_updateVersion = null;
banner.IsVisible = false;
return;
}
if (!result.UpdateAvailable || result.LatestRelease == null) return;
_updateCheck = result;
string version = result.LatestRelease.Version;
string critical = result.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
Dispatcher.UIThread.Post(() =>
{
this.FindControl<TextBlock>("lblUpdateInfo")!.Text =
$"Update verfügbar: v{version}{critical}. {result.LatestRelease.Changelog}".TrimEnd();
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = true;
});
}
catch
{
// Update-Prüfung darf den Start nie stören - stiller Rückzug, kein Log-Spam beim
// vermutlich häufigsten Fall (Deploymentcenter nicht erreichbar).
}
_updateVersion = snapshot.LatestVersion;
string critical = snapshot.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
this.FindControl<TextBlock>("lblUpdateInfo")!.Text =
$"Update verfügbar: v{snapshot.LatestVersion}{critical}. {snapshot.Changelog}".TrimEnd();
banner.IsVisible = !string.Equals(_dismissedVersion, snapshot.LatestVersion, StringComparison.OrdinalIgnoreCase);
});
}
/// <summary>
@@ -244,7 +232,7 @@ namespace PolyTrader.App.Avalonia.Views
/// </summary>
private async Task InstallUpdateAsync()
{
if (_updateCheck?.LatestRelease == null) return;
if (_updateVersion == null) return;
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null) return;
@@ -259,7 +247,7 @@ namespace PolyTrader.App.Avalonia.Views
}
bool confirmed = await DialogWindow.Confirm(this, "Update installieren",
$"Version {_updateCheck.LatestRelease.Version} herunterladen und PolyTrader danach " +
$"Version {_updateVersion} herunterladen und PolyTrader danach " +
"neu starten?\n\nOffene Vorgänge werden dabei genauso geordnet beendet wie über „Beenden\".",
"Installieren");
if (!confirmed) return;
@@ -269,7 +257,12 @@ namespace PolyTrader.App.Avalonia.Views
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
catch { /* siehe CheckForUpdateAsync */ }
UpdateClient.LaunchUpdateAgent(
// Derselbe Weg wie ein Klick auf "Beenden" - inklusive Sicherheitsabfrage. Der Agent startet
// aber erst NACH deren Bestätigung: Am 14.09.2026 lief er schon beim Klick auf "Installieren"
// los, seine 40 s Wartefrist verstrichen während Download und 10-Sekunden-Sperre, und er
// brach ohne Update ab. 120 s decken das Herunterfahren ab (Dienste bis 45 s, siehe
// Program.Main); läuft die Anwendung danach noch, verändert der Agent nichts.
Func<bool> launchAgent = () => UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: dcOptions.ProjectSlug,
channel: dcOptions.Channel,
@@ -278,12 +271,13 @@ namespace PolyTrader.App.Avalonia.Views
currentVersion: BuildInfo.Version,
exitCurrentApp: false,
waitForCurrentProcess: true,
waitTimeoutSeconds: 40,
waitTimeoutSeconds: 120,
licenseKey: licenseKey);
// Derselbe Weg wie ein Klick auf "Beenden" - inklusive Sicherheitsabfrage. Der Agent
// wartet ohnehin per --wait-for-pid auf unser Prozessende.
_uiHost.RequestShutdown();
if (_uiHost is AvaloniaUiHost shell)
shell.RequestShutdown(onConfirmed: launchAgent);
else if (launchAgent())
_uiHost.RequestShutdown();
}
// ===== Account-Übersicht =====
@@ -24,11 +24,13 @@ namespace PolyTrader.App.Avalonia.Views.Modules
private readonly ObservableCollection<CopyOpenTradeRow> _openTrades = new();
private readonly ObservableCollection<CopyClosedTradeRow> _closedTrades = new();
private readonly TradingState _state;
private readonly CopyTradingState _copyState;
private readonly ITrackedTraderRepository _traderRepo;
private readonly ICopyTradingAccountSettingsRepository _settingsRepo;
private readonly ICopyTradeLogRepository _tradeLog;
// "= null!": Der parameterlose Konstruktor gibt es nur für den XAML-Lader und Previewer; im Betrieb
// setzt der Konstruktor mit Diensten alle Felder (#155).
private readonly TradingState _state = null!;
private readonly CopyTradingState _copyState = null!;
private readonly ITrackedTraderRepository _traderRepo = null!;
private readonly ICopyTradingAccountSettingsRepository _settingsRepo = null!;
private readonly ICopyTradeLogRepository _tradeLog = null!;
private List<CopyClosedTradeRow> _closedBase = new();
private bool _loading;
@@ -21,11 +21,13 @@ namespace PolyTrader.App.Avalonia.Views.Modules
private readonly ObservableCollection<RfPosition> _positions = new();
private readonly ObservableCollection<RfClosedTrade> _history = new();
private readonly TradingState _state;
private readonly IRfCandidateRepository _candidateRepo;
private readonly IRfPositionRepository _positionRepo;
private readonly IRfClosedTradeRepository _closedRepo;
private readonly IRfSettingsRepository _settingsRepo;
// "= null!": Der parameterlose Konstruktor gibt es nur für den XAML-Lader und Previewer; im Betrieb
// setzt der Konstruktor mit Diensten alle Felder (#155).
private readonly TradingState _state = null!;
private readonly IRfCandidateRepository _candidateRepo = null!;
private readonly IRfPositionRepository _positionRepo = null!;
private readonly IRfClosedTradeRepository _closedRepo = null!;
private readonly IRfSettingsRepository _settingsRepo = null!;
private RfSettings? _currentSettings;
+3 -1
View File
@@ -15,7 +15,9 @@
"WatchdogIntervalSeconds": 60,
"WatchdogEnabled": false,
"ErrorReportingEnabled": false,
"UpdateCheckEnabled": false
"_UpdateCheck_comment": "Prueft regelmaessig auf ein neues Release und weist darauf hin. Installiert wird nie automatisch, nur auf Klick im Desktop.",
"UpdateCheckEnabled": true,
"UpdateCheckIntervalHours": 6
},
"Logging": {
"LogLevel": {
@@ -54,7 +54,14 @@ namespace PolyTrader.Core.Configuration
/// <summary>Ungefangene Fehler an /api/errors/v1/report melden.</summary>
public bool ErrorReportingEnabled { get; set; } = false;
/// <summary>Beim Start auf ein neues Release prüfen (installiert nie automatisch).</summary>
public bool UpdateCheckEnabled { get; set; } = false;
/// <summary>
/// Regelmäßig auf ein neues Release prüfen und darauf hinweisen. Installiert nie automatisch - das
/// geschieht nur auf Klick im Desktop. Ab Werk an (Richard, 14.09.2026, #587): Vorher stand hier
/// <c>false</c>, und eine frisch installierte Anwendung erfuhr nie von einem neuen Release.
/// </summary>
public bool UpdateCheckEnabled { get; set; } = true;
/// <summary>Abstand der Update-Prüfungen in Stunden, 1 bis 168.</summary>
public int UpdateCheckIntervalHours { get; set; } = 6;
}
}
@@ -28,6 +28,14 @@ namespace PolyTraderSharp.Models
[Category("02. Wallet & Keys")]
public string PrivateKey { get; set; } = string.Empty;
/// <summary>
/// Handelsplatz des Kontos (#623), gespeicherte Kennung aus <see cref="PolyTrader.Core.Platforms.PlatformId"/>.
/// Konten von vor #623 stehen auf Polymarket Global. Noch nicht in der Oberfläche wählbar – das kommt
/// mit dem ersten zweiten Adapter (#625).
/// </summary>
[Browsable(false)]
public string Platform { get; set; } = PolyTrader.Core.Platforms.PlatformId.PolymarketGlobalValue;
[Category("01. General")]
public bool IsDemo { get; set; }
+6
View File
@@ -2,6 +2,12 @@ namespace PolyTraderSharp.Models
{
public class MarketData
{
/// <summary>
/// Handelsplatz des Marktes (#623). Zusammen mit <see cref="Id"/> der Schlüssel von core_markets, weil
/// Markt-IDs verschiedener Plattformen sich überschneiden können.
/// </summary>
public string Platform { get; set; } = PolyTrader.Core.Platforms.PlatformId.PolymarketGlobalValue;
public string Id { get; set; } = string.Empty;
public string ConditionId { get; set; } = string.Empty;
public string Question { get; set; } = string.Empty;
@@ -29,6 +29,8 @@ namespace PolyTrader.Core.Persistence.Ef
e.Property(x => x.AccountId).ValueGeneratedNever();
e.Ignore(x => x.OpenPositions); // Runtime-Only (In-Memory Hot-Path)
e.Property(x => x.Name).HasMaxLength(200);
// #623: Handelsplatz des Kontos. Bestandskonten bekommen per Migration Polymarket Global.
e.Property(x => x.Platform).HasMaxLength(40).IsRequired();
e.Property(x => x.WalletAddress).HasMaxLength(128);
e.Property(x => x.ApiKey).HasMaxLength(256);
// F1: sensible Secrets at-rest verschlüsselt (AES-256-GCM, Master-Key außerhalb der DB).
@@ -66,7 +68,10 @@ namespace PolyTrader.Core.Persistence.Ef
b.Entity<MarketData>(e =>
{
e.ToTable("core_markets");
e.HasKey(x => x.Id);
// #623: Markt-IDs verschiedener Plattformen können sich überschneiden – der Schlüssel
// enthält daher die Plattform. Bestandsmärkte bekommen per Migration Polymarket Global.
e.HasKey(x => new { x.Platform, x.Id });
e.Property(x => x.Platform).HasMaxLength(40);
e.Property(x => x.Id).HasMaxLength(120);
e.Property(x => x.ConditionId).HasMaxLength(120);
e.Property(x => x.Slug).HasMaxLength(300);
@@ -1,12 +1,17 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using PolyTrader.Core.Platforms;
using PolyTraderSharp.Models;
namespace PolyTrader.Core.Persistence.Ef
{
public class EfMarketRepository : IMarketRepository
{
// #623: Die lesenden Methoden sind Polymarket-Global-Abfragen (Gamma-IDs, CLOB-Token-IDs) und
// bleiben es. Ohne diesen Filter würden Märkte anderer Plattformen mit gleicher ID hineingeraten.
private const string Global = PlatformId.PolymarketGlobalValue;
private readonly IDbContextFactory<CoreDbContext> _factory;
public EfMarketRepository(IDbContextFactory<CoreDbContext> factory) => _factory = factory;
@@ -14,26 +19,26 @@ namespace PolyTrader.Core.Persistence.Ef
public MarketData? GetById(string id)
{
using var ctx = _factory.CreateDbContext();
return ctx.Markets.AsNoTracking().FirstOrDefault(x => x.Id == id);
return ctx.Markets.AsNoTracking().FirstOrDefault(x => x.Platform == Global && x.Id == id);
}
public MarketData? FindByTokenId(string tokenId)
{
using var ctx = _factory.CreateDbContext();
return ctx.Markets.AsNoTracking()
.FirstOrDefault(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(tokenId));
.FirstOrDefault(x => x.Platform == Global && x.ClobTokenIds != null && x.ClobTokenIds.Contains(tokenId));
}
public List<MarketData> GetActive()
{
using var ctx = _factory.CreateDbContext();
return ctx.Markets.AsNoTracking().Where(x => !x.Closed).ToList();
return ctx.Markets.AsNoTracking().Where(x => x.Platform == Global && !x.Closed).ToList();
}
public void Upsert(MarketData market)
{
using var ctx = _factory.CreateDbContext();
var existing = ctx.Markets.Find(market.Id);
var existing = ctx.Markets.Find(market.Platform, market.Id);
if (existing == null)
ctx.Markets.Add(market);
else
@@ -51,7 +56,7 @@ namespace PolyTrader.Core.Persistence.Ef
public void Update(MarketData market)
{
using var ctx = _factory.CreateDbContext();
var existing = ctx.Markets.Find(market.Id);
var existing = ctx.Markets.Find(market.Platform, market.Id);
if (existing != null)
{
ctx.Entry(existing).CurrentValues.SetValues(market);
@@ -0,0 +1,490 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PolyTrader.Core.Persistence.Ef;
#nullable disable
namespace PolyTrader.Core.Persistence.Ef.Migrations
{
[DbContext(typeof(CoreDbContext))]
[Migration("20260918084743_AddPlatformColumns")]
partial class AddPlatformColumns
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("PolyTraderSharp.Models.AccountState", b =>
{
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("ApiPassphrase")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ApiSecret")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<decimal>("AvailableBalance")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<bool>("CloseOnlyMode")
.HasColumnType("tinyint(1)");
b.Property<bool>("HasOpenLimitOrders")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("PayoutAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<decimal>("PayoutLimitUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<decimal>("TotalBalance")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("WalletAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.HasKey("AccountId");
b.ToTable("core_accounts", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.DecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<string>("ContextJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("MarketEndDate")
.HasColumnType("datetime(6)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("SourceTraderId")
.HasColumnType("int");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("Reason");
b.HasIndex("SignalId");
b.HasIndex("Timestamp");
b.HasIndex("AccountId", "TokenId");
b.ToTable("core_decision_journal", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b =>
{
b.Property<string>("Platform")
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Id")
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("ClobTokenIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Closed")
.HasColumnType("tinyint(1)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<bool>("NegRisk")
.HasColumnType("tinyint(1)");
b.Property<string>("Outcomes")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<int>("TakerFeeBps")
.HasColumnType("int");
b.HasKey("Platform", "Id");
b.ToTable("core_markets", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.OrderEventRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("AmountUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("DetailsJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Price")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Response")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("SignalId");
b.HasIndex("Timestamp");
b.HasIndex("AccountId", "TokenId");
b.ToTable("core_order_events", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.Position", b =>
{
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("TokenId")
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<decimal>("AmountUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("CurrentValueUsd")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("EntryPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<DateTime?>("ExpiryDate")
.HasColumnType("datetime(6)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<DateTime>("OpenedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Size")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("SourceTraderAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("SourceTraderId")
.HasColumnType("int");
b.Property<string>("SourceTraderName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.HasKey("AccountId", "IsDemo", "TokenId");
b.ToTable("core_positions", (string)null);
});
modelBuilder.Entity("PolyTraderSharp.Models.TradeRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("EntryPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("ExitPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("ExitReason")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("ModuleName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("OpenedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("PnlPercent")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("Size")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("ClosedAt");
b.HasIndex("ModuleName");
b.ToTable("core_trade_log", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyTrader.Core.Persistence.Ef.Migrations
{
/// <summary>
/// #623: Handelsplatz je Konto und Markt. Bestandszeilen bekommen Polymarket Global – das ist der Stand
/// aller Konten und Märkte vor #623. core_markets bekommt den Schlüssel (Platform, Id), weil Markt-IDs
/// verschiedener Plattformen sich überschneiden können.
///
/// <para>Down gilt nur, solange core_markets ausschließlich Global-Märkte enthält; bei doppelten IDs
/// verschiedener Plattformen scheitert das Wiederherstellen des alten Schlüssels.</para>
/// </summary>
public partial class AddPlatformColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_core_markets",
table: "core_markets");
migrationBuilder.AddColumn<string>(
name: "Platform",
table: "core_markets",
type: "varchar(40)",
maxLength: 40,
nullable: false,
defaultValue: "polymarket-global")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Platform",
table: "core_accounts",
type: "varchar(40)",
maxLength: 40,
nullable: false,
defaultValue: "polymarket-global")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddPrimaryKey(
name: "PK_core_markets",
table: "core_markets",
columns: new[] { "Platform", "Id" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_core_markets",
table: "core_markets");
migrationBuilder.DropColumn(
name: "Platform",
table: "core_markets");
migrationBuilder.DropColumn(
name: "Platform",
table: "core_accounts");
migrationBuilder.AddPrimaryKey(
name: "PK_core_markets",
table: "core_markets",
column: "Id");
}
}
}
@@ -72,6 +72,11 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(512)
@@ -183,6 +188,10 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b =>
{
b.Property<string>("Platform")
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Id")
.HasMaxLength(120)
.HasColumnType("varchar(120)");
@@ -230,7 +239,7 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations
b.Property<int>("TakerFeeBps")
.HasColumnType("int");
b.HasKey("Id");
b.HasKey("Platform", "Id");
b.ToTable("core_markets", (string)null);
});
@@ -4,7 +4,10 @@ using PolyTraderSharp.Models;
namespace PolyTrader.Core.Persistence
{
/// <summary>
/// Zugriff auf die Markt-Metadaten (Collection "markets").
/// Zugriff auf die Markt-Metadaten (Tabelle core_markets).
///
/// <para>#623: Die lesenden Methoden liefern nur Märkte von Polymarket Global – ihre Aufrufer arbeiten mit
/// Gamma-IDs und CLOB-Token-IDs. Schreibende Methoden nehmen die Plattform aus <see cref="MarketData.Platform"/>.</para>
/// </summary>
public interface IMarketRepository
{
@@ -0,0 +1,38 @@
using System;
namespace PolyTrader.Core.Platforms
{
/// <summary>
/// Ein handelbarer Ausgang auf einer Plattform (#623) – der plattformneutrale Ersatz für die
/// nackte <c>TokenId</c>.
///
/// <para><see cref="Key"/> ist innerhalb der Plattform eindeutig und das, was in
/// <c>core_positions.TokenId</c> gespeichert wird. Die Spalte behält ihren Namen, damit die bestehenden
/// Fundstellen schrittweise umziehen können.</para>
/// <list type="table">
/// <item><term>Polymarket Global</term><description>Key = tokenId, MarketKey = conditionId</description></item>
/// <item><term>Polymarket US</term><description>Key = "{marketSlug}:YES" bzw. ":NO", MarketKey = marketSlug</description></item>
/// <item><term>Kalshi</term><description>Key = "{ticker}:YES" bzw. ":NO", MarketKey = ticker</description></item>
/// </list>
///
/// <para>Gleichheit hängt nur an Plattform und <see cref="Key"/>. <see cref="MarketKey"/> und
/// <see cref="Outcome"/> sind Beiwerk, das nicht jeder Aufrufer kennt.</para>
/// </summary>
public sealed record InstrumentRef(PlatformId Platform, string Key, string MarketKey = "", string Outcome = "")
{
public string Key { get; init; } = !string.IsNullOrWhiteSpace(Key)
? Key
: throw new ArgumentException("Instrument-Schlüssel darf nicht leer sein.", nameof(Key));
/// <summary>Ausgang auf Polymarket Global über seine Token-ID.</summary>
public static InstrumentRef PolymarketGlobal(string tokenId, string conditionId = "", string outcome = "") =>
new(PlatformId.PolymarketGlobal, tokenId, conditionId, outcome);
public bool Equals(InstrumentRef? other) =>
other is not null && Platform == other.Platform && string.Equals(Key, other.Key, StringComparison.Ordinal);
public override int GetHashCode() => HashCode.Combine(Platform, Key);
public override string ToString() => $"{Platform}:{Key}";
}
}
@@ -0,0 +1,54 @@
using System.Collections.Generic;
namespace PolyTrader.Core.Platforms
{
/// <summary>Gültigkeit einer Order, plattformneutral. Jeder Adapter übersetzt in seine eigenen Begriffe.</summary>
public enum TimeInForce
{
/// <summary>Bleibt im Buch, bis ausgeführt oder storniert.</summary>
GoodTillCancel,
/// <summary>Ganz oder gar nicht, sofort.</summary>
FillOrKill,
/// <summary>Sofort so viel wie möglich, Rest verfällt.</summary>
ImmediateOrCancel,
/// <summary>Bleibt im Buch bis zu einem Zeitpunkt.</summary>
GoodTillDate,
/// <summary>Bleibt im Buch bis Handelsschluss des Tages.</summary>
Day
}
/// <summary>
/// Was ein Handelsplatz kann (#623). Logik fragt nach Fähigkeiten, nicht nach dem Namen der Plattform:
/// <c>if (caps.PublicTraderActivity)</c> statt <c>if (platform == PolymarketGlobal)</c>. Ein neuer Adapter
/// bringt seine Fähigkeiten mit, und bestehende Module entscheiden richtig, ohne ihn zu kennen.
/// </summary>
public sealed record PlatformCapabilities
{
/// <summary>Trades fremder Konten sind öffentlich beobachtbar – Voraussetzung für CopyTrading.</summary>
public bool PublicTraderActivity { get; init; }
/// <summary>Abwicklung auf einer Blockchain: gewonnene Positionen müssen eingelöst werden (Redeem, #146).</summary>
public bool OnChainSettlement { get; init; }
/// <summary>Orders, die abgelehnt werden, statt sofort gegen das Buch auszuführen.</summary>
public bool PostOnlyOrders { get; init; }
/// <summary>Preis/Menge einer offenen Order ändern, ohne sie zu stornieren und neu zu platzieren.</summary>
public bool OrderModify { get; init; }
/// <summary>Maker bekommen eine Gutschrift statt einer Gebühr.</summary>
public bool MakerRebate { get; init; }
/// <summary>Mengen unter einem ganzen Kontrakt/Share sind handelbar.</summary>
public bool FractionalQuantity { get; init; }
/// <summary>Die Plattform bietet eine eigene Testumgebung mit Spielgeld.</summary>
public bool NativeDemoEnvironment { get; init; }
/// <summary>Welche Gültigkeiten der Adapter annimmt. Alles andere lehnt er ab.</summary>
public IReadOnlySet<TimeInForce> TimeInForce { get; init; } = new HashSet<TimeInForce>();
/// <summary>Währung, in der Salden und Auszahlungen geführt werden, z. B. "USDC" oder "USD".</summary>
public string SettlementCurrency { get; init; } = "USD";
}
}
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PolyTraderSharp.Models;
namespace PolyTrader.Core.Platforms
{
// Handelsverträge je Plattform (#623). Module sprechen gegen diese Verträge, nicht gegen
// Polymarket*-Klassen. Aufgelöst werden sie über IPlatformRegistry, nie direkt.
//
// Bewusst noch NICHT hier: Echtzeit-Streams (Markt/Konto). Der einzige Stream-Nutzer ist heute
// CopyTrading mit seinem eigenen WSS-Client; der Vertrag entsteht mit dem ersten zweiten Nutzer
// (#625, Polymarket-US-WebSockets), statt auf Vorrat.
/// <summary>Märkte finden und ihren Auflösungsstand abfragen.</summary>
public interface IMarketCatalog
{
/// <summary>Die zuletzt angelegten Märkte, neueste zuerst.</summary>
Task<IReadOnlyList<PlatformMarket>> GetRecentMarketsAsync(int limit, CancellationToken ct = default);
/// <summary>Der Markt, zu dem ein Ausgang gehört; <c>null</c>, wenn unbekannt oder bei Fehler.</summary>
Task<PlatformMarket?> GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default);
Task<MarketResolution> GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default);
}
/// <summary>Preise aus dem Orderbuch.</summary>
public interface IOrderBookSource
{
/// <summary>
/// Bester Preis, zu dem man sofort handeln kann: für <see cref="OrderSide.Buy"/> der niedrigste Ask,
/// für <see cref="OrderSide.Sell"/> das höchste Bid. <c>null</c> bei leerem Buch oder Fehler.
/// </summary>
Task<decimal?> GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default);
}
/// <summary>Orders platzieren, abfragen, stornieren. Der geldkritische Vertrag.</summary>
public interface IOrderGateway
{
Task<OrderResult> PlaceAsync(AccountState account, OrderRequest request, CancellationToken ct = default);
/// <summary>Offene Orders; mit <paramref name="instrument"/> nur für diesen Ausgang.</summary>
Task<IReadOnlyList<OpenOrder>> GetOpenOrdersAsync(AccountState account, InstrumentRef? instrument = null, CancellationToken ct = default);
Task<bool> CancelAsync(AccountState account, string orderId, CancellationToken ct = default);
}
/// <summary>Saldo und Positionen des eigenen Kontos.</summary>
public interface IPortfolioSource
{
/// <summary>Für Orders verfügbares Guthaben in der Abwicklungswährung der Plattform.</summary>
Task<decimal> GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default);
/// <summary>Offene Positionen; <c>null</c> bei Fehler (nicht zu verwechseln mit „keine Positionen").</summary>
Task<IReadOnlyList<PlatformPosition>?> GetPositionsAsync(AccountState account, CancellationToken ct = default);
}
/// <summary>
/// Gebühren der Plattform. Die Form unterscheidet sich je Plattform (Global: Satz in bps auf das Notional;
/// US: Θ·C·p·(1−p)) – deshalb eine Rechnung, kein Satz.
/// </summary>
public interface IFeeModel
{
/// <summary>Gebühr in der Abwicklungswährung; negativ bei Maker-Gutschrift.</summary>
decimal EstimateFee(decimal price, decimal quantity, LiquidityRole role, FeeContext context);
}
/// <summary>Ein Handelsplatz mit allen seinen Verträgen.</summary>
public interface IPlatformAdapter
{
PlatformId Id { get; }
PlatformCapabilities Capabilities { get; }
IMarketCatalog Markets { get; }
IOrderBookSource OrderBook { get; }
IOrderGateway Orders { get; }
IPortfolioSource Portfolio { get; }
IFeeModel Fees { get; }
}
}
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
namespace PolyTrader.Core.Platforms
{
/// <summary>
/// Kennung eines Handelsplatzes (#623). Der Wert wird in der Datenbank gespeichert
/// (<c>core_accounts.Platform</c>, <c>core_markets.Platform</c>) – bestehende Werte daher nie umbenennen.
///
/// <para>Die Plattform gehört zum Konto, nicht zur Installation: Eine Installation kann Konten
/// mehrerer Plattformen führen. Konzept: docs/archiv/konzepte/KONZEPT-Multi-Plattform.md.</para>
/// </summary>
public readonly record struct PlatformId
{
public const string PolymarketGlobalValue = "polymarket-global";
public const string PolymarketUsValue = "polymarket-us";
public const string KalshiValue = "kalshi";
public static readonly PlatformId PolymarketGlobal = new(PolymarketGlobalValue);
public static readonly PlatformId PolymarketUs = new(PolymarketUsValue);
public static readonly PlatformId Kalshi = new(KalshiValue);
/// <summary>Alle Plattformen, die PolyTrader dem Namen nach kennt – nicht alle haben schon einen Adapter.</summary>
public static IReadOnlyList<PlatformId> Known { get; } = new[] { PolymarketGlobal, PolymarketUs, Kalshi };
public PlatformId(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Plattform-Kennung darf nicht leer sein.", nameof(value));
Value = value.Trim().ToLowerInvariant();
}
public string Value { get; }
/// <summary>
/// Liest eine gespeicherte Kennung. Leer oder <c>null</c> ergibt Polymarket Global – das ist der
/// Stand aller Konten und Märkte, die vor #623 angelegt wurden.
/// </summary>
public static PlatformId Parse(string? value) =>
string.IsNullOrWhiteSpace(value) ? PolymarketGlobal : new PlatformId(value);
/// <summary>Anzeigename für Oberflächen und Logs.</summary>
public string DisplayName => Value switch
{
PolymarketGlobalValue => "Polymarket",
PolymarketUsValue => "Polymarket US",
KalshiValue => "Kalshi",
_ => Value
};
public override string ToString() => Value ?? PolymarketGlobalValue;
}
}
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
namespace PolyTrader.Core.Platforms
{
public enum OrderSide { Buy, Sell }
/// <summary>Rolle bei einer Ausführung – bestimmt die Gebühr.</summary>
public enum LiquidityRole { Taker, Maker }
/// <summary>
/// Eine Order, plattformneutral (#623). Preise sind immer Wahrscheinlichkeiten 0..1 – ein Adapter,
/// dessen Plattform in Cent rechnet, rechnet selbst um.
///
/// <para>Genau eine der beiden Mengenangaben ist gesetzt: <see cref="Quantity"/> (Kontrakte/Shares) oder
/// <see cref="NotionalUsd"/> (Betrag). Plattformspezifisches (Polymarket Global: negRisk, Tick-Size,
/// Gebühr in der Signatur) bleibt im Adapter.</para>
/// </summary>
public sealed record OrderRequest
{
public required InstrumentRef Instrument { get; init; }
public required OrderSide Side { get; init; }
public required decimal LimitPrice { get; init; }
public decimal? Quantity { get; init; }
public decimal? NotionalUsd { get; init; }
public TimeInForce TimeInForce { get; init; } = TimeInForce.FillOrKill;
public bool PostOnly { get; init; }
/// <summary>Welches Modul die Order auslöst – für Journal und die Plattform-Sperre (#624).</summary>
public string OriginModule { get; init; } = string.Empty;
/// <summary>Korrelation zum Entscheidungsjournal (SignalId).</summary>
public string CorrelationId { get; init; } = string.Empty;
}
public enum OrderOutcome
{
/// <summary>Die Plattform hat die Order angenommen.</summary>
Accepted,
/// <summary>Nicht ausgeführt, weil die Gegenseite nicht reichte (z. B. FOK ohne Tiefe). Kein Fehler.</summary>
NotFilled,
/// <summary>Vom Adapter vor dem Senden abgelehnt (unzulässige Anfrage) – es ging nichts an die Plattform.</summary>
Invalid,
/// <summary>Von der Plattform abgelehnt oder technischer Fehler.</summary>
Rejected
}
public sealed record OrderResult(OrderOutcome Outcome, string Message, string? OrderId = null)
{
public bool IsAccepted => Outcome == OrderOutcome.Accepted;
public static OrderResult Invalid(string message) => new(OrderOutcome.Invalid, message);
}
/// <summary>Offene Order. <see cref="Instrument"/> ist <c>null</c>, wenn die Plattform es in der Liste nicht mitliefert.</summary>
public sealed record OpenOrder(string Id, OrderSide Side, decimal Price, InstrumentRef? Instrument = null);
/// <summary>Ein Ausgang eines Marktes mit seinem Namen ("Yes", "No", "Lakers" …).</summary>
public sealed record PlatformOutcome(string Name, InstrumentRef Instrument);
/// <summary>Markt-Metadaten, plattformneutral.</summary>
public sealed record PlatformMarket
{
public required PlatformId Platform { get; init; }
/// <summary>Marktschlüssel der Plattform (Global: conditionId, US: Slug, Kalshi: Ticker).</summary>
public required string MarketKey { get; init; }
public string Slug { get; init; } = string.Empty;
public string Question { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
public DateTime? EndDateUtc { get; init; }
public bool Active { get; init; }
public bool Closed { get; init; }
public IReadOnlyList<PlatformOutcome> Outcomes { get; init; } = Array.Empty<PlatformOutcome>();
}
/// <summary>Auflösungsstand eines Ausgangs.</summary>
public sealed record MarketResolution(bool IsClosed, bool IsWinner)
{
public static readonly MarketResolution Open = new(false, false);
}
/// <summary>Eigene Position laut Plattform.</summary>
public sealed record PlatformPosition
{
public required InstrumentRef Instrument { get; init; }
public decimal Size { get; init; }
public decimal AveragePrice { get; init; }
public decimal CurrentPrice { get; init; }
public decimal CostUsd { get; init; }
public decimal CurrentValueUsd { get; init; }
public string Slug { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public DateTime? EndDateUtc { get; init; }
}
/// <summary>Was die Gebührenberechnung über den Markt wissen muss. Jedes Feld ist optional.</summary>
public sealed record FeeContext
{
public string? Category { get; init; }
/// <summary>Gebührensatz, den die Plattform für diesen Markt meldet (Polymarket Global: bps). 0 = unbekannt.</summary>
public int MarketFeeBps { get; init; }
public static readonly FeeContext None = new();
}
}
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Linq;
using PolyTraderSharp.Models;
namespace PolyTrader.Core.Platforms
{
/// <summary>Löst den Adapter einer Plattform auf – der einzige Weg an Handelsverträge (#623).</summary>
public interface IPlatformRegistry
{
/// <summary>Plattformen, für die ein Adapter registriert ist.</summary>
IReadOnlyList<PlatformId> Available { get; }
bool IsAvailable(PlatformId platform);
/// <summary>Wirft, wenn für die Plattform kein Adapter registriert ist.</summary>
IPlatformAdapter Get(PlatformId platform);
bool TryGet(PlatformId platform, out IPlatformAdapter? adapter);
/// <summary>Adapter der Plattform, zu der das Konto gehört.</summary>
IPlatformAdapter ForAccount(AccountState account);
}
public sealed class PlatformRegistry : IPlatformRegistry
{
private readonly Dictionary<PlatformId, IPlatformAdapter> _adapters;
public PlatformRegistry(IEnumerable<IPlatformAdapter> adapters)
{
_adapters = new Dictionary<PlatformId, IPlatformAdapter>();
foreach (var adapter in adapters ?? throw new ArgumentNullException(nameof(adapters)))
{
if (!_adapters.TryAdd(adapter.Id, adapter))
throw new InvalidOperationException($"Für die Plattform '{adapter.Id}' ist mehr als ein Adapter registriert.");
}
Available = _adapters.Keys.ToList();
}
public IReadOnlyList<PlatformId> Available { get; }
public bool IsAvailable(PlatformId platform) => _adapters.ContainsKey(platform);
public IPlatformAdapter Get(PlatformId platform) =>
_adapters.TryGetValue(platform, out var adapter)
? adapter
: throw new InvalidOperationException(
$"Für die Plattform '{platform.DisplayName}' ist in dieser Installation kein Adapter vorhanden.");
public bool TryGet(PlatformId platform, out IPlatformAdapter? adapter) =>
_adapters.TryGetValue(platform, out adapter);
public IPlatformAdapter ForAccount(AccountState account) =>
Get(PlatformId.Parse((account ?? throw new ArgumentNullException(nameof(account))).Platform));
}
}
@@ -0,0 +1,37 @@
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Platforms.PolymarketGlobal;
using PolyTraderSharp.Services;
namespace PolyTrader.Core.Platforms
{
public static class PlatformServiceCollectionExtensions
{
/// <summary>
/// Registriert die Plattform-Adapter und die Registry (#623). Setzt voraus, dass
/// <see cref="PolymarketApiService"/>, <see cref="PolymarketClobClient"/> und die Core-Persistenz
/// schon registriert sind.
///
/// <para>Der Global-Adapter nutzt denselben <see cref="PolymarketClobClient"/>-Singleton wie die Module –
/// bewusst ohne Umweg über die <c>IClobClient</c>-Registrierung des CopyTrading-Moduls, die fehlt, wenn
/// CopyTrading abgeschaltet ist.</para>
/// </summary>
public static IServiceCollection AddPlatforms(this IServiceCollection services)
{
services.AddSingleton<IPlatformAdapter>(sp =>
{
var api = sp.GetRequiredService<PolymarketApiService>();
var clob = sp.GetRequiredService<PolymarketClobClient>();
return new PolymarketGlobalAdapter(
new GlobalMarketCatalog(api),
new GlobalOrderBookSource(api),
new GlobalOrderGateway(clob, sp.GetRequiredService<IMarketRepository>()),
new GlobalPortfolioSource(clob, api),
new GlobalFeeModel());
});
services.AddSingleton<IPlatformRegistry>(sp => new PlatformRegistry(sp.GetServices<IPlatformAdapter>()));
return services;
}
}
}
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Core.Trading;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTrader.Core.Platforms.PolymarketGlobal
{
/// <summary>Marktkatalog über Gamma-API (#623) – Hülle um <see cref="PolymarketApiService"/>.</summary>
public sealed class GlobalMarketCatalog : IMarketCatalog
{
private readonly PolymarketApiService _api;
public GlobalMarketCatalog(PolymarketApiService api) => _api = api ?? throw new ArgumentNullException(nameof(api));
public async Task<IReadOnlyList<PlatformMarket>> GetRecentMarketsAsync(int limit, CancellationToken ct = default)
{
var markets = await _api.GetRecentMarketsAsync(limit);
return markets.Select(ToPlatformMarket).ToList();
}
public async Task<PlatformMarket?> GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default)
{
EnsureGlobal(instrument);
var market = await _api.GetMarketByTokenIdAsync(instrument.Key);
return market == null ? null : ToPlatformMarket(market);
}
public async Task<MarketResolution> GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default)
{
EnsureGlobal(instrument);
// Der Slug wird von CheckMarketResolutionAsync nicht ausgewertet; maßgeblich ist die Token-ID.
var (isResolved, isWinner) = await _api.CheckMarketResolutionAsync(instrument.MarketKey, instrument.Key);
return new MarketResolution(isResolved, isWinner);
}
/// <summary>
/// Übersetzt die gespeicherten Global-Metadaten. <c>ClobTokenIds</c> und <c>Outcomes</c> sind JSON-Arrays
/// als Text und gehören positionsweise zusammen; fehlt ein Name, heißt der Ausgang nach seiner Stelle.
/// </summary>
internal static PlatformMarket ToPlatformMarket(MarketData md)
{
var tokenIds = ParseStringArray(md.ClobTokenIds);
var names = ParseStringArray(md.Outcomes);
string marketKey = !string.IsNullOrEmpty(md.ConditionId) ? md.ConditionId : md.Id;
var outcomes = tokenIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select((id, i) =>
{
string name = i < names.Count ? names[i] : $"Ausgang {i + 1}";
return new PlatformOutcome(name, InstrumentRef.PolymarketGlobal(id, marketKey, name));
})
.ToList();
return new PlatformMarket
{
Platform = PlatformId.PolymarketGlobal,
MarketKey = marketKey,
Slug = md.Slug,
Question = md.Question,
Category = md.Category,
EndDateUtc = md.EndDate,
Active = md.Active,
Closed = md.Closed,
Outcomes = outcomes
};
}
internal static List<string> ParseStringArray(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new List<string>();
return doc.RootElement.EnumerateArray()
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() ?? string.Empty : e.ToString())
.ToList();
}
catch (JsonException)
{
return new List<string>();
}
}
internal static void EnsureGlobal(InstrumentRef instrument)
{
if (instrument == null) throw new ArgumentNullException(nameof(instrument));
if (instrument.Platform != PlatformId.PolymarketGlobal)
throw new ArgumentException($"Instrument gehört zu '{instrument.Platform}', nicht zu Polymarket Global.", nameof(instrument));
}
}
/// <summary>Bester Preis aus dem CLOB-Orderbuch (#623) – Hülle um <see cref="PolymarketApiService.GetOrderBookPriceAsync"/>.</summary>
public sealed class GlobalOrderBookSource : IOrderBookSource
{
private readonly PolymarketApiService _api;
public GlobalOrderBookSource(PolymarketApiService api) => _api = api ?? throw new ArgumentNullException(nameof(api));
public Task<decimal?> GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default)
{
GlobalMarketCatalog.EnsureGlobal(instrument);
// GetOrderBookPriceAsync("BUY") liefert den besten Ask, ("SELL") das beste Bid – genau der Vertrag.
return _api.GetOrderBookPriceAsync(instrument.Key, GlobalOrderGateway.SideString(side));
}
}
/// <summary>
/// Saldo und Positionen (#623): Saldo aus dem CLOB (<c>/balance-allowance</c>, das für Orders verfügbare
/// Collateral), Positionen aus der Data-API.
/// </summary>
public sealed class GlobalPortfolioSource : IPortfolioSource
{
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
public GlobalPortfolioSource(PolymarketClobClient clob, PolymarketApiService api)
{
_clob = clob ?? throw new ArgumentNullException(nameof(clob));
_api = api ?? throw new ArgumentNullException(nameof(api));
}
public Task<decimal> GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default) =>
_clob.GetUsdcBalanceAsync(account ?? throw new ArgumentNullException(nameof(account)));
public async Task<IReadOnlyList<PlatformPosition>?> GetPositionsAsync(AccountState account, CancellationToken ct = default)
{
if (account == null) throw new ArgumentNullException(nameof(account));
var raw = await _api.SyncOpenPositionsAsync(account.WalletAddress);
// SyncOpenPositionsAsync liefert null sowohl bei Fehler als auch bei null Positionen, außer die
// Wallet ist leer (dann leere Liste). Der Vertrag kennt nur „Fehler = null" – das übernehmen wir.
if (raw == null) return null;
return raw.Select(ToPosition).Where(p => p != null).Select(p => p!).ToList();
}
/// <summary>Übersetzt einen Eintrag von data-api.polymarket.com/positions. Ohne <c>asset</c> kein Ergebnis.</summary>
internal static PlatformPosition? ToPosition(JsonElement el)
{
string asset = Str(el, "asset");
if (string.IsNullOrEmpty(asset)) return null;
DateTime? endDate = null;
if (DateTime.TryParse(Str(el, "endDate"), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var ed))
endDate = ed;
return new PlatformPosition
{
Instrument = InstrumentRef.PolymarketGlobal(asset, Str(el, "conditionId"), Str(el, "outcome")),
Size = Dec(el, "size"),
AveragePrice = Dec(el, "avgPrice"),
CurrentPrice = Dec(el, "curPrice"),
CostUsd = Dec(el, "initialValue"),
CurrentValueUsd = Dec(el, "currentValue"),
Slug = Str(el, "slug"),
Title = Str(el, "title"),
EndDateUtc = endDate
};
}
private static string Str(JsonElement el, string name) =>
el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? string.Empty : string.Empty;
// Die Data-API liefert Zahlen mal als Zahl, mal als Text.
private static decimal Dec(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return 0m;
if (p.ValueKind == JsonValueKind.Number && p.TryGetDecimal(out var d)) return d;
if (p.ValueKind == JsonValueKind.String &&
decimal.TryParse(p.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var s)) return s;
return 0m;
}
}
/// <summary>
/// Gebühren von Polymarket Global (#623) – Hülle um <see cref="FeeModel"/>: Taker zahlt den Satz des
/// Marktes (bps auf das Notional), sonst den Kategorie-Fallback; Maker zahlen nichts.
/// </summary>
public sealed class GlobalFeeModel : IFeeModel
{
public decimal EstimateFee(decimal price, decimal quantity, LiquidityRole role, FeeContext context)
{
if (role == LiquidityRole.Maker) return 0m;
int bps = FeeModel.ResolveBps(context?.MarketFeeBps ?? 0, context?.Category);
return FeeModel.FeeUsd(price * quantity, bps);
}
}
}
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Trading;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTrader.Core.Platforms.PolymarketGlobal
{
/// <summary>
/// Order-Gateway für Polymarket Global (#623): übersetzt eine neutrale <see cref="OrderRequest"/> in einen
/// Aufruf des bestehenden <see cref="IClobClient"/> – und sonst nichts.
///
/// <para><b>Hülle, kein Umbau.</b> Signierung, Betragsrechnung, Tick-Size-/Fee-Nachregelung und alle
/// Wiederholungen bleiben in <see cref="PolymarketClobClient"/> (.agents/rules/clob.md). Diese Klasse
/// ergänzt nur, was die Module heute selbst vor dem Aufruf bestimmen: negRisk und Gebührensatz aus den
/// Markt-Metadaten – nach demselben Muster wie die SELL-Leiter (<c>FeeModel.ResolveBps</c>, Fallback bei
/// unbekanntem Markt).</para>
/// </summary>
public sealed class GlobalOrderGateway : IOrderGateway
{
private readonly IClobClient _clob;
private readonly IMarketRepository _markets;
public GlobalOrderGateway(IClobClient clob, IMarketRepository markets)
{
_clob = clob ?? throw new ArgumentNullException(nameof(clob));
_markets = markets ?? throw new ArgumentNullException(nameof(markets));
}
public async Task<OrderResult> PlaceAsync(AccountState account, OrderRequest request, CancellationToken ct = default)
{
if (account == null) throw new ArgumentNullException(nameof(account));
if (request == null) throw new ArgumentNullException(nameof(request));
string? invalid = Validate(account, request);
if (invalid != null) return OrderResult.Invalid(invalid);
decimal investAmountUsd = request.Quantity.HasValue
? request.Quantity.Value * request.LimitPrice
: request.NotionalUsd!.Value;
// Wie die Module vor #623: negRisk und Gebühr aus den Markt-Metadaten, sonst Kategorie-Fallback.
MarketData? market = _markets.FindByTokenId(request.Instrument.Key);
bool isNegRisk = market?.NegRisk ?? false;
int feeBps = market != null ? FeeModel.ResolveBps(market.TakerFeeBps, market.Category) : FeeModel.FallbackBps(null);
string response = await _clob.PlaceOrderAsync(
account,
request.Instrument.Key,
SideString(request.Side),
investAmountUsd,
request.LimitPrice,
OrderTypeString(request.TimeInForce),
debugPayloadLog: false,
isNegRisk: isNegRisk,
actualFeeBps: feeBps);
return MapResponse(response);
}
public async Task<IReadOnlyList<OpenOrder>> GetOpenOrdersAsync(AccountState account, InstrumentRef? instrument = null, CancellationToken ct = default)
{
if (account == null) throw new ArgumentNullException(nameof(account));
if (instrument != null && instrument.Platform != PlatformId.PolymarketGlobal)
throw new ArgumentException($"Instrument gehört zu '{instrument.Platform}', nicht zu Polymarket Global.", nameof(instrument));
var orders = await _clob.GetOpenOrdersAsync(account, instrument?.Key ?? string.Empty);
return orders
.Select(o => new OpenOrder(o.Id, ParseSide(o.Side), o.Price, instrument))
.ToList();
}
public Task<bool> CancelAsync(AccountState account, string orderId, CancellationToken ct = default)
{
if (account == null) throw new ArgumentNullException(nameof(account));
return _clob.CancelOrderAsync(account, orderId);
}
/// <summary>Prüft, was vor dem Senden feststehen muss. <c>null</c> = zulässig.</summary>
internal static string? Validate(AccountState account, OrderRequest request)
{
if (request.Instrument.Platform != PlatformId.PolymarketGlobal)
return $"Instrument gehört zu '{request.Instrument.Platform.DisplayName}', nicht zu Polymarket.";
if (PlatformId.Parse(account.Platform) != PlatformId.PolymarketGlobal)
return $"Konto '{account.Name}' gehört zu '{PlatformId.Parse(account.Platform).DisplayName}', nicht zu Polymarket.";
if (request.PostOnly)
return "Post-only-Orders unterstützt Polymarket nicht.";
if (!PolymarketGlobalAdapter.CapabilitiesValue.TimeInForce.Contains(request.TimeInForce))
return $"Gültigkeit {request.TimeInForce} unterstützt Polymarket nicht.";
if (request.LimitPrice <= 0m || request.LimitPrice >= 1m)
return $"Limitpreis {request.LimitPrice} liegt nicht zwischen 0 und 1.";
if (request.Quantity.HasValue == request.NotionalUsd.HasValue)
return "Genau eine Mengenangabe setzen: Stückzahl oder Betrag.";
if ((request.Quantity ?? request.NotionalUsd) <= 0m)
return "Menge muss größer als 0 sein.";
return null;
}
internal static string SideString(OrderSide side) => side == OrderSide.Buy ? "BUY" : "SELL";
internal static OrderSide ParseSide(string side) =>
string.Equals(side, "SELL", StringComparison.OrdinalIgnoreCase) ? OrderSide.Sell : OrderSide.Buy;
internal static string OrderTypeString(TimeInForce tif) => tif switch
{
TimeInForce.GoodTillCancel => "GTC",
TimeInForce.FillOrKill => "FOK",
_ => throw new ArgumentOutOfRangeException(nameof(tif), tif, "Von Polymarket nicht unterstützt.")
};
/// <summary>
/// Übersetzt die Antwort des CLOB-Clients. Er liefert "OK", "SKIPPED_LIQUIDITY" (FOK-Kauf ohne Tiefe,
/// bewusst leise) oder einen Fehlertext.
/// </summary>
internal static OrderResult MapResponse(string? response) => response switch
{
"OK" => new OrderResult(OrderOutcome.Accepted, "OK"),
"SKIPPED_LIQUIDITY" => new OrderResult(OrderOutcome.NotFilled, "Nicht genügend Liquidität (FOK)."),
null or "" => new OrderResult(OrderOutcome.Rejected, "Keine Antwort vom CLOB."),
_ => new OrderResult(OrderOutcome.Rejected, response)
};
}
}
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
namespace PolyTrader.Core.Platforms.PolymarketGlobal
{
/// <summary>
/// Polymarket Global (polymarket.com, CLOB auf Polygon) als Plattform-Adapter (#623). Bündelt die dünnen
/// Hüllen um die bestehenden Dienste; keine eigene Logik.
/// </summary>
public sealed class PolymarketGlobalAdapter : IPlatformAdapter
{
/// <summary>
/// Fähigkeiten laut heutiger Anbindung – nicht laut Plattform. Polymarket kennt z. B. GTD und FAK; der
/// bestehende CLOB-Client setzt bei GTD aber eine feste Ablaufzeit, und FAK nutzt er nicht. Was hier
/// fehlt, lehnt das Gateway ab, statt es halb richtig zu senden.
/// </summary>
public static readonly PlatformCapabilities CapabilitiesValue = new()
{
PublicTraderActivity = true,
OnChainSettlement = true,
PostOnlyOrders = false,
OrderModify = false,
MakerRebate = false,
FractionalQuantity = true,
NativeDemoEnvironment = false,
TimeInForce = new HashSet<TimeInForce> { TimeInForce.GoodTillCancel, TimeInForce.FillOrKill },
SettlementCurrency = "USDC"
};
public PolymarketGlobalAdapter(
IMarketCatalog markets, IOrderBookSource orderBook, IOrderGateway orders, IPortfolioSource portfolio, IFeeModel fees)
{
Markets = markets ?? throw new ArgumentNullException(nameof(markets));
OrderBook = orderBook ?? throw new ArgumentNullException(nameof(orderBook));
Orders = orders ?? throw new ArgumentNullException(nameof(orders));
Portfolio = portfolio ?? throw new ArgumentNullException(nameof(portfolio));
Fees = fees ?? throw new ArgumentNullException(nameof(fees));
}
public PlatformId Id => PlatformId.PolymarketGlobal;
public PlatformCapabilities Capabilities => CapabilitiesValue;
public IMarketCatalog Markets { get; }
public IOrderBookSource OrderBook { get; }
public IOrderGateway Orders { get; }
public IPortfolioSource Portfolio { get; }
public IFeeModel Fees { get; }
}
}
@@ -34,7 +34,8 @@ namespace PolyTrader.Modules.Accounting.Logic
var doc = new Document();
doc.Info.Title = "Buchhalterische Abrechnung";
var style = doc.Styles["Normal"];
// "Normal" legt MigraDoc mit jedem Document selbst an - der Indexer ist nur als nullable deklariert.
var style = doc.Styles["Normal"]!;
// Wunschschrift; der Resolver weicht auf eine verfügbare aus, wenn es sie nicht gibt
// (auf Linux z.B. DejaVu Sans).
style.Font.Name = "Segoe UI";
@@ -25,9 +25,8 @@ namespace PolyTrader.Web.Hosting
{
services.AddSingleton(Options.Create(options));
// #565: Update-Status mit Zwischenspeicher - nicht jeder Seitenaufruf fragt das Deploymentcenter.
services.AddSingleton<IUpdateChecker, DeploymentcenterUpdateChecker>();
services.AddSingleton(sp => new UpdateStatusService(sp.GetRequiredService<IUpdateChecker>()));
// Update-Status (#565) registriert Program.cs über AddPolyTraderUpdateCheck - unabhängig vom WebUI,
// damit die regelmäßige Prüfung (#587) auch ohne WebUI läuft.
});
hostBuilder.ConfigureWebHostDefaults(webBuilder =>
@@ -0,0 +1,95 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Configuration;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Updates
{
/// <summary>
/// Prüft regelmäßig auf ein neues Release und weist darauf hin (#587).
///
/// <para><b>Installiert nie.</b> Richards Vorgabe vom 14.09.2026: Updates werden nicht automatisch
/// eingespielt, die Prüfung mit Hinweis läuft aber von selbst. Installiert wird nur auf Klick im Desktop
/// ("Jetzt installieren …"), weil das die Anwendung beendet.</para>
///
/// <para>Bis dahin prüfte der Desktop genau einmal beim Start und das WebUI nur, wenn jemand die Seite
/// aufrief. Eine Anwendung, die wochenlang läuft - der Normalfall im Betrieb -, erfuhr so nie von einem
/// neuen Release. Der Dienst läuft auch ohne WebUI und im kopflosen Betrieb; das Ergebnis landet im
/// gemeinsamen <see cref="UpdateStatusService"/>, an dem Desktop-Banner und WebUI hängen.</para>
/// </summary>
public sealed class UpdateCheckBackgroundService : BackgroundService
{
/// <summary>Erste Prüfung kurz nach dem Start - nicht sofort, damit sie den Start nicht belastet.</summary>
public static readonly TimeSpan StartDelay = TimeSpan.FromSeconds(30);
private readonly UpdateStatusService _status;
private readonly TerminalLogger _logger;
private readonly TimeSpan _interval;
private string? _announcedVersion;
public UpdateCheckBackgroundService(UpdateStatusService status, TerminalLogger logger, DeploymentcenterOptions? options)
{
_status = status;
_logger = logger;
_interval = Interval(options);
}
/// <summary>Takt aus <see cref="DeploymentcenterOptions.UpdateCheckIntervalHours"/>, auf 1 bis 168 Stunden begrenzt.</summary>
public static TimeSpan Interval(DeploymentcenterOptions? options) =>
TimeSpan.FromHours(Math.Clamp(options?.UpdateCheckIntervalHours ?? 6, 1, 168));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false);
while (!stoppingToken.IsCancellationRequested)
{
await CheckOnceAsync(stoppingToken).ConfigureAwait(false);
await Task.Delay(_interval, stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Herunterfahren
}
}
/// <summary>
/// Eine Prüfung. Der Hinweis im Log erscheint einmal je neuer Fassung, nicht bei jeder Prüfung - sonst
/// stünde dieselbe Zeile alle paar Stunden im Log, bis jemand installiert.
/// </summary>
public async Task CheckOnceAsync(CancellationToken ct)
{
var (snapshot, _, _) = await _status.GetAsync(force: false, ct).ConfigureAwait(false);
if (!snapshot.UpdateAvailable || string.IsNullOrEmpty(snapshot.LatestVersion)) return;
if (string.Equals(snapshot.LatestVersion, _announcedVersion, StringComparison.OrdinalIgnoreCase)) return;
_announcedVersion = snapshot.LatestVersion;
string critical = snapshot.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
_logger.Info($"🔔 Update verfügbar: v{snapshot.LatestVersion}{critical}. Installiert wird nur auf Wunsch - " +
"im Desktop über „Jetzt installieren …“ auf der Startseite.");
}
}
public static class UpdateCheckServiceCollectionExtensions
{
/// <summary>
/// Update-Prüfung für Desktop, WebUI und kopflosen Betrieb. Unabhängig davon, ob das WebUI läuft - bis
/// zum 14.09.2026 hing die Registrierung am WebUI, ohne WebUI gab es keine regelmäßige Prüfung.
/// </summary>
public static IServiceCollection AddPolyTraderUpdateCheck(this IServiceCollection services)
{
services.AddSingleton<IUpdateChecker, DeploymentcenterUpdateChecker>();
services.AddSingleton(sp => new UpdateStatusService(sp.GetRequiredService<IUpdateChecker>()));
services.AddHostedService(sp => new UpdateCheckBackgroundService(
sp.GetRequiredService<UpdateStatusService>(),
sp.GetRequiredService<TerminalLogger>(),
sp.GetService<DeploymentcenterOptions>()));
return services;
}
}
}
@@ -52,6 +52,15 @@ namespace PolyTrader.Web.Updates
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
/// <summary>Letztes Prüfergebnis, <c>null</c> vor der ersten Prüfung.</summary>
public UpdateCheckSnapshot? Last => _last;
/// <summary>
/// Feuert nach jeder frischen Prüfung, nicht bei einer Antwort aus dem Zwischenspeicher. Der Desktop hängt
/// sein Update-Banner daran - geprüft wird an genau einer Stelle, angezeigt an zweien.
/// </summary>
public event Action<UpdateCheckSnapshot>? Checked;
/// <summary>
/// Liefert das Ergebnis, bei Bedarf frisch geprüft. <c>RetryAfter</c> ist gesetzt, wenn eine erzwungene
/// Prüfung zu früh kam und stattdessen der vorige Stand geliefert wird.
@@ -59,6 +68,7 @@ namespace PolyTrader.Web.Updates
public async Task<(UpdateCheckSnapshot Snapshot, bool FromCache, TimeSpan? RetryAfter)> GetAsync(bool force, CancellationToken ct)
{
// Eine Prüfung zur Zeit: Gleichzeitige Aufrufe warten und bekommen danach das frische Ergebnis.
UpdateCheckSnapshot fresh;
await _gate.WaitAsync(ct);
try
{
@@ -72,13 +82,17 @@ namespace PolyTrader.Web.Updates
return (_last, true, MinManualInterval - age);
}
_last = await _checker.CheckAsync(ct);
return (_last, false, null);
fresh = await _checker.CheckAsync(ct);
_last = fresh;
}
finally
{
_gate.Release();
}
// Ausserhalb der Sperre: Ein Empfaenger, der selbst GetAsync ruft, soll sich nicht selbst blockieren.
Checked?.Invoke(fresh);
return (fresh, false, null);
}
}
+3
View File
@@ -9,6 +9,9 @@ document.addEventListener('DOMContentLoaded', () => {
applyThemeChoice(currentThemeChoice());
supChatWelcome = document.getElementById('supChatMessages')?.innerHTML ?? '';
loadUpdateStatus(false); // #565: Banner im Dashboard; der Server haelt das Ergebnis vor
// #587: Der Server prueft regelmaessig selbst; die Seite holt nur den vorgehaltenen Stand nach,
// damit ein lange offenes Browserfenster ein neues Release auch mitbekommt.
setInterval(() => loadUpdateStatus(false), 10 * 60 * 1000);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('modalStopTrading')?.style.display === 'flex') closeStopTradingModal();
});
@@ -12,7 +12,8 @@ namespace PolyTrader.Tests.Fakes
/// </summary>
public sealed class FakeClobClient : IClobClient
{
public sealed record PlacedOrder(string TokenId, string Side, decimal Usdc, decimal Price, string OrderType);
public sealed record PlacedOrder(string TokenId, string Side, decimal Usdc, decimal Price, string OrderType,
bool IsNegRisk = false, int FeeBps = 0);
// Aufzeichnungen
public List<PlacedOrder> Placed { get; } = new();
@@ -36,7 +37,7 @@ namespace PolyTrader.Tests.Fakes
string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0,
decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
Placed.Add(new PlacedOrder(tokenId, sideStr, investAmountUsd, limitPrice, orderType));
Placed.Add(new PlacedOrder(tokenId, sideStr, investAmountUsd, limitPrice, orderType, isNegRisk, actualFeeBps));
string result = PlaceResults.Count > 0 ? PlaceResults.Dequeue() : PlaceResult;
return Task.FromResult(result);
}
+418
View File
@@ -0,0 +1,418 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Persistence.Ef;
using PolyTrader.Core.Platforms;
using PolyTrader.Core.Platforms.PolymarketGlobal;
using PolyTrader.Core.Trading;
using PolyTrader.Tests.Fakes;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// #623 Plattform-Fundament: Kennungen, Registry, und dass der Global-Adapter den bestehenden CLOB-Client
/// genau so aufruft, wie die Module es vor #623 selbst getan haben.
/// </summary>
public class PlatformTests
{
// ── PlatformId ─────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_of_missing_value_is_polymarket_global(string? stored)
{
Assert.Equal(PlatformId.PolymarketGlobal, PlatformId.Parse(stored));
}
[Fact]
public void Parse_normalizes_case_and_whitespace()
{
Assert.Equal(PlatformId.PolymarketUs, PlatformId.Parse(" Polymarket-US "));
}
[Fact]
public void Stored_values_are_stable()
{
// Diese Werte stehen in der Datenbank. Ändert sich einer, verlieren Bestandskonten ihre Plattform.
Assert.Equal("polymarket-global", PlatformId.PolymarketGlobal.Value);
Assert.Equal("polymarket-us", PlatformId.PolymarketUs.Value);
Assert.Equal("kalshi", PlatformId.Kalshi.Value);
}
[Fact]
public void New_accounts_and_markets_default_to_polymarket_global()
{
Assert.Equal(PlatformId.PolymarketGlobalValue, new AccountState().Platform);
Assert.Equal(PlatformId.PolymarketGlobalValue, new MarketData().Platform);
}
// ── InstrumentRef ─────────────────────────────────────────────────────
[Fact]
public void Instruments_are_equal_by_platform_and_key_only()
{
var a = InstrumentRef.PolymarketGlobal("123", "0xcond", "Yes");
var b = InstrumentRef.PolymarketGlobal("123");
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void Same_key_on_different_platforms_is_a_different_instrument()
{
Assert.NotEqual(
new InstrumentRef(PlatformId.PolymarketGlobal, "x"),
new InstrumentRef(PlatformId.PolymarketUs, "x"));
}
[Fact]
public void Empty_instrument_key_is_rejected()
{
Assert.Throws<ArgumentException>(() => new InstrumentRef(PlatformId.PolymarketGlobal, " "));
}
// ── Registry ──────────────────────────────────────────────────────────
[Fact]
public void Registry_resolves_adapter_for_account_platform()
{
var global = NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository());
var registry = new PlatformRegistry(new[] { global });
Assert.Same(global, registry.ForAccount(new AccountState()));
Assert.Same(global, registry.ForAccount(new AccountState { Platform = "" }));
Assert.Equal(new[] { PlatformId.PolymarketGlobal }, registry.Available);
}
[Fact]
public void Registry_names_missing_platform_instead_of_returning_null()
{
var registry = new PlatformRegistry(new[] { NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository()) });
var ex = Assert.Throws<InvalidOperationException>(() => registry.Get(PlatformId.PolymarketUs));
Assert.Contains("Polymarket US", ex.Message);
Assert.False(registry.TryGet(PlatformId.Kalshi, out _));
}
[Fact]
public void Registry_refuses_two_adapters_for_one_platform()
{
var a = NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository());
var b = NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository());
Assert.Throws<InvalidOperationException>(() => new PlatformRegistry(new[] { a, b }));
}
[Fact]
public void AddPlatforms_wires_global_adapter_from_the_app_singletons()
{
// Wie Program.cs: API-Dienst, CLOB-Client und Persistenz sind registriert, CopyTrading (und damit
// dessen IClobClient-Registrierung) bewusst NICHT – der Adapter muss trotzdem auflösbar sein.
var logger = new TerminalLogger();
var services = new ServiceCollection();
services.AddSingleton(new PolymarketApiService(logger, new HttpClient()));
services.AddSingleton(new PolymarketClobClient(logger, new HttpClient()));
services.AddSingleton<IMarketRepository>(new StubMarketRepository());
services.AddPlatforms();
using var sp = services.BuildServiceProvider();
var registry = sp.GetRequiredService<IPlatformRegistry>();
var adapter = registry.ForAccount(new AccountState());
Assert.IsType<PolymarketGlobalAdapter>(adapter);
Assert.IsType<GlobalOrderGateway>(adapter.Orders);
Assert.True(adapter.Capabilities.PublicTraderActivity);
Assert.False(adapter.Capabilities.PostOnlyOrders);
}
// ── Global-Gateway: derselbe CLOB-Aufruf wie vorher ───────────────────
[Fact]
public async Task Buy_by_notional_passes_amount_price_negrisk_and_market_fee_through()
{
var clob = new FakeClobClient();
var markets = new StubMarketRepository(new MarketData
{
Id = "m1", ClobTokenIds = "[\"tok-yes\",\"tok-no\"]", NegRisk = true, TakerFeeBps = 180, Category = "Sports"
});
var gateway = new GlobalOrderGateway(clob, markets);
var result = await gateway.PlaceAsync(new AccountState(), new OrderRequest
{
Instrument = InstrumentRef.PolymarketGlobal("tok-yes"),
Side = OrderSide.Buy,
LimitPrice = 0.42m,
NotionalUsd = 25m,
TimeInForce = TimeInForce.FillOrKill
});
Assert.True(result.IsAccepted);
var placed = Assert.Single(clob.Placed);
Assert.Equal(new FakeClobClient.PlacedOrder("tok-yes", "BUY", 25m, 0.42m, "FOK", IsNegRisk: true, FeeBps: 180), placed);
}
[Fact]
public async Task Sell_by_quantity_converts_to_amount_like_the_sell_ladder()
{
// Die SELL-Leiter übergibt pos.Size * Preis als Betrag – das Gateway muss dasselbe senden.
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
await gateway.PlaceAsync(new AccountState(), new OrderRequest
{
Instrument = InstrumentRef.PolymarketGlobal("tok"),
Side = OrderSide.Sell,
LimitPrice = 0.8m,
Quantity = 50m,
TimeInForce = TimeInForce.GoodTillCancel
});
var placed = Assert.Single(clob.Placed);
Assert.Equal("SELL", placed.Side);
Assert.Equal(40m, placed.Usdc);
Assert.Equal("GTC", placed.OrderType);
}
[Fact]
public async Task Unknown_market_uses_conservative_fee_fallback_and_no_negrisk()
{
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
await gateway.PlaceAsync(new AccountState(), Buy("tok", 0.5m, 10m));
var placed = Assert.Single(clob.Placed);
Assert.False(placed.IsNegRisk);
Assert.Equal(FeeModel.FallbackBps(null), placed.FeeBps);
}
public static IEnumerable<object[]> InvalidRequests()
{
var ok = Buy("tok", 0.5m, 10m);
yield return new object[] { ok with { Instrument = new InstrumentRef(PlatformId.PolymarketUs, "slug:YES") }, "Polymarket US" };
yield return new object[] { ok with { PostOnly = true }, "Post-only" };
yield return new object[] { ok with { TimeInForce = TimeInForce.ImmediateOrCancel }, "Gültigkeit" };
yield return new object[] { ok with { TimeInForce = TimeInForce.GoodTillDate }, "Gültigkeit" };
yield return new object[] { ok with { LimitPrice = 1m }, "Limitpreis" };
yield return new object[] { ok with { LimitPrice = 0m }, "Limitpreis" };
yield return new object[] { ok with { Quantity = 5m }, "Mengenangabe" };
yield return new object[] { ok with { NotionalUsd = null }, "Mengenangabe" };
yield return new object[] { ok with { NotionalUsd = 0m }, "größer als 0" };
}
[Theory]
[MemberData(nameof(InvalidRequests))]
public async Task Invalid_request_never_reaches_the_clob(OrderRequest request, string expectedHint)
{
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var result = await gateway.PlaceAsync(new AccountState(), request);
Assert.Equal(OrderOutcome.Invalid, result.Outcome);
Assert.Contains(expectedHint, result.Message);
Assert.Empty(clob.Placed);
}
[Fact]
public async Task Account_of_another_platform_never_reaches_the_clob()
{
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var result = await gateway.PlaceAsync(new AccountState { Platform = PlatformId.PolymarketUsValue }, Buy("tok", 0.5m, 10m));
Assert.Equal(OrderOutcome.Invalid, result.Outcome);
Assert.Empty(clob.Placed);
}
[Theory]
[InlineData("OK", OrderOutcome.Accepted)]
[InlineData("SKIPPED_LIQUIDITY", OrderOutcome.NotFilled)]
[InlineData("ERROR", OrderOutcome.Rejected)]
[InlineData("Error: Missing API or Private Keys", OrderOutcome.Rejected)]
[InlineData("", OrderOutcome.Rejected)]
public async Task Clob_response_is_mapped(string response, OrderOutcome expected)
{
var clob = new FakeClobClient { PlaceResult = response };
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var result = await gateway.PlaceAsync(new AccountState(), Buy("tok", 0.5m, 10m));
Assert.Equal(expected, result.Outcome);
}
[Fact]
public async Task Open_orders_and_cancel_go_to_the_clob()
{
var clob = new FakeClobClient();
clob.OpenOrdersByAsset["tok"] = new() { ("o1", "SELL", 0.7m) };
clob.AllOpenOrders.Add(("o2", "BUY", 0.3m));
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var instrument = InstrumentRef.PolymarketGlobal("tok");
var forToken = await gateway.GetOpenOrdersAsync(new AccountState(), instrument);
var all = await gateway.GetOpenOrdersAsync(new AccountState());
bool canceled = await gateway.CancelAsync(new AccountState(), "o1");
Assert.Equal(new OpenOrder("o1", OrderSide.Sell, 0.7m, instrument), Assert.Single(forToken));
Assert.Equal(new OpenOrder("o2", OrderSide.Buy, 0.3m), Assert.Single(all));
Assert.True(canceled);
Assert.Equal(new[] { "o1" }, clob.CanceledOrderIds);
}
// ── Markt- und Positionsdaten ─────────────────────────────────────────
[Fact]
public void Stored_market_maps_outcomes_to_instruments_in_order()
{
var market = GlobalMarketCatalog.ToPlatformMarket(new MarketData
{
Id = "555", ConditionId = "0xcond", Slug = "ev", Question = "Q?", Category = "Politics",
ClobTokenIds = "[\"t-yes\", \"t-no\"]", Outcomes = "[\"Yes\", \"No\"]",
EndDate = new DateTime(2026, 11, 3, 0, 0, 0, DateTimeKind.Utc), Active = true
});
Assert.Equal(PlatformId.PolymarketGlobal, market.Platform);
Assert.Equal("0xcond", market.MarketKey);
Assert.Equal(new[] { "Yes", "No" }, market.Outcomes.Select(o => o.Name));
Assert.Equal("t-no", market.Outcomes[1].Instrument.Key);
Assert.Equal("0xcond", market.Outcomes[1].Instrument.MarketKey);
}
[Theory]
[InlineData("")]
[InlineData("not json")]
[InlineData("{\"a\":1}")]
public void Market_without_readable_token_ids_has_no_outcomes(string clobTokenIds)
{
var market = GlobalMarketCatalog.ToPlatformMarket(new MarketData { Id = "1", ClobTokenIds = clobTokenIds });
Assert.Empty(market.Outcomes);
Assert.Equal("1", market.MarketKey); // ohne ConditionId die Gamma-ID
}
[Fact]
public void Data_api_position_is_mapped_with_numbers_as_text_or_number()
{
using var doc = JsonDocument.Parse("""
{"asset":"tok","conditionId":"0xc","outcome":"Yes","size":"12.5","avgPrice":0.4,
"curPrice":"0.55","initialValue":5,"currentValue":"6.875","slug":"s","title":"T",
"endDate":"2026-12-31T00:00:00Z"}
""");
var pos = GlobalPortfolioSource.ToPosition(doc.RootElement)!;
Assert.Equal(InstrumentRef.PolymarketGlobal("tok"), pos.Instrument);
Assert.Equal(12.5m, pos.Size);
Assert.Equal(0.4m, pos.AveragePrice);
Assert.Equal(0.55m, pos.CurrentPrice);
Assert.Equal(5m, pos.CostUsd);
Assert.Equal(6.875m, pos.CurrentValueUsd);
Assert.Equal(new DateTime(2026, 12, 31, 0, 0, 0, DateTimeKind.Utc), pos.EndDateUtc);
}
[Fact]
public void Data_api_entry_without_asset_is_skipped()
{
using var doc = JsonDocument.Parse("{\"size\":\"3\"}");
Assert.Null(GlobalPortfolioSource.ToPosition(doc.RootElement));
}
// ── Gebühren ──────────────────────────────────────────────────────────
[Fact]
public void Global_taker_fee_equals_existing_fee_model()
{
var fees = new GlobalFeeModel();
var ctx = new FeeContext { Category = "Crypto" };
decimal fee = fees.EstimateFee(0.5m, 100m, LiquidityRole.Taker, ctx);
Assert.Equal(FeeModel.FeeUsd(50m, FeeModel.CryptoBps), fee);
Assert.Equal(FeeModel.FeeUsd(50m, 75), fees.EstimateFee(0.5m, 100m, LiquidityRole.Taker, new FeeContext { MarketFeeBps = 75 }));
}
[Fact]
public void Global_maker_pays_nothing()
{
Assert.Equal(0m, new GlobalFeeModel().EstimateFee(0.5m, 100m, LiquidityRole.Maker, FeeContext.None));
}
// ── Persistenz: Märkte je Plattform ───────────────────────────────────
[Fact]
public void Same_market_id_on_two_platforms_is_stored_twice_and_reads_stay_global()
{
var repo = new EfMarketRepository(new InMemoryContextFactory<CoreDbContext>(o => new CoreDbContext(o)));
repo.Upsert(new MarketData { Id = "42", Question = "global", ClobTokenIds = "[\"tok\"]" });
repo.Upsert(new MarketData { Id = "42", Question = "us", ClobTokenIds = "[\"tok\"]", Platform = PlatformId.PolymarketUsValue });
Assert.Equal("global", repo.GetById("42")!.Question);
Assert.Equal("global", repo.FindByTokenId("tok")!.Question);
Assert.Equal("global", Assert.Single(repo.GetActive()).Question);
repo.Update(new MarketData { Id = "42", Question = "us2", Platform = PlatformId.PolymarketUsValue });
Assert.Equal("global", repo.GetById("42")!.Question);
}
// ── Hilfen ────────────────────────────────────────────────────────────
private static OrderRequest Buy(string token, decimal price, decimal usd) => new()
{
Instrument = InstrumentRef.PolymarketGlobal(token),
Side = OrderSide.Buy,
LimitPrice = price,
NotionalUsd = usd
};
private static PolymarketGlobalAdapter NewGlobalAdapter(FakeClobClient clob, IMarketRepository markets) =>
new(new NullCatalog(), new NullBook(), new GlobalOrderGateway(clob, markets), new NullPortfolio(), new GlobalFeeModel());
private sealed class StubMarketRepository : IMarketRepository
{
private readonly List<MarketData> _markets;
public StubMarketRepository(params MarketData[] markets) => _markets = markets.ToList();
public MarketData? GetById(string id) => _markets.FirstOrDefault(m => m.Id == id);
public MarketData? FindByTokenId(string tokenId) => _markets.FirstOrDefault(m => m.ClobTokenIds.Contains(tokenId));
public List<MarketData> GetActive() => _markets.Where(m => !m.Closed).ToList();
public void Upsert(MarketData market) { }
public void Insert(MarketData market) { }
public void Update(MarketData market) { }
public void EnsureIndexes() { }
}
private sealed class NullCatalog : IMarketCatalog
{
public Task<IReadOnlyList<PlatformMarket>> GetRecentMarketsAsync(int limit, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<PlatformMarket>>(Array.Empty<PlatformMarket>());
public Task<PlatformMarket?> GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default) =>
Task.FromResult<PlatformMarket?>(null);
public Task<MarketResolution> GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default) =>
Task.FromResult(MarketResolution.Open);
}
private sealed class NullBook : IOrderBookSource
{
public Task<decimal?> GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default) =>
Task.FromResult<decimal?>(null);
}
private sealed class NullPortfolio : IPortfolioSource
{
public Task<decimal> GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default) => Task.FromResult(0m);
public Task<IReadOnlyList<PlatformPosition>?> GetPositionsAsync(AccountState account, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<PlatformPosition>?>(Array.Empty<PlatformPosition>());
}
}
}
@@ -104,6 +104,80 @@ namespace PolyTrader.Tests.WebUi
Assert.Equal(1, checker.Calls);
}
[Fact]
public async Task Checked_feuert_nur_bei_frischer_Pruefung()
{
var clock = new Clock();
var checker = new FakeChecker(() => clock.Now);
var service = new UpdateStatusService(checker, () => clock.Now);
int events = 0;
service.Checked += _ => events++;
await service.GetAsync(false, CancellationToken.None);
await service.GetAsync(false, CancellationToken.None); // aus dem Zwischenspeicher
Assert.Equal(1, events);
Assert.Equal("0.2.0", service.Last?.LatestVersion);
}
[Fact]
public async Task Hintergrundpruefung_meldet_jede_neue_Fassung_genau_einmal_und_installiert_nichts()
{
var clock = new Clock();
var checker = new FakeChecker(() => clock.Now);
var status = new UpdateStatusService(checker, () => clock.Now);
var logger = new PolyTraderSharp.Services.TerminalLogger();
var notices = new System.Collections.Generic.List<string>();
logger.OnLogMessage += (_, e) => { if (e.Message.Contains("Update verfügbar")) notices.Add(e.Message); };
var background = new UpdateCheckBackgroundService(status, logger, null);
await background.CheckOnceAsync(CancellationToken.None);
clock.Now = clock.Now + UpdateStatusService.CacheDuration;
await background.CheckOnceAsync(CancellationToken.None); // dieselbe Fassung: kein zweiter Hinweis
checker.Result = checker.Result with { LatestVersion = "0.3.0" };
clock.Now = clock.Now + UpdateStatusService.CacheDuration;
await background.CheckOnceAsync(CancellationToken.None);
Assert.Equal(2, notices.Count);
Assert.Contains("v0.2.0", notices[0]);
Assert.Contains("v0.3.0", notices[1]);
Assert.All(notices, n => Assert.Contains("nur auf Wunsch", n));
}
[Fact]
public async Task Ohne_Update_kein_Hinweis()
{
var clock = new Clock();
var checker = new FakeChecker(() => clock.Now);
checker.Result = checker.Result with { UpdateAvailable = false, LatestVersion = null };
var logger = new PolyTraderSharp.Services.TerminalLogger();
int notices = 0;
logger.OnLogMessage += (_, e) => { if (e.Message.Contains("Update verfügbar")) notices++; };
await new UpdateCheckBackgroundService(new UpdateStatusService(checker, () => clock.Now), logger, null)
.CheckOnceAsync(CancellationToken.None);
Assert.Equal(0, notices);
}
[Theory]
[InlineData(null, 6)]
[InlineData(0, 1)]
[InlineData(12, 12)]
[InlineData(10000, 168)]
public void Pruefintervall_ist_begrenzt(int? hours, int expected)
{
var options = hours == null ? null : new PolyTrader.Core.Configuration.DeploymentcenterOptions { UpdateCheckIntervalHours = hours.Value };
Assert.Equal(TimeSpan.FromHours(expected), UpdateCheckBackgroundService.Interval(options));
}
[Fact]
public void Update_Pruefung_ist_ab_Werk_an()
{
Assert.True(new PolyTrader.Core.Configuration.DeploymentcenterOptions().UpdateCheckEnabled);
}
[Fact]
public async Task Route_liefert_Fassung_Stand_und_verweist_zur_Installation_auf_den_Desktop()
{