diff --git a/src/PolyTrader.Core/Models/MarketData.cs b/src/PolyTrader.Core/Models/MarketData.cs index 74e6651..f7183e3 100644 --- a/src/PolyTrader.Core/Models/MarketData.cs +++ b/src/PolyTrader.Core/Models/MarketData.cs @@ -15,7 +15,13 @@ namespace PolyTraderSharp.Models public string ClobTokenIds { get; set; } = string.Empty; public string Outcomes { get; set; } = string.Empty; // e.g. "[\"Yes\", \"No\"]" - + public bool NegRisk { get; set; } + + /// + /// Taker-Fee dieses Marktes in Basispunkten (100 = 1,0 %). 0 = keine/unbekannt. + /// Wird beim Markt-Sync gesetzt (API-Feld bevorzugt, sonst Kategorie-Fallback via FeeModel). + /// + public int TakerFeeBps { get; set; } } } diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/20260707155613_AddMarketTakerFeeBps.Designer.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260707155613_AddMarketTakerFeeBps.Designer.cs new file mode 100644 index 0000000..ae08803 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260707155613_AddMarketTakerFeeBps.Designer.cs @@ -0,0 +1,313 @@ +// +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("20260707155613_AddMarketTakerFeeBps")] + partial class AddMarketTakerFeeBps + { + /// + 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(256) + .HasColumnType("varchar(256)"); + + b.Property("ApiSecret") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + 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("PrivateKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + 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.MarketData", b => + { + 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("Id"); + + b.ToTable("core_markets", (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("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/20260707155613_AddMarketTakerFeeBps.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260707155613_AddMarketTakerFeeBps.cs new file mode 100644 index 0000000..4f2ede5 --- /dev/null +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/20260707155613_AddMarketTakerFeeBps.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Core.Persistence.Ef.Migrations +{ + /// + public partial class AddMarketTakerFeeBps : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TakerFeeBps", + table: "core_markets", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TakerFeeBps", + table: "core_markets"); + } + } +} diff --git a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index 6c90521..7cba92b 100644 --- a/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/PolyTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -137,6 +137,9 @@ namespace PolyTrader.Core.Persistence.Ef.Migrations .HasMaxLength(300) .HasColumnType("varchar(300)"); + b.Property("TakerFeeBps") + .HasColumnType("int"); + b.HasKey("Id"); b.ToTable("core_markets", (string)null); diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/FeeModel.cs b/src/PolyTrader.Modules.CopyTrading/Logic/FeeModel.cs new file mode 100644 index 0000000..63943bd --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Logic/FeeModel.cs @@ -0,0 +1,41 @@ +namespace PolyTrader.Modules.CopyTrading.Logic +{ + /// + /// Reine Fee-Logik (Phase 0.2). Polymarket erhebt seit März 2026 Taker-Fees (Maker zahlen 0). + /// Der reale Satz kommt idealerweise vom Markt-Objekt der CLOB-/Gamma-API (Feldname im Zielland + /// anhand https://docs.polymarket.com/trading/fees verifizieren); bis dahin greift die + /// Kategorie-Fallback-Tabelle. Werte in Basispunkten (bps): 100 bps = 1,0 %. + /// + public static class FeeModel + { + public const int MakerBps = 0; + + // Fallback-Sätze je Kategorie (Taker, bps) – Näherung laut Plan, im Zielland verifizieren. + public const int SportsBps = 75; // 0,75 % + public const int PoliticsFinanceBps = 100; // 1,0 % + public const int CryptoBps = 180; // 1,8 % + public const int GeopoliticsBps = 0; // 0 % + public const int DefaultBps = 100; // konservativer Default + + /// + /// Fallback-Taker-Fee (bps) anhand der Markt-Kategorie, wenn die API keinen Satz liefert. + /// Robust gegen unbekannte/leere Kategorien (→ konservativer Default). + /// + public static int FallbackBps(string? category) + { + string c = (category ?? string.Empty).ToLowerInvariant(); + if (c.Contains("sport")) return SportsBps; + if (c.Contains("crypto")) return CryptoBps; + if (c.Contains("geopolit")) return GeopoliticsBps; + if (c.Contains("politic") || c.Contains("finance") || c.Contains("econom")) return PoliticsFinanceBps; + return DefaultBps; + } + + /// Taker-Fee in USDC für ein Notional (Preis × Shares) bei gegebenem Satz. + public static decimal FeeUsd(decimal notionalUsd, int feeBps) + { + if (feeBps <= 0 || notionalUsd <= 0m) return 0m; + return notionalUsd * feeBps / 10000m; + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index 4ba26be..a5057fd 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -396,6 +396,14 @@ namespace PolyTraderSharp.Services exactShares = exact.shares; exactUsdc = exact.usdc; + + // Phase 0.2: erwartete Taker-Fee transparent loggen (API-Satz bevorzugt, + // sonst Kategorie-Fallback). Wird im Zielland gegen echte API-Fees verifiziert. + int feeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var feeMd) + ? (feeMd.TakerFeeBps > 0 ? feeMd.TakerFeeBps : FeeModel.FallbackBps(feeMd.Category)) + : FeeModel.FallbackBps(null); + decimal expectedFee = FeeModel.FeeUsd(exactUsdc, feeBps); + _logger.TradeReasoning($"💸 [FEE] {account.Name} | {signal.MarketQuestion}: erwartete Taker-Fee ~${expectedFee:F4} ({feeBps} bps auf ${exactUsdc:F2})."); } else if (signal.Side == "SELL") { diff --git a/tests/PolyTrader.Tests/FeeModelTests.cs b/tests/PolyTrader.Tests/FeeModelTests.cs new file mode 100644 index 0000000..4087cd5 --- /dev/null +++ b/tests/PolyTrader.Tests/FeeModelTests.cs @@ -0,0 +1,44 @@ +using PolyTrader.Modules.CopyTrading.Logic; +using Xunit; +using static PolyTrader.Modules.CopyTrading.Logic.FeeModel; + +namespace PolyTrader.Tests +{ + /// + /// Sicherheitsnetz für die Fee-Logik (Phase 0.2): Kategorie-Fallback + Fee-Berechnung. + /// + public class FeeModelTests + { + [Theory] + [InlineData("Sports", 75)] + [InlineData("SPORTS", 75)] // case-insensitive + [InlineData("Crypto", 180)] + [InlineData("Geopolitics", 0)] + [InlineData("Politics", 100)] + [InlineData("Finance", 100)] + [InlineData("Economics", 100)] + [InlineData("Weird-Unknown", 100)] // konservativer Default + [InlineData("", 100)] + [InlineData(null, 100)] + public void FallbackBps_maps_category(string? category, int expected) + { + Assert.Equal(expected, FallbackBps(category)); + } + + [Fact] + public void FeeUsd_is_notional_times_bps() + { + Assert.Equal(1.0m, FeeUsd(100m, 100)); // 1,0 % von 100 + Assert.Equal(0.9m, FeeUsd(50m, 180)); // 1,8 % von 50 + } + + [Theory] + [InlineData(100, 0)] // kein Satz + [InlineData(0, 100)] // kein Notional + [InlineData(-5, 100)] // negatives Notional + public void FeeUsd_zero_cases(double notional, int bps) + { + Assert.Equal(0m, FeeUsd((decimal)notional, bps)); + } + } +}