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