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