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));
+ }
+ }
+}