diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/BlockchainSubscription.cs b/src/PolyTrader.Modules.CopyTrading/Logic/BlockchainSubscription.cs
new file mode 100644
index 0000000..9d1f710
--- /dev/null
+++ b/src/PolyTrader.Modules.CopyTrading/Logic/BlockchainSubscription.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using PolyTrader.Core.Streaming;
+using PolyTraderSharp.Models;
+
+namespace PolyTrader.Modules.CopyTrading.Logic
+{
+ ///
+ /// Reine Logik zum Aufbau des Alchemy-Blockchain-Abos für die getrackten Master-Wallets.
+ /// Aus AlchemyWebsocketService herausgezogen, damit das Filter-Batching und das
+ /// Adress-Padding vollständig testbar sind (falsche Filter = verpasste Master-Trades).
+ /// Verhalten 1:1 aus dem Listener übernommen.
+ ///
+ public static class BlockchainSubscription
+ {
+ /// Polymarket CTF (ConditionalTokens) Contract – Quelle der Transfer-Events.
+ public const string CtfContractAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045";
+ public const string TransferSingleTopic = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
+ public const string TransferBatchTopic = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce";
+
+ /// Alchemy begrenzt Topic-Arrays auf ~3–4 Einträge → Batchgröße 3.
+ public const int BatchSize = 3;
+
+ ///
+ /// Wandelt eine Wallet-Adresse in die 32-Byte-Topic-Form (lowercase, ohne 0x, links mit
+ /// Nullen auf 64 Hex-Zeichen aufgefüllt, mit 0x-Präfix). Nötig, weil Log-Topics die
+ /// Adresse in voller Wortbreite enthalten.
+ ///
+ public static string PadAddress(string address)
+ {
+ string stripped = address.Replace("0x", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
+ return "0x" + stripped.PadLeft(64, '0');
+ }
+
+ ///
+ /// Baut das Abo: pro 3er-Chunk der gepaddeten Master-Adressen je EIN Filter für Käufe
+ /// (Master ist Empfänger → Topic3) und EIN Filter für Verkäufe (Master ist Sender → Topic2),
+ /// beide auf dem CTF-Contract mit den Transfer-Topics.
+ ///
+ public static BlockchainWssSubscription Build(IEnumerable activeTraders)
+ {
+ var subscription = new BlockchainWssSubscription();
+ var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
+ var topic0 = new List { TransferSingleTopic, TransferBatchTopic };
+
+ for (int i = 0; i < paddedAddresses.Count; i += BatchSize)
+ {
+ var chunk = paddedAddresses.Skip(i).Take(BatchSize).ToList();
+ // Buys: Master-Trader ist Empfänger (Topic 3)
+ subscription.Filters.Add(new LogSubscriptionFilter { Address = CtfContractAddress, Topic0 = topic0, Topic3 = chunk });
+ // Sells: Master-Trader ist Sender (Topic 2)
+ subscription.Filters.Add(new LogSubscriptionFilter { Address = CtfContractAddress, Topic0 = topic0, Topic2 = chunk });
+ }
+ return subscription;
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs b/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs
index a00af37..734ed99 100644
--- a/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs
@@ -33,7 +33,9 @@ namespace PolyTraderSharp.Models
[DisplayName("Max. Kaufpreis (0–1)")]
[Description("Absolute Preis-Obergrenze pro Share auf der 0–1-Skala (1.00 = 100 ¢). Kauf wird übersprungen, wenn der " +
"Master über diesem Preis kauft; zusätzlich wird unser Limitpreis hierauf gedeckelt. " +
- "Beispiel: 0.98 = nichts über 98 ¢ kaufen (nahe 1.00 ist das Aufwärtspotenzial gering, das Risiko asymmetrisch).")]
+ "Beispiel: 0.98 = nichts über 98 ¢ kaufen (nahe 1.00 ist das Aufwärtspotenzial gering, das Risiko asymmetrisch). " +
+ "WICHTIG: Wert IMMER auf der 0–1-Skala angeben. Migrierte Alt-Werte auf Cent-Skala (z. B. 98.0) wurden auf " +
+ "0.98 korrigiert – ein Wert > 1 hätte den Deckel nie greifen lassen (Vergleich erfolgt gegen den 0–1-Preis).")]
public decimal MaxBuyPrice { get; set; } = 0.98m;
[Category("01. Risk Management")]
diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707084602_FixMaxBuyPriceScale.Designer.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707084602_FixMaxBuyPriceScale.Designer.cs
new file mode 100644
index 0000000..066b4eb
--- /dev/null
+++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707084602_FixMaxBuyPriceScale.Designer.cs
@@ -0,0 +1,270 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using PolyTrader.Modules.CopyTrading.Persistence.Ef;
+
+#nullable disable
+
+namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations
+{
+ [DbContext(typeof(CopyTradingDbContext))]
+ [Migration("20260707084602_FixMaxBuyPriceScale")]
+ partial class FixMaxBuyPriceScale
+ {
+ ///
+ 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.ClosedTrade", b =>
+ {
+ b.Property("TradeId")
+ .HasColumnType("int");
+
+ 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("MarketSlug")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ 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("SourceTraderId")
+ .HasColumnType("int");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("TotalFees")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.HasKey("TradeId");
+
+ b.HasIndex("AccountId");
+
+ b.HasIndex("SourceTraderId");
+
+ b.HasIndex("TokenId");
+
+ b.ToTable("mod_copytrading_closed_trades", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTraderSharp.Models.CopyTradingAccountSettings", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("MaxBuyPrice")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxPriceDifference")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxSpreadPct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MinSellRatioPct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("PerMarketLimit")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("PerMasterLimit")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("PreRedeemLimit")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("ProfitTarget")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("SellFloorPct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("perMaxTime24h")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("perMaxTime6h")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("perMaxTime72h")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("perMaxTimeNone")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.HasKey("AccountId");
+
+ b.ToTable("mod_copytrading_account_settings", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTraderSharp.Models.MasterTraderHistoryRecord", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RealizedPnl")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("TraderId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClosedAt");
+
+ b.HasIndex("TraderId");
+
+ b.ToTable("mod_copytrading_mt_history", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTraderSharp.Models.TrackedTrader", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("int");
+
+ b.Property("AssignedAccountIds")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("AutoPauseEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("IsActive")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsHidden")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MakerEntry")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Reasoning")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("TotalPnl")
+ .HasColumnType("double");
+
+ b.Property("TotalTrades")
+ .HasColumnType("int");
+
+ b.Property("WalletAddress")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("WinningTrades")
+ .HasColumnType("int");
+
+ b.Property("Winrate30t")
+ .HasColumnType("double");
+
+ b.HasKey("Id");
+
+ b.ToTable("mod_copytrading_traders", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707084602_FixMaxBuyPriceScale.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707084602_FixMaxBuyPriceScale.cs
new file mode 100644
index 0000000..1b769d2
--- /dev/null
+++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707084602_FixMaxBuyPriceScale.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations
+{
+ ///
+ public partial class FixMaxBuyPriceScale : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // Skalen-Korrektur analog PreRedeemLimit: MaxBuyPrice wird gegen den 0-1-Preis
+ // verglichen/gedeckelt. Migrierte Cent-Skala-Werte (z. B. 98.0, 99.0) haetten den
+ // Deckel nie greifen lassen -> Werte > 1 durch 100 teilen (98.0 -> 0.98).
+ migrationBuilder.Sql(
+ "UPDATE mod_copytrading_account_settings SET MaxBuyPrice = MaxBuyPrice / 100 WHERE MaxBuyPrice > 1;");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.CopyTrading/Services/AlchemyWebsocketService.cs b/src/PolyTrader.Modules.CopyTrading/Services/AlchemyWebsocketService.cs
index 25114e2..8dbd4fc 100644
--- a/src/PolyTrader.Modules.CopyTrading/Services/AlchemyWebsocketService.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Services/AlchemyWebsocketService.cs
@@ -6,16 +6,13 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Streaming;
+using PolyTrader.Modules.CopyTrading.Logic;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class AlchemyWebsocketService : BackgroundService
{
- private const string CtfContractAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045";
- private const string TransferSingleTopic = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
- private const string TransferBatchTopic = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce";
-
private readonly TradingState _state;
private readonly CopyTradingState _copyState;
private readonly ServerSettings _settings;
@@ -98,7 +95,7 @@ namespace PolyTraderSharp.Services
var activeTraders = _copyState.Traders.Values.Where(t => t.IsActive).ToList();
var activeStateHash = string.Join(",", activeTraders.OrderBy(t => t.Id).Select(t => t.WalletAddress.ToLowerInvariant()));
- var subscription = BuildSubscription(activeTraders);
+ var subscription = BlockchainSubscription.Build(activeTraders);
if (subscription.Filters.Count == 0)
{
_logger.Info("Keine aktiven Master-Trader. Socket läuft im Standby...");
@@ -148,24 +145,6 @@ namespace PolyTraderSharp.Services
}
}
- private BlockchainWssSubscription BuildSubscription(List activeTraders)
- {
- var subscription = new BlockchainWssSubscription();
- var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
- var topic0 = new List { TransferSingleTopic, TransferBatchTopic };
-
- const int batchSize = 3; // Alchemy begrenzt Topic-Arrays auf max. 3-4 Einträge
- for (int i = 0; i < paddedAddresses.Count; i += batchSize)
- {
- var chunk = paddedAddresses.Skip(i).Take(batchSize).ToList();
- // Buys: Master-Trader ist Empfänger (Topic 3)
- subscription.Filters.Add(new LogSubscriptionFilter { Address = CtfContractAddress, Topic0 = topic0, Topic3 = chunk });
- // Sells: Master-Trader ist Sender (Topic 2)
- subscription.Filters.Add(new LogSubscriptionFilter { Address = CtfContractAddress, Topic0 = topic0, Topic2 = chunk });
- }
- return subscription;
- }
-
private void OnLog(BlockchainLogEvent evt)
{
if (evt.Topics.Count < 4) return;
@@ -179,7 +158,7 @@ namespace PolyTraderSharp.Services
foreach (var trader in activeTraders)
{
- var padded = PadAddress(trader.WalletAddress);
+ var padded = BlockchainSubscription.PadAddress(trader.WalletAddress);
if (fromTopic == padded || toTopic == padded)
{
triggeredAddress = trader.WalletAddress;
@@ -192,11 +171,5 @@ namespace PolyTraderSharp.Services
_traderMonitor.TriggerFastBlockchainPoll(evt.TransactionHash, _settings.PolygonRpcUrl, triggeredAddress);
}
}
-
- private string PadAddress(string address)
- {
- string stripped = address.Replace("0x", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
- return "0x" + stripped.PadLeft(64, '0');
- }
}
}
diff --git a/tests/PolyTrader.Tests/BlockchainSubscriptionTests.cs b/tests/PolyTrader.Tests/BlockchainSubscriptionTests.cs
new file mode 100644
index 0000000..ca38e07
--- /dev/null
+++ b/tests/PolyTrader.Tests/BlockchainSubscriptionTests.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using System.Linq;
+using PolyTrader.Modules.CopyTrading.Logic;
+using PolyTraderSharp.Models;
+using Xunit;
+
+namespace PolyTrader.Tests
+{
+ ///
+ /// Sichert den Aufbau des Alchemy-Blockchain-Abos ab: falsche Filter/Padding bedeuten
+ /// verpasste (oder falsch zugeordnete) Master-Trades.
+ ///
+ public class BlockchainSubscriptionTests
+ {
+ private static List Traders(int n) =>
+ Enumerable.Range(1, n).Select(i => new TrackedTrader { Id = i, WalletAddress = $"0x{i:x40}" }).ToList();
+
+ // ---------------- PadAddress ----------------
+
+ [Fact]
+ public void PadAddress_strips_prefix_lowercases_and_pads_to_32_bytes()
+ {
+ var r = BlockchainSubscription.PadAddress("0x628914CF1e96A9D1Ab8F0489A9f64be5633bac41");
+ Assert.Equal("0x000000000000000000000000628914cf1e96a9d1ab8f0489a9f64be5633bac41", r);
+ Assert.Equal(66, r.Length); // "0x" + 64 Hex-Zeichen
+ }
+
+ [Fact]
+ public void PadAddress_pads_short_input_with_leading_zeros()
+ {
+ Assert.Equal("0x" + new string('0', 61) + "abc", BlockchainSubscription.PadAddress("0xABC"));
+ }
+
+ // ---------------- Build: Chunk-Anzahl ----------------
+
+ [Theory]
+ [InlineData(0, 0)]
+ [InlineData(1, 2)] // 1 Chunk -> Buy + Sell
+ [InlineData(3, 2)] // 1 Chunk (voll)
+ [InlineData(4, 4)] // 2 Chunks (3 + 1)
+ [InlineData(6, 4)] // 2 Chunks (3 + 3)
+ [InlineData(7, 6)] // 3 Chunks (3 + 3 + 1)
+ public void Build_creates_two_filters_per_chunk(int traderCount, int expectedFilters)
+ {
+ Assert.Equal(expectedFilters, BlockchainSubscription.Build(Traders(traderCount)).Filters.Count);
+ }
+
+ // ---------------- Build: Filter-Inhalt ----------------
+
+ [Fact]
+ public void Build_pairs_buy_topic3_and_sell_topic2()
+ {
+ var sub = BlockchainSubscription.Build(Traders(1));
+ var buy = sub.Filters[0];
+ var sell = sub.Filters[1];
+
+ Assert.NotNull(buy.Topic3);
+ Assert.Null(buy.Topic2);
+ Assert.NotNull(sell.Topic2);
+ Assert.Null(sell.Topic3);
+ }
+
+ [Fact]
+ public void Build_sets_ctf_address_and_transfer_topics()
+ {
+ var buy = BlockchainSubscription.Build(Traders(1)).Filters[0];
+ Assert.Equal(BlockchainSubscription.CtfContractAddress, buy.Address);
+ Assert.Equal(
+ new[] { BlockchainSubscription.TransferSingleTopic, BlockchainSubscription.TransferBatchTopic },
+ buy.Topic0.ToArray());
+ }
+
+ [Fact]
+ public void Build_chunks_addresses_by_three()
+ {
+ var sub = BlockchainSubscription.Build(Traders(4)); // 3 + 1
+ Assert.Equal(3, sub.Filters[0].Topic3!.Count); // Chunk 1 (Buy)
+ Assert.Equal(3, sub.Filters[1].Topic2!.Count); // Chunk 1 (Sell)
+ Assert.Single(sub.Filters[2].Topic3!); // Chunk 2 (Buy)
+ Assert.Single(sub.Filters[3].Topic2!); // Chunk 2 (Sell)
+ }
+
+ [Fact]
+ public void Build_uses_padded_addresses()
+ {
+ var addr = BlockchainSubscription.Build(Traders(1)).Filters[0].Topic3![0];
+ Assert.Equal(66, addr.Length);
+ Assert.StartsWith("0x", addr);
+ }
+
+ [Fact]
+ public void Build_empty_returns_no_filters()
+ {
+ Assert.Empty(BlockchainSubscription.Build(Traders(0)).Filters);
+ }
+ }
+}