RF-Slice 2: ResolutionFarming-Persistenz (EF/MySQL) + Repos + Migration

- Entities RfCandidate/RfPosition/RfClosedTrade (+ RfSettings aus Slice 1).
- ResolutionFarmingDbContext: Tabellen rf_settings/rf_candidates/rf_positions/
  rf_closed_trades. Autoincrement-PKs (Identity) fuer Candidate/ClosedTrade von
  Anfang an (Lehre aus dem CopyTrading-TradeId-Problem), zusammengesetzter PK
  (AccountId,TokenId) fuer Positions, Indizes + Decimal-Precision.
- 4 Repos (Settings/Candidate/Position/ClosedTrade) mit serverseitigen Aggregaten
  (RealizedPnlSince fuer Kill-Switch, CountOpenedSince fuer Tages-Drossel).
- Modul registriert DbContextFactory + Repos.
- Design-Time-Factory nutzt die fest gepinnte Server-Version -> Migration wurde
  OHNE DB-Verbindung generiert (kein Zugriff auf die produktive DB). Anwenden per
  'dotnet ef database update' bewusst im Zielland/lokal durch den Nutzer.

6 neue EF-InMemory-Repo-Tests. Build 0 Fehler, 289 Tests gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-09 22:06:59 +02:00
co-authored by Claude Opus 4.8
parent c9eb11afe4
commit 1b6e194b50
12 changed files with 1396 additions and 2 deletions
@@ -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<ResolutionFarmingDbContext> _factory;
public EfRfSettingsRepository(IDbContextFactory<ResolutionFarmingDbContext> factory) => _factory = factory;
public List<RfSettings> 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<ResolutionFarmingDbContext> _factory;
public EfRfCandidateRepository(IDbContextFactory<ResolutionFarmingDbContext> factory) => _factory = factory;
public void Insert(RfCandidate candidate)
{
using var ctx = _factory.CreateDbContext();
ctx.Candidates.Add(candidate);
ctx.SaveChanges();
}
public List<RfCandidate> 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<ResolutionFarmingDbContext> _factory;
public EfRfPositionRepository(IDbContextFactory<ResolutionFarmingDbContext> factory) => _factory = factory;
public List<RfPosition> GetOpen(int accountId)
{
using var ctx = _factory.CreateDbContext();
return ctx.Positions.AsNoTracking().Where(p => p.AccountId == accountId).ToList();
}
public List<RfPosition> 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<ResolutionFarmingDbContext> _factory;
public EfRfClosedTradeRepository(IDbContextFactory<ResolutionFarmingDbContext> factory) => _factory = factory;
public void Insert(RfClosedTrade trade)
{
using var ctx = _factory.CreateDbContext();
ctx.ClosedTrades.Add(trade);
ctx.SaveChanges();
}
public List<RfClosedTrade> Find(Expression<Func<RfClosedTrade, bool>> 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;
}
}
}
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using PolyTrader.Modules.ResolutionFarming.Models;
namespace PolyTrader.Modules.ResolutionFarming.Persistence.Ef
{
/// <summary>
/// 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).
/// </summary>
public class ResolutionFarmingDbContext : DbContext
{
public ResolutionFarmingDbContext(DbContextOptions<ResolutionFarmingDbContext> options) : base(options) { }
public DbSet<RfSettings> Settings => Set<RfSettings>();
public DbSet<RfCandidate> Candidates => Set<RfCandidate>();
public DbSet<RfPosition> Positions => Set<RfPosition>();
public DbSet<RfClosedTrade> ClosedTrades => Set<RfClosedTrade>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<RfSettings>(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<RfCandidate>(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<RfPosition>(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<RfClosedTrade>(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);
});
}
}
}
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using PolyTrader.Core.Configuration;
namespace PolyTrader.Modules.ResolutionFarming.Persistence.Ef
{
/// <summary>
/// Design-Time-Factory für EF-Tooling. Nutzt die fest gepinnte Server-Version
/// (<see cref="DatabaseServerVersion"/>) statt <c>ServerVersion.AutoDetect</c>, 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.
/// </summary>
public class ResolutionFarmingDbContextFactory : IDesignTimeDbContextFactory<ResolutionFarmingDbContext>
{
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<ResolutionFarmingDbContext>()
.UseMySql(conn, DatabaseServerVersion.Value)
.Options;
return new ResolutionFarmingDbContext(options);
}
}
}
@@ -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<RfSettings> GetAll();
RfSettings? Get(int accountId);
void Upsert(RfSettings settings);
}
public interface IRfCandidateRepository
{
void Insert(RfCandidate candidate);
List<RfCandidate> GetRecent(int accountId, int limit);
}
public interface IRfPositionRepository
{
List<RfPosition> GetOpen(int accountId);
List<RfPosition> GetAllOpen();
RfPosition? Find(int accountId, string tokenId);
void Upsert(RfPosition position);
void Delete(int accountId, string tokenId);
/// <summary>Anzahl heute (seit <paramref name="since"/>) neu eröffneter Positionen (Tages-Drossel).</summary>
int CountOpenedSince(int accountId, DateTime since);
}
public interface IRfClosedTradeRepository
{
void Insert(RfClosedTrade trade);
List<RfClosedTrade> Find(Expression<Func<RfClosedTrade, bool>> predicate);
/// <summary>Summe des realisierten PnL seit <paramref name="since"/> (Kill-Switch).</summary>
decimal RealizedPnlSince(int accountId, DateTime since);
}
}