Compare commits
2
Commits
94716157cf
...
c863bf5fce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c863bf5fce | ||
|
|
05979406c7 |
@@ -408,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
|
||||
@@ -543,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
|
||||
|
||||
@@ -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 | — |
|
||||
@@ -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;
|
||||
@@ -327,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>()));
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+490
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user