MaxBuyPrice-Skala korrigiert + Blockchain-Abo-Logik extrahiert & getestet

- Fix (Richard): MaxBuyPrice-Skalen-Korrektur analog PreRedeemLimit
  (Migration FixMaxBuyPriceScale: Werte > 1 wie 98.0/99.0 -> /100 = 0.98/0.99;
  auf MySQL angewendet). Cap greift bei den betroffenen Accounts jetzt wieder.
  Beschreibung des Feldes um den Skalen-Hinweis ergaenzt.

- Testnetz erweitert: reine Klasse BlockchainSubscription (Logic/) fuer das
  Alchemy-Abo (PadAddress + Filter-Batching in 3er-Chunks, Buy=Topic3/Sell=Topic2
  je Chunk). AlchemyWebsocketService ruft sie jetzt (verhaltensneutral;
  Konstanten + lokale Methoden entfernt). BlockchainSubscriptionTests: 13 Faelle
  (Padding, Chunk-Anzahl 0/1/3/4/6/7, Topic-Zuordnung, CTF-Adresse/Topics).

Gesamt 137 Tests gruen. Build/Smoke gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-07 10:54:46 +02:00
co-authored by Claude Opus 4.8
parent 360f264ed8
commit 6a8ecbc80a
6 changed files with 457 additions and 31 deletions
@@ -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
{
/// <summary>
/// Reine Logik zum Aufbau des Alchemy-Blockchain-Abos für die getrackten Master-Wallets.
/// Aus <c>AlchemyWebsocketService</c> 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.
/// </summary>
public static class BlockchainSubscription
{
/// <summary>Polymarket CTF (ConditionalTokens) Contract Quelle der Transfer-Events.</summary>
public const string CtfContractAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045";
public const string TransferSingleTopic = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
public const string TransferBatchTopic = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce";
/// <summary>Alchemy begrenzt Topic-Arrays auf ~34 Einträge → Batchgröße 3.</summary>
public const int BatchSize = 3;
/// <summary>
/// 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.
/// </summary>
public static string PadAddress(string address)
{
string stripped = address.Replace("0x", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
return "0x" + stripped.PadLeft(64, '0');
}
/// <summary>
/// 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.
/// </summary>
public static BlockchainWssSubscription Build(IEnumerable<TrackedTrader> activeTraders)
{
var subscription = new BlockchainWssSubscription();
var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
var topic0 = new List<string> { 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;
}
}
}
@@ -33,7 +33,9 @@ namespace PolyTraderSharp.Models
[DisplayName("Max. Kaufpreis (01)")] [DisplayName("Max. Kaufpreis (01)")]
[Description("Absolute Preis-Obergrenze pro Share auf der 01-Skala (1.00 = 100 ¢). Kauf wird übersprungen, wenn der " + [Description("Absolute Preis-Obergrenze pro Share auf der 01-Skala (1.00 = 100 ¢). Kauf wird übersprungen, wenn der " +
"Master über diesem Preis kauft; zusätzlich wird unser Limitpreis hierauf gedeckelt. " + "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 01-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 01-Preis).")]
public decimal MaxBuyPrice { get; set; } = 0.98m; public decimal MaxBuyPrice { get; set; } = 0.98m;
[Category("01. Risk Management")] [Category("01. Risk Management")]
@@ -0,0 +1,270 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("TradeId")
.HasColumnType("int");
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("EntryPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("ExitPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("ExitReason")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<DateTime>("OpenedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("PnlPercent")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Size")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("SourceTraderId")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<decimal>("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<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("MaxBuyPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MaxPriceDifference")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MaxSpreadPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MinSellRatioPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PerMarketLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PerMasterLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PreRedeemLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("ProfitTarget")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("SellFloorPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime24h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime6h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime72h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<int>("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<int>("Id")
.HasColumnType("int");
b.Property<string>("AssignedAccountIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("AutoPauseEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsHidden")
.HasColumnType("tinyint(1)");
b.Property<bool>("MakerEntry")
.HasColumnType("tinyint(1)");
b.Property<string>("Reasoning")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<double>("TotalPnl")
.HasColumnType("double");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<string>("WalletAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("WinningTrades")
.HasColumnType("int");
b.Property<double>("Winrate30t")
.HasColumnType("double");
b.HasKey("Id");
b.ToTable("mod_copytrading_traders", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class FixMaxBuyPriceScale : Migration
{
/// <inheritdoc />
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;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -6,16 +6,13 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Streaming; using PolyTrader.Core.Streaming;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTraderSharp.Models; using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services namespace PolyTraderSharp.Services
{ {
public class AlchemyWebsocketService : BackgroundService 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 TradingState _state;
private readonly CopyTradingState _copyState; private readonly CopyTradingState _copyState;
private readonly ServerSettings _settings; private readonly ServerSettings _settings;
@@ -98,7 +95,7 @@ namespace PolyTraderSharp.Services
var activeTraders = _copyState.Traders.Values.Where(t => t.IsActive).ToList(); 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 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) if (subscription.Filters.Count == 0)
{ {
_logger.Info("Keine aktiven Master-Trader. Socket läuft im Standby..."); _logger.Info("Keine aktiven Master-Trader. Socket läuft im Standby...");
@@ -148,24 +145,6 @@ namespace PolyTraderSharp.Services
} }
} }
private BlockchainWssSubscription BuildSubscription(List<TrackedTrader> activeTraders)
{
var subscription = new BlockchainWssSubscription();
var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
var topic0 = new List<string> { 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) private void OnLog(BlockchainLogEvent evt)
{ {
if (evt.Topics.Count < 4) return; if (evt.Topics.Count < 4) return;
@@ -179,7 +158,7 @@ namespace PolyTraderSharp.Services
foreach (var trader in activeTraders) foreach (var trader in activeTraders)
{ {
var padded = PadAddress(trader.WalletAddress); var padded = BlockchainSubscription.PadAddress(trader.WalletAddress);
if (fromTopic == padded || toTopic == padded) if (fromTopic == padded || toTopic == padded)
{ {
triggeredAddress = trader.WalletAddress; triggeredAddress = trader.WalletAddress;
@@ -192,11 +171,5 @@ namespace PolyTraderSharp.Services
_traderMonitor.TriggerFastBlockchainPoll(evt.TransactionHash, _settings.PolygonRpcUrl, triggeredAddress); _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');
}
} }
} }
@@ -0,0 +1,97 @@
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sichert den Aufbau des Alchemy-Blockchain-Abos ab: falsche Filter/Padding bedeuten
/// verpasste (oder falsch zugeordnete) Master-Trades.
/// </summary>
public class BlockchainSubscriptionTests
{
private static List<TrackedTrader> 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);
}
}
}