From c863bf5fce795377ffc662506927172725438db8 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 18 Sep 2026 10:54:00 +0200 Subject: [PATCH] Plattform-Fundament im Core: Vertraege, Registry, Global-Adapter (#623) Neue neutrale Handelsvertraege (Maerkte, Orderbuch, Orders, Portfolio, Gebuehren) hinter IPlatformAdapter, aufgeloest ueber IPlatformRegistry je Konto. Polymarket Global ist eine duenne Huelle um die bestehenden Dienste; PolymarketClobClient bleibt unveraendert. Das Gateway lehnt ab, was die heutige Anbindung nicht sauber kann (Post-only, IOC/GTD, falsche Plattform), bevor etwas an den CLOB geht. core_accounts und core_markets bekommen die Spalte Platform (Vorgabe polymarket-global), core_markets den Schluessel (Platform, Id). core_positions bleibt: die Plattform folgt aus dem Konto. Noch nutzt kein Modul die Vertraege - das Handelsverhalten ist unveraendert. Vor dem ersten Start auf dem Server: dotnet ef database update --project src/PolyTrader.Core (Migration AddPlatformColumns). Co-Authored-By: Claude Opus 5 --- src/PolyTrader.App.Avalonia/Program.cs | 5 + src/PolyTrader.Core/Models/AccountState.cs | 8 + src/PolyTrader.Core/Models/MarketData.cs | 6 + .../Persistence/Ef/CoreDbContext.cs | 7 +- .../Persistence/Ef/EfMarketRepository.cs | 15 +- ...60918084743_AddPlatformColumns.Designer.cs | 490 ++++++++++++++++++ .../20260918084743_AddPlatformColumns.cs | 69 +++ .../Migrations/CoreDbContextModelSnapshot.cs | 11 +- .../Persistence/IMarketRepository.cs | 5 +- .../Platforms/InstrumentRef.cs | 38 ++ .../Platforms/PlatformCapabilities.cs | 54 ++ .../Platforms/PlatformContracts.cs | 79 +++ src/PolyTrader.Core/Platforms/PlatformId.cs | 53 ++ .../Platforms/PlatformModels.cs | 105 ++++ .../Platforms/PlatformRegistry.cs | 56 ++ .../PlatformServiceCollectionExtensions.cs | 37 ++ .../PolymarketGlobal/GlobalMarketData.cs | 194 +++++++ .../PolymarketGlobal/GlobalOrderGateway.cs | 127 +++++ .../PolymarketGlobalAdapter.cs | 48 ++ .../PolyTrader.Tests/Fakes/FakeClobClient.cs | 5 +- tests/PolyTrader.Tests/PlatformTests.cs | 418 +++++++++++++++ 21 files changed, 1820 insertions(+), 10 deletions(-) create mode 100644 src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.Designer.cs create mode 100644 src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.cs create mode 100644 src/PolyTrader.Core/Platforms/InstrumentRef.cs create mode 100644 src/PolyTrader.Core/Platforms/PlatformCapabilities.cs create mode 100644 src/PolyTrader.Core/Platforms/PlatformContracts.cs create mode 100644 src/PolyTrader.Core/Platforms/PlatformId.cs create mode 100644 src/PolyTrader.Core/Platforms/PlatformModels.cs create mode 100644 src/PolyTrader.Core/Platforms/PlatformRegistry.cs create mode 100644 src/PolyTrader.Core/Platforms/PlatformServiceCollectionExtensions.cs create mode 100644 src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalMarketData.cs create mode 100644 src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalOrderGateway.cs create mode 100644 src/PolyTrader.Core/Platforms/PolymarketGlobal/PolymarketGlobalAdapter.cs create mode 100644 tests/PolyTrader.Tests/PlatformTests.cs diff --git a/src/PolyTrader.App.Avalonia/Program.cs b/src/PolyTrader.App.Avalonia/Program.cs index 92e39ae..70ec2fc 100644 --- a/src/PolyTrader.App.Avalonia/Program.cs +++ b/src/PolyTrader.App.Avalonia/Program.cs @@ -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(), 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( sp => new LogNotificationSink(sp.GetRequiredService())); diff --git a/src/PolyTrader.Core/Models/AccountState.cs b/src/PolyTrader.Core/Models/AccountState.cs index 6a29e0f..e6c3add 100644 --- a/src/PolyTrader.Core/Models/AccountState.cs +++ b/src/PolyTrader.Core/Models/AccountState.cs @@ -28,6 +28,14 @@ namespace PolyTraderSharp.Models [Category("02. Wallet & Keys")] public string PrivateKey { get; set; } = string.Empty; + /// + /// Handelsplatz des Kontos (#623), gespeicherte Kennung aus . + /// Konten von vor #623 stehen auf Polymarket Global. Noch nicht in der Oberfläche wählbar – das kommt + /// mit dem ersten zweiten Adapter (#625). + /// + [Browsable(false)] + public string Platform { get; set; } = PolyTrader.Core.Platforms.PlatformId.PolymarketGlobalValue; + [Category("01. General")] public bool IsDemo { get; set; } diff --git a/src/PolyTrader.Core/Models/MarketData.cs b/src/PolyTrader.Core/Models/MarketData.cs index f7183e3..4f38f45 100644 --- a/src/PolyTrader.Core/Models/MarketData.cs +++ b/src/PolyTrader.Core/Models/MarketData.cs @@ -2,6 +2,12 @@ namespace PolyTraderSharp.Models { public class MarketData { + /// + /// Handelsplatz des Marktes (#623). Zusammen mit der Schlüssel von core_markets, weil + /// Markt-IDs verschiedener Plattformen sich überschneiden können. + /// + 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; diff --git a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs index a0adc84..761b599 100644 --- a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs +++ b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs @@ -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(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); diff --git a/src/PolyTrader.Core/Persistence/Ef/EfMarketRepository.cs b/src/PolyTrader.Core/Persistence/Ef/EfMarketRepository.cs index 99cfefc..2c0132b 100644 --- a/src/PolyTrader.Core/Persistence/Ef/EfMarketRepository.cs +++ b/src/PolyTrader.Core/Persistence/Ef/EfMarketRepository.cs @@ -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 _factory; public EfMarketRepository(IDbContextFactory 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 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); diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.Designer.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.Designer.cs new file mode 100644 index 0000000..58a7534 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.Designer.cs @@ -0,0 +1,490 @@ +// +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 + { + /// + 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("AccountId") + .HasColumnType("int"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ApiPassphrase") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("ApiSecret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("AvailableBalance") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CloseOnlyMode") + .HasColumnType("tinyint(1)"); + + b.Property("HasOpenLimitOrders") + .HasColumnType("tinyint(1)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PayoutAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("PayoutLimitUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("PrivateKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("TotalBalance") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WalletAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("AccountId"); + + b.ToTable("core_accounts", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.DecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ContextJson") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketEndDate") + .HasColumnType("datetime(6)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("SignalPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("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("Platform") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Id") + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ClobTokenIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Closed") + .HasColumnType("tinyint(1)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("NegRisk") + .HasColumnType("tinyint(1)"); + + b.Property("Outcomes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("TakerFeeBps") + .HasColumnType("int"); + + b.HasKey("Platform", "Id"); + + b.ToTable("core_markets", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.OrderEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AmountUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("DetailsJson") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Price") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Response") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("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("AccountId") + .HasColumnType("int"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("TokenId") + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("AmountUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("CurrentPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CurrentValueUsd") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitReason") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PnlPercent") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("RealizedPnl") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("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 + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.cs new file mode 100644 index 0000000..aba932e --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260918084743_AddPlatformColumns.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Core.Persistence.Ef.Migrations +{ + /// + /// #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. + /// + /// 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. + /// + public partial class AddPlatformColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_core_markets", + table: "core_markets"); + + migrationBuilder.AddColumn( + name: "Platform", + table: "core_markets", + type: "varchar(40)", + maxLength: 40, + nullable: false, + defaultValue: "polymarket-global") + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + 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" }); + } + + /// + 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"); + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index 0d96234..c6a2e34 100644 --- a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -72,6 +72,11 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations .HasPrecision(18, 6) .HasColumnType("decimal(18,6)"); + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + b.Property("PrivateKey") .IsRequired() .HasMaxLength(512) @@ -183,6 +188,10 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations modelBuilder.Entity("PolyTraderSharp.Models.MarketData", b => { + b.Property("Platform") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + b.Property("Id") .HasMaxLength(120) .HasColumnType("varchar(120)"); @@ -230,7 +239,7 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations b.Property("TakerFeeBps") .HasColumnType("int"); - b.HasKey("Id"); + b.HasKey("Platform", "Id"); b.ToTable("core_markets", (string)null); }); diff --git a/src/PolyTrader.Core/Persistence/IMarketRepository.cs b/src/PolyTrader.Core/Persistence/IMarketRepository.cs index 45a89ea..a6f8288 100644 --- a/src/PolyTrader.Core/Persistence/IMarketRepository.cs +++ b/src/PolyTrader.Core/Persistence/IMarketRepository.cs @@ -4,7 +4,10 @@ using PolyTraderSharp.Models; namespace PolyTrader.Core.Persistence { /// - /// Zugriff auf die Markt-Metadaten (Collection "markets"). + /// Zugriff auf die Markt-Metadaten (Tabelle core_markets). + /// + /// #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 . /// public interface IMarketRepository { diff --git a/src/PolyTrader.Core/Platforms/InstrumentRef.cs b/src/PolyTrader.Core/Platforms/InstrumentRef.cs new file mode 100644 index 0000000..be9db77 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/InstrumentRef.cs @@ -0,0 +1,38 @@ +using System; + +namespace PolyTrader.Core.Platforms +{ + /// + /// Ein handelbarer Ausgang auf einer Plattform (#623) – der plattformneutrale Ersatz für die + /// nackte TokenId. + /// + /// ist innerhalb der Plattform eindeutig und das, was in + /// core_positions.TokenId gespeichert wird. Die Spalte behält ihren Namen, damit die bestehenden + /// Fundstellen schrittweise umziehen können. + /// + /// Polymarket GlobalKey = tokenId, MarketKey = conditionId + /// Polymarket USKey = "{marketSlug}:YES" bzw. ":NO", MarketKey = marketSlug + /// KalshiKey = "{ticker}:YES" bzw. ":NO", MarketKey = ticker + /// + /// + /// Gleichheit hängt nur an Plattform und . und + /// sind Beiwerk, das nicht jeder Aufrufer kennt. + /// + 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)); + + /// Ausgang auf Polymarket Global über seine Token-ID. + 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}"; + } +} diff --git a/src/PolyTrader.Core/Platforms/PlatformCapabilities.cs b/src/PolyTrader.Core/Platforms/PlatformCapabilities.cs new file mode 100644 index 0000000..b4b17fc --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PlatformCapabilities.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; + +namespace PolyTrader.Core.Platforms +{ + /// Gültigkeit einer Order, plattformneutral. Jeder Adapter übersetzt in seine eigenen Begriffe. + public enum TimeInForce + { + /// Bleibt im Buch, bis ausgeführt oder storniert. + GoodTillCancel, + /// Ganz oder gar nicht, sofort. + FillOrKill, + /// Sofort so viel wie möglich, Rest verfällt. + ImmediateOrCancel, + /// Bleibt im Buch bis zu einem Zeitpunkt. + GoodTillDate, + /// Bleibt im Buch bis Handelsschluss des Tages. + Day + } + + /// + /// Was ein Handelsplatz kann (#623). Logik fragt nach Fähigkeiten, nicht nach dem Namen der Plattform: + /// if (caps.PublicTraderActivity) statt if (platform == PolymarketGlobal). Ein neuer Adapter + /// bringt seine Fähigkeiten mit, und bestehende Module entscheiden richtig, ohne ihn zu kennen. + /// + public sealed record PlatformCapabilities + { + /// Trades fremder Konten sind öffentlich beobachtbar – Voraussetzung für CopyTrading. + public bool PublicTraderActivity { get; init; } + + /// Abwicklung auf einer Blockchain: gewonnene Positionen müssen eingelöst werden (Redeem, #146). + public bool OnChainSettlement { get; init; } + + /// Orders, die abgelehnt werden, statt sofort gegen das Buch auszuführen. + public bool PostOnlyOrders { get; init; } + + /// Preis/Menge einer offenen Order ändern, ohne sie zu stornieren und neu zu platzieren. + public bool OrderModify { get; init; } + + /// Maker bekommen eine Gutschrift statt einer Gebühr. + public bool MakerRebate { get; init; } + + /// Mengen unter einem ganzen Kontrakt/Share sind handelbar. + public bool FractionalQuantity { get; init; } + + /// Die Plattform bietet eine eigene Testumgebung mit Spielgeld. + public bool NativeDemoEnvironment { get; init; } + + /// Welche Gültigkeiten der Adapter annimmt. Alles andere lehnt er ab. + public IReadOnlySet TimeInForce { get; init; } = new HashSet(); + + /// Währung, in der Salden und Auszahlungen geführt werden, z. B. "USDC" oder "USD". + public string SettlementCurrency { get; init; } = "USD"; + } +} diff --git a/src/PolyTrader.Core/Platforms/PlatformContracts.cs b/src/PolyTrader.Core/Platforms/PlatformContracts.cs new file mode 100644 index 0000000..7a682a0 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PlatformContracts.cs @@ -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. + + /// Märkte finden und ihren Auflösungsstand abfragen. + public interface IMarketCatalog + { + /// Die zuletzt angelegten Märkte, neueste zuerst. + Task> GetRecentMarketsAsync(int limit, CancellationToken ct = default); + + /// Der Markt, zu dem ein Ausgang gehört; null, wenn unbekannt oder bei Fehler. + Task GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default); + + Task GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default); + } + + /// Preise aus dem Orderbuch. + public interface IOrderBookSource + { + /// + /// Bester Preis, zu dem man sofort handeln kann: für der niedrigste Ask, + /// für das höchste Bid. null bei leerem Buch oder Fehler. + /// + Task GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default); + } + + /// Orders platzieren, abfragen, stornieren. Der geldkritische Vertrag. + public interface IOrderGateway + { + Task PlaceAsync(AccountState account, OrderRequest request, CancellationToken ct = default); + + /// Offene Orders; mit nur für diesen Ausgang. + Task> GetOpenOrdersAsync(AccountState account, InstrumentRef? instrument = null, CancellationToken ct = default); + + Task CancelAsync(AccountState account, string orderId, CancellationToken ct = default); + } + + /// Saldo und Positionen des eigenen Kontos. + public interface IPortfolioSource + { + /// Für Orders verfügbares Guthaben in der Abwicklungswährung der Plattform. + Task GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default); + + /// Offene Positionen; null bei Fehler (nicht zu verwechseln mit „keine Positionen"). + Task?> GetPositionsAsync(AccountState account, CancellationToken ct = default); + } + + /// + /// 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. + /// + public interface IFeeModel + { + /// Gebühr in der Abwicklungswährung; negativ bei Maker-Gutschrift. + decimal EstimateFee(decimal price, decimal quantity, LiquidityRole role, FeeContext context); + } + + /// Ein Handelsplatz mit allen seinen Verträgen. + public interface IPlatformAdapter + { + PlatformId Id { get; } + PlatformCapabilities Capabilities { get; } + IMarketCatalog Markets { get; } + IOrderBookSource OrderBook { get; } + IOrderGateway Orders { get; } + IPortfolioSource Portfolio { get; } + IFeeModel Fees { get; } + } +} diff --git a/src/PolyTrader.Core/Platforms/PlatformId.cs b/src/PolyTrader.Core/Platforms/PlatformId.cs new file mode 100644 index 0000000..6f5d006 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PlatformId.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace PolyTrader.Core.Platforms +{ + /// + /// Kennung eines Handelsplatzes (#623). Der Wert wird in der Datenbank gespeichert + /// (core_accounts.Platform, core_markets.Platform) – bestehende Werte daher nie umbenennen. + /// + /// 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. + /// + 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); + + /// Alle Plattformen, die PolyTrader dem Namen nach kennt – nicht alle haben schon einen Adapter. + public static IReadOnlyList 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; } + + /// + /// Liest eine gespeicherte Kennung. Leer oder null ergibt Polymarket Global – das ist der + /// Stand aller Konten und Märkte, die vor #623 angelegt wurden. + /// + public static PlatformId Parse(string? value) => + string.IsNullOrWhiteSpace(value) ? PolymarketGlobal : new PlatformId(value); + + /// Anzeigename für Oberflächen und Logs. + public string DisplayName => Value switch + { + PolymarketGlobalValue => "Polymarket", + PolymarketUsValue => "Polymarket US", + KalshiValue => "Kalshi", + _ => Value + }; + + public override string ToString() => Value ?? PolymarketGlobalValue; + } +} diff --git a/src/PolyTrader.Core/Platforms/PlatformModels.cs b/src/PolyTrader.Core/Platforms/PlatformModels.cs new file mode 100644 index 0000000..79ffc07 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PlatformModels.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace PolyTrader.Core.Platforms +{ + public enum OrderSide { Buy, Sell } + + /// Rolle bei einer Ausführung – bestimmt die Gebühr. + public enum LiquidityRole { Taker, Maker } + + /// + /// Eine Order, plattformneutral (#623). Preise sind immer Wahrscheinlichkeiten 0..1 – ein Adapter, + /// dessen Plattform in Cent rechnet, rechnet selbst um. + /// + /// Genau eine der beiden Mengenangaben ist gesetzt: (Kontrakte/Shares) oder + /// (Betrag). Plattformspezifisches (Polymarket Global: negRisk, Tick-Size, + /// Gebühr in der Signatur) bleibt im Adapter. + /// + 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; } + + /// Welches Modul die Order auslöst – für Journal und die Plattform-Sperre (#624). + public string OriginModule { get; init; } = string.Empty; + + /// Korrelation zum Entscheidungsjournal (SignalId). + public string CorrelationId { get; init; } = string.Empty; + } + + public enum OrderOutcome + { + /// Die Plattform hat die Order angenommen. + Accepted, + /// Nicht ausgeführt, weil die Gegenseite nicht reichte (z. B. FOK ohne Tiefe). Kein Fehler. + NotFilled, + /// Vom Adapter vor dem Senden abgelehnt (unzulässige Anfrage) – es ging nichts an die Plattform. + Invalid, + /// Von der Plattform abgelehnt oder technischer Fehler. + 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); + } + + /// Offene Order. ist null, wenn die Plattform es in der Liste nicht mitliefert. + public sealed record OpenOrder(string Id, OrderSide Side, decimal Price, InstrumentRef? Instrument = null); + + /// Ein Ausgang eines Marktes mit seinem Namen ("Yes", "No", "Lakers" …). + public sealed record PlatformOutcome(string Name, InstrumentRef Instrument); + + /// Markt-Metadaten, plattformneutral. + public sealed record PlatformMarket + { + public required PlatformId Platform { get; init; } + /// Marktschlüssel der Plattform (Global: conditionId, US: Slug, Kalshi: Ticker). + 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 Outcomes { get; init; } = Array.Empty(); + } + + /// Auflösungsstand eines Ausgangs. + public sealed record MarketResolution(bool IsClosed, bool IsWinner) + { + public static readonly MarketResolution Open = new(false, false); + } + + /// Eigene Position laut Plattform. + 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; } + } + + /// Was die Gebührenberechnung über den Markt wissen muss. Jedes Feld ist optional. + public sealed record FeeContext + { + public string? Category { get; init; } + /// Gebührensatz, den die Plattform für diesen Markt meldet (Polymarket Global: bps). 0 = unbekannt. + public int MarketFeeBps { get; init; } + + public static readonly FeeContext None = new(); + } +} diff --git a/src/PolyTrader.Core/Platforms/PlatformRegistry.cs b/src/PolyTrader.Core/Platforms/PlatformRegistry.cs new file mode 100644 index 0000000..70ba862 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PlatformRegistry.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTraderSharp.Models; + +namespace PolyTrader.Core.Platforms +{ + /// Löst den Adapter einer Plattform auf – der einzige Weg an Handelsverträge (#623). + public interface IPlatformRegistry + { + /// Plattformen, für die ein Adapter registriert ist. + IReadOnlyList Available { get; } + + bool IsAvailable(PlatformId platform); + + /// Wirft, wenn für die Plattform kein Adapter registriert ist. + IPlatformAdapter Get(PlatformId platform); + + bool TryGet(PlatformId platform, out IPlatformAdapter? adapter); + + /// Adapter der Plattform, zu der das Konto gehört. + IPlatformAdapter ForAccount(AccountState account); + } + + public sealed class PlatformRegistry : IPlatformRegistry + { + private readonly Dictionary _adapters; + + public PlatformRegistry(IEnumerable adapters) + { + _adapters = new Dictionary(); + 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 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)); + } +} diff --git a/src/PolyTrader.Core/Platforms/PlatformServiceCollectionExtensions.cs b/src/PolyTrader.Core/Platforms/PlatformServiceCollectionExtensions.cs new file mode 100644 index 0000000..4d6b8d6 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PlatformServiceCollectionExtensions.cs @@ -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 + { + /// + /// Registriert die Plattform-Adapter und die Registry (#623). Setzt voraus, dass + /// , und die Core-Persistenz + /// schon registriert sind. + /// + /// Der Global-Adapter nutzt denselben -Singleton wie die Module – + /// bewusst ohne Umweg über die IClobClient-Registrierung des CopyTrading-Moduls, die fehlt, wenn + /// CopyTrading abgeschaltet ist. + /// + public static IServiceCollection AddPlatforms(this IServiceCollection services) + { + services.AddSingleton(sp => + { + var api = sp.GetRequiredService(); + var clob = sp.GetRequiredService(); + return new PolymarketGlobalAdapter( + new GlobalMarketCatalog(api), + new GlobalOrderBookSource(api), + new GlobalOrderGateway(clob, sp.GetRequiredService()), + new GlobalPortfolioSource(clob, api), + new GlobalFeeModel()); + }); + + services.AddSingleton(sp => new PlatformRegistry(sp.GetServices())); + return services; + } + } +} diff --git a/src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalMarketData.cs b/src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalMarketData.cs new file mode 100644 index 0000000..d208534 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalMarketData.cs @@ -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 +{ + /// Marktkatalog über Gamma-API (#623) – Hülle um . + public sealed class GlobalMarketCatalog : IMarketCatalog + { + private readonly PolymarketApiService _api; + + public GlobalMarketCatalog(PolymarketApiService api) => _api = api ?? throw new ArgumentNullException(nameof(api)); + + public async Task> GetRecentMarketsAsync(int limit, CancellationToken ct = default) + { + var markets = await _api.GetRecentMarketsAsync(limit); + return markets.Select(ToPlatformMarket).ToList(); + } + + public async Task GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default) + { + EnsureGlobal(instrument); + var market = await _api.GetMarketByTokenIdAsync(instrument.Key); + return market == null ? null : ToPlatformMarket(market); + } + + public async Task 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); + } + + /// + /// Übersetzt die gespeicherten Global-Metadaten. ClobTokenIds und Outcomes sind JSON-Arrays + /// als Text und gehören positionsweise zusammen; fehlt ein Name, heißt der Ausgang nach seiner Stelle. + /// + 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 ParseStringArray(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return new List(); + return doc.RootElement.EnumerateArray() + .Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() ?? string.Empty : e.ToString()) + .ToList(); + } + catch (JsonException) + { + return new List(); + } + } + + 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)); + } + } + + /// Bester Preis aus dem CLOB-Orderbuch (#623) – Hülle um . + public sealed class GlobalOrderBookSource : IOrderBookSource + { + private readonly PolymarketApiService _api; + + public GlobalOrderBookSource(PolymarketApiService api) => _api = api ?? throw new ArgumentNullException(nameof(api)); + + public Task 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)); + } + } + + /// + /// Saldo und Positionen (#623): Saldo aus dem CLOB (/balance-allowance, das für Orders verfügbare + /// Collateral), Positionen aus der Data-API. + /// + 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 GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default) => + _clob.GetUsdcBalanceAsync(account ?? throw new ArgumentNullException(nameof(account))); + + public async Task?> 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(); + } + + /// Übersetzt einen Eintrag von data-api.polymarket.com/positions. Ohne asset kein Ergebnis. + 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; + } + } + + /// + /// Gebühren von Polymarket Global (#623) – Hülle um : Taker zahlt den Satz des + /// Marktes (bps auf das Notional), sonst den Kategorie-Fallback; Maker zahlen nichts. + /// + 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); + } + } +} diff --git a/src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalOrderGateway.cs b/src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalOrderGateway.cs new file mode 100644 index 0000000..595256e --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PolymarketGlobal/GlobalOrderGateway.cs @@ -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 +{ + /// + /// Order-Gateway für Polymarket Global (#623): übersetzt eine neutrale in einen + /// Aufruf des bestehenden – und sonst nichts. + /// + /// Hülle, kein Umbau. Signierung, Betragsrechnung, Tick-Size-/Fee-Nachregelung und alle + /// Wiederholungen bleiben in (.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 (FeeModel.ResolveBps, Fallback bei + /// unbekanntem Markt). + /// + 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 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> 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 CancelAsync(AccountState account, string orderId, CancellationToken ct = default) + { + if (account == null) throw new ArgumentNullException(nameof(account)); + return _clob.CancelOrderAsync(account, orderId); + } + + /// Prüft, was vor dem Senden feststehen muss. null = zulässig. + 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.") + }; + + /// + /// Übersetzt die Antwort des CLOB-Clients. Er liefert "OK", "SKIPPED_LIQUIDITY" (FOK-Kauf ohne Tiefe, + /// bewusst leise) oder einen Fehlertext. + /// + 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) + }; + } +} diff --git a/src/PolyTrader.Core/Platforms/PolymarketGlobal/PolymarketGlobalAdapter.cs b/src/PolyTrader.Core/Platforms/PolymarketGlobal/PolymarketGlobalAdapter.cs new file mode 100644 index 0000000..db1e0a7 --- /dev/null +++ b/src/PolyTrader.Core/Platforms/PolymarketGlobal/PolymarketGlobalAdapter.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace PolyTrader.Core.Platforms.PolymarketGlobal +{ + /// + /// 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. + /// + public sealed class PolymarketGlobalAdapter : IPlatformAdapter + { + /// + /// 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. + /// + public static readonly PlatformCapabilities CapabilitiesValue = new() + { + PublicTraderActivity = true, + OnChainSettlement = true, + PostOnlyOrders = false, + OrderModify = false, + MakerRebate = false, + FractionalQuantity = true, + NativeDemoEnvironment = false, + TimeInForce = new HashSet { 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; } + } +} diff --git a/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs b/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs index 7ed66cc..0f01fd5 100644 --- a/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs +++ b/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs @@ -12,7 +12,8 @@ namespace PolyTrader.Tests.Fakes /// 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 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); } diff --git a/tests/PolyTrader.Tests/PlatformTests.cs b/tests/PolyTrader.Tests/PlatformTests.cs new file mode 100644 index 0000000..caeb5b5 --- /dev/null +++ b/tests/PolyTrader.Tests/PlatformTests.cs @@ -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 +{ + /// + /// #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. + /// + 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(() => 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(() => 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(() => 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(new StubMarketRepository()); + + services.AddPlatforms(); + using var sp = services.BuildServiceProvider(); + var registry = sp.GetRequiredService(); + + var adapter = registry.ForAccount(new AccountState()); + Assert.IsType(adapter); + Assert.IsType(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 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(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 _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 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> GetRecentMarketsAsync(int limit, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); + public Task GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default) => + Task.FromResult(null); + public Task GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default) => + Task.FromResult(MarketResolution.Open); + } + + private sealed class NullBook : IOrderBookSource + { + public Task GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default) => + Task.FromResult(null); + } + + private sealed class NullPortfolio : IPortfolioSource + { + public Task GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default) => Task.FromResult(0m); + public Task?> GetPositionsAsync(AccountState account, CancellationToken ct = default) => + Task.FromResult?>(Array.Empty()); + } + } +}