diff --git a/src/PolyTrader.Modules.ResolutionFarming/Migrations/20260709200552_InitialResolutionFarming.Designer.cs b/src/PolyTrader.Modules.ResolutionFarming/Migrations/20260709200552_InitialResolutionFarming.Designer.cs
new file mode 100644
index 0000000..af292b7
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Migrations/20260709200552_InitialResolutionFarming.Designer.cs
@@ -0,0 +1,338 @@
+//
+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.ResolutionFarming.Persistence.Ef;
+
+#nullable disable
+
+namespace PolyTrader.Modules.ResolutionFarming.Migrations
+{
+ [DbContext(typeof(ResolutionFarmingDbContext))]
+ [Migration("20260709200552_InitialResolutionFarming")]
+ partial class InitialResolutionFarming
+ {
+ ///
+ 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("PolyTrader.Modules.ResolutionFarming.Models.RfCandidate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Accepted")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("Ask")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ClusterKey")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EventSlug")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("FeeBps")
+ .HasColumnType("int");
+
+ b.Property("MarketQuestion")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("MarketSlug")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("NetEdgePct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Outcome")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RejectReason")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("ScannedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Score")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScannedAt");
+
+ b.HasIndex("AccountId", "Accepted");
+
+ b.ToTable("rf_candidates", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTrader.Modules.ResolutionFarming.Models.RfClosedTrade", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ClusterKey")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ 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("RedeemStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Size")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("TotalFees")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClosedAt");
+
+ b.HasIndex("AccountId", "TokenId");
+
+ b.ToTable("rf_closed_trades", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTrader.Modules.ResolutionFarming.Models.RfPosition", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("TokenId")
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("AmountUsd")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ClusterKey")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EntryFeeBps")
+ .HasColumnType("int");
+
+ b.Property("EntryPrice")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ 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("Size")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("AccountId", "TokenId");
+
+ b.ToTable("rf_positions", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTrader.Modules.ResolutionFarming.Models.RfSettings", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("AllowTakerFallback")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("BlacklistCsv")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("CategoryWhitelistCsv")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("DailyLossKillSwitchUsd")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MakerFillTimeoutMinutes")
+ .HasColumnType("int");
+
+ b.Property("MaxHoursToResolution")
+ .HasColumnType("int");
+
+ b.Property("MaxNewPositionsPerDay")
+ .HasColumnType("int");
+
+ b.Property("MaxPerClusterPct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxPerMarketUsd")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxPrice")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxTotalExposurePct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MinEdgePct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MinPrice")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.HasKey("AccountId");
+
+ b.ToTable("rf_settings", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Migrations/20260709200552_InitialResolutionFarming.cs b/src/PolyTrader.Modules.ResolutionFarming/Migrations/20260709200552_InitialResolutionFarming.cs
new file mode 100644
index 0000000..6f5996e
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Migrations/20260709200552_InitialResolutionFarming.cs
@@ -0,0 +1,192 @@
+using System;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PolyTrader.Modules.ResolutionFarming.Migrations
+{
+ ///
+ public partial class InitialResolutionFarming : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "rf_candidates",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
+ ScannedAt = table.Column(type: "datetime(6)", nullable: false),
+ AccountId = table.Column(type: "int", nullable: false),
+ TokenId = table.Column(type: "varchar(120)", maxLength: 120, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MarketSlug = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ EventSlug = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MarketQuestion = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Outcome = table.Column(type: "varchar(200)", maxLength: 200, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Category = table.Column(type: "varchar(100)", maxLength: 100, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ ClusterKey = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Ask = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ FeeBps = table.Column(type: "int", nullable: false),
+ NetEdgePct = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ Score = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ EndDate = table.Column(type: "datetime(6)", nullable: true),
+ Accepted = table.Column(type: "tinyint(1)", nullable: false),
+ RejectReason = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_rf_candidates", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "rf_closed_trades",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
+ AccountId = table.Column(type: "int", nullable: false),
+ IsDemo = table.Column(type: "tinyint(1)", nullable: false),
+ TokenId = table.Column(type: "varchar(120)", maxLength: 120, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MarketSlug = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MarketQuestion = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Outcome = table.Column(type: "varchar(200)", maxLength: 200, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Category = table.Column(type: "varchar(100)", maxLength: 100, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ ClusterKey = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ EntryPrice = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ ExitPrice = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ Size = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ RealizedPnl = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ PnlPercent = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ TotalFees = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ OpenedAt = table.Column(type: "datetime(6)", nullable: false),
+ ClosedAt = table.Column(type: "datetime(6)", nullable: false),
+ ExitReason = table.Column(type: "varchar(200)", maxLength: 200, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ RedeemStatus = table.Column(type: "varchar(20)", maxLength: 20, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_rf_closed_trades", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "rf_positions",
+ columns: table => new
+ {
+ AccountId = table.Column(type: "int", nullable: false),
+ TokenId = table.Column(type: "varchar(120)", maxLength: 120, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MarketSlug = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MarketQuestion = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Outcome = table.Column(type: "varchar(200)", maxLength: 200, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Category = table.Column(type: "varchar(100)", maxLength: 100, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ ClusterKey = table.Column(type: "varchar(300)", maxLength: 300, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ EntryPrice = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ Size = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ AmountUsd = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ EntryFeeBps = table.Column(type: "int", nullable: false),
+ IsDemo = table.Column(type: "tinyint(1)", nullable: false),
+ OpenedAt = table.Column(type: "datetime(6)", nullable: false),
+ EndDate = table.Column(type: "datetime(6)", nullable: true),
+ Status = table.Column(type: "varchar(20)", maxLength: 20, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_rf_positions", x => new { x.AccountId, x.TokenId });
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "rf_settings",
+ columns: table => new
+ {
+ AccountId = table.Column(type: "int", nullable: false),
+ Enabled = table.Column(type: "tinyint(1)", nullable: false),
+ MinPrice = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ MaxPrice = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ MaxHoursToResolution = table.Column(type: "int", nullable: false),
+ CategoryWhitelistCsv = table.Column(type: "varchar(500)", maxLength: 500, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ BlacklistCsv = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MinEdgePct = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ MaxPerMarketUsd = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ MaxPerClusterPct = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ MaxTotalExposurePct = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ MaxNewPositionsPerDay = table.Column(type: "int", nullable: false),
+ DailyLossKillSwitchUsd = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
+ MakerFillTimeoutMinutes = table.Column(type: "int", nullable: false),
+ AllowTakerFallback = table.Column(type: "tinyint(1)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_rf_settings", x => x.AccountId);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_rf_candidates_AccountId_Accepted",
+ table: "rf_candidates",
+ columns: new[] { "AccountId", "Accepted" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_rf_candidates_ScannedAt",
+ table: "rf_candidates",
+ column: "ScannedAt");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_rf_closed_trades_AccountId_TokenId",
+ table: "rf_closed_trades",
+ columns: new[] { "AccountId", "TokenId" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_rf_closed_trades_ClosedAt",
+ table: "rf_closed_trades",
+ column: "ClosedAt");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "rf_candidates");
+
+ migrationBuilder.DropTable(
+ name: "rf_closed_trades");
+
+ migrationBuilder.DropTable(
+ name: "rf_positions");
+
+ migrationBuilder.DropTable(
+ name: "rf_settings");
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Migrations/ResolutionFarmingDbContextModelSnapshot.cs b/src/PolyTrader.Modules.ResolutionFarming/Migrations/ResolutionFarmingDbContextModelSnapshot.cs
new file mode 100644
index 0000000..ba2f2a0
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Migrations/ResolutionFarmingDbContextModelSnapshot.cs
@@ -0,0 +1,335 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
+
+#nullable disable
+
+namespace PolyTrader.Modules.ResolutionFarming.Migrations
+{
+ [DbContext(typeof(ResolutionFarmingDbContext))]
+ partial class ResolutionFarmingDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("PolyTrader.Modules.ResolutionFarming.Models.RfCandidate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Accepted")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("Ask")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ClusterKey")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EventSlug")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("FeeBps")
+ .HasColumnType("int");
+
+ b.Property("MarketQuestion")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("MarketSlug")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("NetEdgePct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Outcome")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RejectReason")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("ScannedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Score")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScannedAt");
+
+ b.HasIndex("AccountId", "Accepted");
+
+ b.ToTable("rf_candidates", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTrader.Modules.ResolutionFarming.Models.RfClosedTrade", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ClusterKey")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ 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("RedeemStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Size")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("TotalFees")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClosedAt");
+
+ b.HasIndex("AccountId", "TokenId");
+
+ b.ToTable("rf_closed_trades", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTrader.Modules.ResolutionFarming.Models.RfPosition", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("TokenId")
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("AmountUsd")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ClusterKey")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EntryFeeBps")
+ .HasColumnType("int");
+
+ b.Property("EntryPrice")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ 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("Size")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("AccountId", "TokenId");
+
+ b.ToTable("rf_positions", (string)null);
+ });
+
+ modelBuilder.Entity("PolyTrader.Modules.ResolutionFarming.Models.RfSettings", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("AllowTakerFallback")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("BlacklistCsv")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("CategoryWhitelistCsv")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("DailyLossKillSwitchUsd")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MakerFillTimeoutMinutes")
+ .HasColumnType("int");
+
+ b.Property("MaxHoursToResolution")
+ .HasColumnType("int");
+
+ b.Property("MaxNewPositionsPerDay")
+ .HasColumnType("int");
+
+ b.Property("MaxPerClusterPct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxPerMarketUsd")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxPrice")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MaxTotalExposurePct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MinEdgePct")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("MinPrice")
+ .HasPrecision(18, 6)
+ .HasColumnType("decimal(18,6)");
+
+ b.HasKey("AccountId");
+
+ b.ToTable("rf_settings", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Models/RfCandidate.cs b/src/PolyTrader.Modules.ResolutionFarming/Models/RfCandidate.cs
new file mode 100644
index 0000000..6ae8d18
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Models/RfCandidate.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace PolyTrader.Modules.ResolutionFarming.Models
+{
+ ///
+ /// Ein Scanner-Ergebnis (Tabelle rf_candidates) – auch abgelehnte Kandidaten werden mit Grund
+ /// gespeichert, damit die Filterkette später datenbasiert kalibriert werden kann (Phase RF-5).
+ ///
+ public class RfCandidate
+ {
+ public long Id { get; set; } // DB-Autoincrement
+ public DateTime ScannedAt { get; set; }
+ public int AccountId { get; set; }
+
+ public string TokenId { get; set; } = string.Empty;
+ public string MarketSlug { get; set; } = string.Empty;
+ public string EventSlug { get; set; } = string.Empty;
+ public string MarketQuestion { get; set; } = string.Empty;
+ public string Outcome { get; set; } = string.Empty;
+ public string Category { get; set; } = string.Empty;
+ public string ClusterKey { get; set; } = string.Empty;
+
+ public decimal Ask { get; set; }
+ public int FeeBps { get; set; }
+ public decimal NetEdgePct { get; set; }
+ public decimal Score { get; set; }
+ public DateTime? EndDate { get; set; }
+
+ public bool Accepted { get; set; }
+ public string RejectReason { get; set; } = string.Empty;
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Models/RfClosedTrade.cs b/src/PolyTrader.Modules.ResolutionFarming/Models/RfClosedTrade.cs
new file mode 100644
index 0000000..9313960
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Models/RfClosedTrade.cs
@@ -0,0 +1,36 @@
+using System;
+
+namespace PolyTrader.Modules.ResolutionFarming.Models
+{
+ ///
+ /// Abgeschlossener Farming-Trade (Tabelle rf_closed_trades) inkl. Fees und Redeem-Status.
+ /// Das Modul schreibt zusätzlich einen generischen Core-Trade-Log-Eintrag (Dashboard).
+ ///
+ public class RfClosedTrade
+ {
+ public long Id { get; set; } // DB-Autoincrement
+ public int AccountId { get; set; }
+ public bool IsDemo { get; set; }
+
+ public string TokenId { get; set; } = string.Empty;
+ public string MarketSlug { get; set; } = string.Empty;
+ public string MarketQuestion { get; set; } = string.Empty;
+ public string Outcome { get; set; } = string.Empty;
+ public string Category { get; set; } = string.Empty;
+ public string ClusterKey { get; set; } = string.Empty;
+
+ public decimal EntryPrice { get; set; }
+ public decimal ExitPrice { get; set; }
+ public decimal Size { get; set; }
+ public decimal RealizedPnl { get; set; }
+ public decimal PnlPercent { get; set; }
+ public decimal TotalFees { get; set; }
+
+ public DateTime OpenedAt { get; set; }
+ public DateTime ClosedAt { get; set; }
+ public string ExitReason { get; set; } = string.Empty;
+
+ /// "None", "Pending", "Redeemed", "Sold" (PreRedeem-Verkauf statt On-Chain-Redeem).
+ public string RedeemStatus { get; set; } = "None";
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Models/RfPosition.cs b/src/PolyTrader.Modules.ResolutionFarming/Models/RfPosition.cs
new file mode 100644
index 0000000..de44b39
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Models/RfPosition.cs
@@ -0,0 +1,33 @@
+using System;
+
+namespace PolyTrader.Modules.ResolutionFarming.Models
+{
+ ///
+ /// Offene Farming-Position (Tabelle rf_positions). Zusammengesetzter Schlüssel (AccountId, TokenId):
+ /// eine offene Position je Markt/Account. Beim Schließen wird die Zeile gelöscht und ein
+ /// geschrieben.
+ ///
+ public class RfPosition
+ {
+ public int AccountId { get; set; }
+ public string TokenId { get; set; } = string.Empty;
+
+ public string MarketSlug { get; set; } = string.Empty;
+ public string MarketQuestion { get; set; } = string.Empty;
+ public string Outcome { get; set; } = string.Empty;
+ public string Category { get; set; } = string.Empty;
+ public string ClusterKey { get; set; } = string.Empty;
+
+ public decimal EntryPrice { get; set; }
+ public decimal Size { get; set; } // Shares
+ public decimal AmountUsd { get; set; } // Einstiegskosten inkl. Fee
+ public int EntryFeeBps { get; set; }
+ public bool IsDemo { get; set; }
+
+ public DateTime OpenedAt { get; set; }
+ public DateTime? EndDate { get; set; }
+
+ /// "Pending" (Order platziert, nicht gefüllt), "Open" (gefüllt), "Resolving".
+ public string Status { get; set; } = "Pending";
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/EfRfRepositories.cs b/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/EfRfRepositories.cs
new file mode 100644
index 0000000..0799818
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/EfRfRepositories.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using Microsoft.EntityFrameworkCore;
+using PolyTrader.Modules.ResolutionFarming.Models;
+
+namespace PolyTrader.Modules.ResolutionFarming.Persistence.Ef
+{
+ public class EfRfSettingsRepository : IRfSettingsRepository
+ {
+ private readonly IDbContextFactory _factory;
+ public EfRfSettingsRepository(IDbContextFactory factory) => _factory = factory;
+
+ public List GetAll()
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.Settings.AsNoTracking().ToList();
+ }
+
+ public RfSettings? Get(int accountId)
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.Settings.AsNoTracking().FirstOrDefault(s => s.AccountId == accountId);
+ }
+
+ public void Upsert(RfSettings settings)
+ {
+ using var ctx = _factory.CreateDbContext();
+ bool exists = ctx.Settings.Any(s => s.AccountId == settings.AccountId);
+ if (exists) ctx.Settings.Update(settings);
+ else ctx.Settings.Add(settings);
+ ctx.SaveChanges();
+ }
+ }
+
+ public class EfRfCandidateRepository : IRfCandidateRepository
+ {
+ private readonly IDbContextFactory _factory;
+ public EfRfCandidateRepository(IDbContextFactory factory) => _factory = factory;
+
+ public void Insert(RfCandidate candidate)
+ {
+ using var ctx = _factory.CreateDbContext();
+ ctx.Candidates.Add(candidate);
+ ctx.SaveChanges();
+ }
+
+ public List GetRecent(int accountId, int limit)
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.Candidates.AsNoTracking()
+ .Where(c => c.AccountId == accountId)
+ .OrderByDescending(c => c.ScannedAt)
+ .Take(limit)
+ .ToList();
+ }
+ }
+
+ public class EfRfPositionRepository : IRfPositionRepository
+ {
+ private readonly IDbContextFactory _factory;
+ public EfRfPositionRepository(IDbContextFactory factory) => _factory = factory;
+
+ public List GetOpen(int accountId)
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.Positions.AsNoTracking().Where(p => p.AccountId == accountId).ToList();
+ }
+
+ public List GetAllOpen()
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.Positions.AsNoTracking().ToList();
+ }
+
+ public RfPosition? Find(int accountId, string tokenId)
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.Positions.AsNoTracking().FirstOrDefault(p => p.AccountId == accountId && p.TokenId == tokenId);
+ }
+
+ public void Upsert(RfPosition position)
+ {
+ using var ctx = _factory.CreateDbContext();
+ bool exists = ctx.Positions.Any(p => p.AccountId == position.AccountId && p.TokenId == position.TokenId);
+ if (exists) ctx.Positions.Update(position);
+ else ctx.Positions.Add(position);
+ ctx.SaveChanges();
+ }
+
+ public void Delete(int accountId, string tokenId)
+ {
+ using var ctx = _factory.CreateDbContext();
+ var row = ctx.Positions.FirstOrDefault(p => p.AccountId == accountId && p.TokenId == tokenId);
+ if (row != null) { ctx.Positions.Remove(row); ctx.SaveChanges(); }
+ }
+
+ public int CountOpenedSince(int accountId, DateTime since)
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.Positions.AsNoTracking().Count(p => p.AccountId == accountId && p.OpenedAt >= since);
+ }
+ }
+
+ public class EfRfClosedTradeRepository : IRfClosedTradeRepository
+ {
+ private readonly IDbContextFactory _factory;
+ public EfRfClosedTradeRepository(IDbContextFactory factory) => _factory = factory;
+
+ public void Insert(RfClosedTrade trade)
+ {
+ using var ctx = _factory.CreateDbContext();
+ ctx.ClosedTrades.Add(trade);
+ ctx.SaveChanges();
+ }
+
+ public List Find(Expression> predicate)
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.ClosedTrades.AsNoTracking().Where(predicate).ToList();
+ }
+
+ public decimal RealizedPnlSince(int accountId, DateTime since)
+ {
+ using var ctx = _factory.CreateDbContext();
+ return ctx.ClosedTrades.AsNoTracking()
+ .Where(t => t.AccountId == accountId && t.ClosedAt >= since)
+ .Select(t => (decimal?)t.RealizedPnl)
+ .Sum() ?? 0m;
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/ResolutionFarmingDbContext.cs b/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/ResolutionFarmingDbContext.cs
new file mode 100644
index 0000000..411a496
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/ResolutionFarmingDbContext.cs
@@ -0,0 +1,98 @@
+using Microsoft.EntityFrameworkCore;
+using PolyTrader.Modules.ResolutionFarming.Models;
+
+namespace PolyTrader.Modules.ResolutionFarming.Persistence.Ef
+{
+ ///
+ /// EF-Core-Kontext des ResolutionFarming-Moduls (gleiche MySQL-DB wie der Core, eigene Tabellen
+ /// mit Präfix rf_). Autoincrement-PKs von Anfang an (keine code-vergebenen Schlüssel – vermeidet
+ /// die im Copytrading nachträglich aufgefallene TradeId-Kollisionsklasse).
+ ///
+ public class ResolutionFarmingDbContext : DbContext
+ {
+ public ResolutionFarmingDbContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Settings => Set();
+ public DbSet Candidates => Set();
+ public DbSet Positions => Set();
+ public DbSet ClosedTrades => Set();
+
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.ToTable("rf_settings");
+ e.HasKey(x => x.AccountId);
+ e.Property(x => x.AccountId).ValueGeneratedNever();
+ e.Property(x => x.MinPrice).HasPrecision(18, 6);
+ e.Property(x => x.MaxPrice).HasPrecision(18, 6);
+ e.Property(x => x.MinEdgePct).HasPrecision(18, 6);
+ e.Property(x => x.MaxPerMarketUsd).HasPrecision(18, 6);
+ e.Property(x => x.MaxPerClusterPct).HasPrecision(18, 6);
+ e.Property(x => x.MaxTotalExposurePct).HasPrecision(18, 6);
+ e.Property(x => x.DailyLossKillSwitchUsd).HasPrecision(18, 6);
+ e.Property(x => x.CategoryWhitelistCsv).HasMaxLength(500);
+ e.Property(x => x.BlacklistCsv).HasMaxLength(1000);
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("rf_candidates");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Id).ValueGeneratedOnAdd();
+ e.Property(x => x.TokenId).HasMaxLength(120);
+ e.Property(x => x.MarketSlug).HasMaxLength(300);
+ e.Property(x => x.EventSlug).HasMaxLength(300);
+ e.Property(x => x.MarketQuestion).HasMaxLength(1000);
+ e.Property(x => x.Outcome).HasMaxLength(200);
+ e.Property(x => x.Category).HasMaxLength(100);
+ e.Property(x => x.ClusterKey).HasMaxLength(300);
+ e.Property(x => x.RejectReason).HasMaxLength(300);
+ e.Property(x => x.Ask).HasPrecision(18, 6);
+ e.Property(x => x.NetEdgePct).HasPrecision(18, 6);
+ e.Property(x => x.Score).HasPrecision(18, 6);
+ e.HasIndex(x => x.ScannedAt);
+ e.HasIndex(x => new { x.AccountId, x.Accepted });
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("rf_positions");
+ e.HasKey(x => new { x.AccountId, x.TokenId });
+ e.Property(x => x.TokenId).HasMaxLength(120);
+ e.Property(x => x.MarketSlug).HasMaxLength(300);
+ e.Property(x => x.MarketQuestion).HasMaxLength(1000);
+ e.Property(x => x.Outcome).HasMaxLength(200);
+ e.Property(x => x.Category).HasMaxLength(100);
+ e.Property(x => x.ClusterKey).HasMaxLength(300);
+ e.Property(x => x.Status).HasMaxLength(20);
+ e.Property(x => x.EntryPrice).HasPrecision(18, 6);
+ e.Property(x => x.Size).HasPrecision(18, 6);
+ e.Property(x => x.AmountUsd).HasPrecision(18, 6);
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("rf_closed_trades");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Id).ValueGeneratedOnAdd();
+ e.Property(x => x.TokenId).HasMaxLength(120);
+ e.Property(x => x.MarketSlug).HasMaxLength(300);
+ e.Property(x => x.MarketQuestion).HasMaxLength(1000);
+ e.Property(x => x.Outcome).HasMaxLength(200);
+ e.Property(x => x.Category).HasMaxLength(100);
+ e.Property(x => x.ClusterKey).HasMaxLength(300);
+ e.Property(x => x.ExitReason).HasMaxLength(200);
+ e.Property(x => x.RedeemStatus).HasMaxLength(20);
+ e.Property(x => x.EntryPrice).HasPrecision(18, 6);
+ e.Property(x => x.ExitPrice).HasPrecision(18, 6);
+ e.Property(x => x.Size).HasPrecision(18, 6);
+ e.Property(x => x.RealizedPnl).HasPrecision(18, 6);
+ e.Property(x => x.PnlPercent).HasPrecision(18, 6);
+ e.Property(x => x.TotalFees).HasPrecision(18, 6);
+ e.HasIndex(x => new { x.AccountId, x.TokenId });
+ e.HasIndex(x => x.ClosedAt);
+ });
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/ResolutionFarmingDbContextFactory.cs b/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/ResolutionFarmingDbContextFactory.cs
new file mode 100644
index 0000000..52c454b
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Persistence/Ef/ResolutionFarmingDbContextFactory.cs
@@ -0,0 +1,29 @@
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+using PolyTrader.Core.Configuration;
+
+namespace PolyTrader.Modules.ResolutionFarming.Persistence.Ef
+{
+ ///
+ /// Design-Time-Factory für EF-Tooling. Nutzt die fest gepinnte Server-Version
+ /// () statt ServerVersion.AutoDetect, damit
+ /// Migrations-Scaffolding OHNE DB-Verbindung funktioniert (kein Zugriff auf die produktive DB
+ /// beim Generieren). Connection-String rein nominell über POLYTRADER_MYSQL; es wird beim
+ /// bloßen Scaffolding keine Verbindung geöffnet.
+ ///
+ public class ResolutionFarmingDbContextFactory : IDesignTimeDbContextFactory
+ {
+ public ResolutionFarmingDbContext CreateDbContext(string[] args)
+ {
+ var conn = Environment.GetEnvironmentVariable("POLYTRADER_MYSQL")
+ ?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;";
+
+ var options = new DbContextOptionsBuilder()
+ .UseMySql(conn, DatabaseServerVersion.Value)
+ .Options;
+
+ return new ResolutionFarmingDbContext(options);
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Persistence/IRfRepositories.cs b/src/PolyTrader.Modules.ResolutionFarming/Persistence/IRfRepositories.cs
new file mode 100644
index 0000000..76bd236
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Persistence/IRfRepositories.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+using PolyTrader.Modules.ResolutionFarming.Models;
+
+namespace PolyTrader.Modules.ResolutionFarming.Persistence
+{
+ public interface IRfSettingsRepository
+ {
+ List GetAll();
+ RfSettings? Get(int accountId);
+ void Upsert(RfSettings settings);
+ }
+
+ public interface IRfCandidateRepository
+ {
+ void Insert(RfCandidate candidate);
+ List GetRecent(int accountId, int limit);
+ }
+
+ public interface IRfPositionRepository
+ {
+ List GetOpen(int accountId);
+ List GetAllOpen();
+ RfPosition? Find(int accountId, string tokenId);
+ void Upsert(RfPosition position);
+ void Delete(int accountId, string tokenId);
+ /// Anzahl heute (seit ) neu eröffneter Positionen (Tages-Drossel).
+ int CountOpenedSince(int accountId, DateTime since);
+ }
+
+ public interface IRfClosedTradeRepository
+ {
+ void Insert(RfClosedTrade trade);
+ List Find(Expression> predicate);
+ /// Summe des realisierten PnL seit (Kill-Switch).
+ decimal RealizedPnlSince(int accountId, DateTime since);
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs
index 0a5a4ea..bf87862 100644
--- a/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs
+++ b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs
@@ -1,8 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
+using PolyTrader.Modules.ResolutionFarming.Persistence;
+using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
namespace PolyTrader.Modules.ResolutionFarming
{
@@ -22,8 +26,16 @@ namespace PolyTrader.Modules.ResolutionFarming
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{
- // Slice 1: reines Logik-Skelett. Persistenz (DbContext/Repos), Scanner-/Monitor-Jobs und
- // Execution werden in den folgenden Slices registriert.
+ // Modul-Persistenz: EF Core / Pomelo / MySQL (thread-safer DbContextFactory), eigene rf_-Tabellen.
+ var conn = configuration["Database:MySqlConnectionString"] ?? string.Empty;
+ services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value));
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Scanner-/Monitor-Jobs, Execution und UI folgen in den nächsten Slices.
}
public void RegisterUi(IModuleUiHost host, System.IServiceProvider services)
diff --git a/tests/PolyTrader.Tests/RfRepositoryTests.cs b/tests/PolyTrader.Tests/RfRepositoryTests.cs
new file mode 100644
index 0000000..4b52e47
--- /dev/null
+++ b/tests/PolyTrader.Tests/RfRepositoryTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Linq;
+using PolyTrader.Modules.ResolutionFarming.Models;
+using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
+using PolyTrader.Tests.TestSupport;
+using Xunit;
+
+namespace PolyTrader.Tests
+{
+ /// EF-InMemory-Tests der ResolutionFarming-Repositories (rf_-Tabellen).
+ public class RfRepositoryTests
+ {
+ private static InMemoryContextFactory Factory() =>
+ new(o => new ResolutionFarmingDbContext(o));
+
+ // ----- Settings -----
+
+ [Fact]
+ public void Settings_upsert_inserts_then_updates()
+ {
+ var f = Factory();
+ var repo = new EfRfSettingsRepository(f);
+ repo.Upsert(new RfSettings { AccountId = 1, MaxPerMarketUsd = 25m });
+ repo.Upsert(new RfSettings { AccountId = 1, MaxPerMarketUsd = 50m });
+
+ Assert.Single(repo.GetAll());
+ Assert.Equal(50m, repo.Get(1)!.MaxPerMarketUsd);
+ Assert.Null(repo.Get(99));
+ }
+
+ // ----- Candidates -----
+
+ [Fact]
+ public void Candidates_getrecent_orders_desc_and_limits()
+ {
+ var f = Factory();
+ var repo = new EfRfCandidateRepository(f);
+ var t0 = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
+ for (int i = 0; i < 5; i++)
+ repo.Insert(new RfCandidate { AccountId = 1, TokenId = "t" + i, ScannedAt = t0.AddMinutes(i) });
+ repo.Insert(new RfCandidate { AccountId = 2, TokenId = "other", ScannedAt = t0.AddMinutes(99) });
+
+ var recent = repo.GetRecent(1, 3);
+ Assert.Equal(3, recent.Count);
+ Assert.Equal("t4", recent[0].TokenId); // neuester zuerst
+ Assert.DoesNotContain(recent, c => c.AccountId == 2);
+ }
+
+ [Fact]
+ public void Candidate_id_is_db_generated()
+ {
+ var f = Factory();
+ var repo = new EfRfCandidateRepository(f);
+ repo.Insert(new RfCandidate { AccountId = 1, TokenId = "a" });
+ repo.Insert(new RfCandidate { AccountId = 1, TokenId = "b" });
+
+ var ids = repo.GetRecent(1, 10).Select(c => c.Id).ToList();
+ Assert.Equal(2, ids.Distinct().Count());
+ Assert.DoesNotContain(0L, ids); // Autoincrement, keine code-vergebene 0
+ }
+
+ // ----- Positions -----
+
+ [Fact]
+ public void Positions_upsert_find_delete_and_exposure()
+ {
+ var f = Factory();
+ var repo = new EfRfPositionRepository(f);
+ var now = DateTime.UtcNow;
+ repo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", AmountUsd = 10m, OpenedAt = now });
+ repo.Upsert(new RfPosition { AccountId = 1, TokenId = "b", AmountUsd = 15m, OpenedAt = now });
+ repo.Upsert(new RfPosition { AccountId = 2, TokenId = "c", AmountUsd = 99m, OpenedAt = now });
+
+ Assert.Equal(2, repo.GetOpen(1).Count);
+ Assert.Equal(3, repo.GetAllOpen().Count);
+ Assert.Equal(15m, repo.Find(1, "b")!.AmountUsd);
+
+ // Update über denselben zusammengesetzten Schlüssel
+ repo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", AmountUsd = 12m, OpenedAt = now });
+ Assert.Equal(12m, repo.Find(1, "a")!.AmountUsd);
+ Assert.Equal(2, repo.GetOpen(1).Count); // kein Duplikat
+
+ repo.Delete(1, "a");
+ Assert.Null(repo.Find(1, "a"));
+ }
+
+ [Fact]
+ public void Positions_countopenedsince_respects_account_and_time()
+ {
+ var f = Factory();
+ var repo = new EfRfPositionRepository(f);
+ var today = DateTime.UtcNow;
+ repo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", OpenedAt = today });
+ repo.Upsert(new RfPosition { AccountId = 1, TokenId = "b", OpenedAt = today.AddDays(-2) });
+ repo.Upsert(new RfPosition { AccountId = 2, TokenId = "c", OpenedAt = today });
+
+ Assert.Equal(1, repo.CountOpenedSince(1, today.AddHours(-1)));
+ }
+
+ // ----- ClosedTrades -----
+
+ [Fact]
+ public void ClosedTrades_insert_find_and_pnl_sum()
+ {
+ var f = Factory();
+ var repo = new EfRfClosedTradeRepository(f);
+ var since = new DateTime(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc);
+ repo.Insert(new RfClosedTrade { AccountId = 1, TokenId = "a", RealizedPnl = 5m, ClosedAt = since.AddHours(1) });
+ repo.Insert(new RfClosedTrade { AccountId = 1, TokenId = "b", RealizedPnl = -8m, ClosedAt = since.AddHours(2) });
+ repo.Insert(new RfClosedTrade { AccountId = 1, TokenId = "c", RealizedPnl = 100m, ClosedAt = since.AddDays(-1) }); // vor dem Fenster
+ repo.Insert(new RfClosedTrade { AccountId = 2, TokenId = "d", RealizedPnl = 50m, ClosedAt = since.AddHours(1) });
+
+ Assert.Equal(-3m, repo.RealizedPnlSince(1, since)); // 5 - 8, ohne 100 (zu alt), ohne Account 2
+ Assert.Equal(2, repo.Find(t => t.RealizedPnl > 0 && t.AccountId == 1).Count); // a (5) + c (100)
+ }
+ }
+}