RF-Slice 5: Demo-Execution + Resolution-Monitor (Phase RF-2)

Schliesst die Demo-Handelsschleife des ResolutionFarming:
- FarmingExecutionPlanner (pur): waehlt aus akzeptierten Kandidaten die zu oeffnenden
  Positionen + Groesse, priorisiert nach Score, unter Markt-/Cluster-/Gesamt-Limits,
  Kill-Switch und Tages-Drossel; dedupliziert Token, schreibt Exposure im Lauf fort.
- FarmingResolution (pur): baut aus Position + Ergebnis den RfClosedTrade (PnL/Fees/Redeem-Status).
- FarmingExecutionService: Demo-Einstieg (Maker-Fill 0 Fee via FarmingFillModel) -> rf_positions.
  Live-Execution bewusst geloggt/uebersprungen (Zielland). Demo-Balance NICHT mutiert
  (kein Shared-Account-Konflikt; PnL fliesst ueber rf_closed_trades).
- FarmingResolutionMonitorService: schliesst aufgeloeste Positionen, bucht GlobalPnl,
  Dual-Write ins Core-Trade-Log (Dashboard). Auflösungsstatus via IMarketResolutionSource.
- NullMarketResolutionSource als Default (nichts loest auf), bis Live-Data-API verdrahtet ist.

15 neue Tests (Planner 8, Resolution 3, Execution-Service 2, Monitor 2). Build 0 Fehler,
311 Tests gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-10 11:40:30 +02:00
co-authored by Claude Opus 4.8
parent 80e6ad9b2d
commit 1c3a364df2
11 changed files with 680 additions and 1 deletions
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using PolyTrader.Core.Persistence;
using PolyTraderSharp.Models;
namespace PolyTrader.Tests.Fakes
{
/// <summary>In-Memory-Stub für den generischen Core-Trade-Log (Dual-Write-Ziel der Module).</summary>
public sealed class FakeCoreTradeLogRepository : ITradeLogRepository
{
public List<TradeRecord> Inserted { get; } = new();
public void EnsureIndexes() { }
public void Insert(TradeRecord record) => Inserted.Add(record);
public List<TradeRecord> GetRecent(int limit) => Inserted.TakeLast(limit).ToList();
public List<TradeRecord> Find(Expression<Func<TradeRecord, bool>> predicate) => Inserted.Where(predicate.Compile()).ToList();
}
}
@@ -0,0 +1,108 @@
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.ResolutionFarming.Logic;
using PolyTrader.Modules.ResolutionFarming.Models;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>Sicherheitsnetz für die reine Entry-Planung (Score-Priorisierung unter allen Risiko-Limits).</summary>
public class FarmingExecutionPlannerTests
{
private static RfSettings Settings(decimal maxPerMarket = 25m, decimal clusterPct = 10m,
decimal totalPct = 60m, int maxPerDay = 20, decimal killUsd = 0m) => new()
{
MaxPerMarketUsd = maxPerMarket, MaxPerClusterPct = clusterPct, MaxTotalExposurePct = totalPct,
MaxNewPositionsPerDay = maxPerDay, DailyLossKillSwitchUsd = killUsd
};
private static RfCandidate Cand(string token, decimal score, string cluster = "c", decimal ask = 0.95m) =>
new() { Accepted = true, TokenId = token, Score = score, ClusterKey = cluster, Ask = ask };
[Fact]
public void Plan_ranks_by_score_and_sizes_to_limits()
{
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 3m, "c1"), Cand("b", 5m, "c2") },
new List<RfPosition>(), Settings(), bankrollUsd: 1000m, newPositionsToday: 0, realizedDailyPnlUsd: 0m);
Assert.Equal(2, plan.Count);
Assert.Equal("b", plan[0].candidate.TokenId); // höherer Score zuerst
Assert.Equal(25m, plan[0].sizeUsd); // min(Markt 25, Cluster 100, Gesamt 600)
}
[Fact]
public void Plan_skips_already_held_tokens()
{
var open = new List<RfPosition> { new() { TokenId = "a", ClusterKey = "c1", AmountUsd = 10m } };
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 9m, "c1") }, open, Settings(), 1000m, 0, 0m);
Assert.Empty(plan);
}
[Fact]
public void Plan_cluster_limit_caps_second_open_in_same_cluster()
{
// Cluster 3% von 1000 = 30. Erste 25, zweite nur noch 5.
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 5m, "cx"), Cand("b", 4m, "cx") },
new List<RfPosition>(), Settings(clusterPct: 3m), 1000m, 0, 0m);
Assert.Equal(2, plan.Count);
Assert.Equal(25m, plan[0].sizeUsd);
Assert.Equal(5m, plan[1].sizeUsd);
}
[Fact]
public void Plan_total_exposure_limit_caps()
{
// Gesamt 3% von 1000 = 30. Erste 25, zweite 5.
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 5m, "c1"), Cand("b", 4m, "c2") },
new List<RfPosition>(), Settings(totalPct: 3m), 1000m, 0, 0m);
Assert.Equal(25m, plan[0].sizeUsd);
Assert.Equal(5m, plan[1].sizeUsd);
}
[Fact]
public void Plan_kill_switch_blocks_all()
{
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 5m) }, new List<RfPosition>(),
Settings(killUsd: 10m), 1000m, 0, realizedDailyPnlUsd: -10m);
Assert.Empty(plan);
}
[Fact]
public void Plan_daily_limit_stops_new_opens()
{
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 5m, "c1"), Cand("b", 4m, "c2") },
new List<RfPosition>(), Settings(maxPerDay: 1), 1000m, newPositionsToday: 0, realizedDailyPnlUsd: 0m);
Assert.Single(plan);
}
[Fact]
public void Plan_dedupes_duplicate_candidate_tokens()
{
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 5m, "c1"), Cand("a", 3m, "c1") },
new List<RfPosition>(), Settings(), 1000m, 0, 0m);
Assert.Single(plan);
Assert.Equal(5m, plan[0].candidate.Score); // höherer Score gewinnt
}
[Fact]
public void Plan_empty_when_bankroll_zero()
{
var plan = FarmingExecutionPlanner.Plan(
new[] { Cand("a", 5m) }, new List<RfPosition>(), Settings(), bankrollUsd: 0m, 0, 0m);
Assert.Empty(plan);
}
}
}
@@ -0,0 +1,67 @@
using System.Linq;
using PolyTrader.Modules.ResolutionFarming.Models;
using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
using PolyTrader.Modules.ResolutionFarming.Services;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>Orchestrierungstest des Demo-Einstiegs: akzeptierte Kandidaten → geplante/geöffnete rf_positions.</summary>
public class FarmingExecutionServiceTests
{
private static RfSettings Settings() => new()
{
AccountId = 1, MaxPerMarketUsd = 25m, MaxPerClusterPct = 10m, MaxTotalExposurePct = 60m,
MaxNewPositionsPerDay = 20, DailyLossKillSwitchUsd = 0m
};
private static (FarmingExecutionService svc, EfRfPositionRepository posRepo, EfRfCandidateRepository candRepo) Build()
{
var factory = new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o));
var candRepo = new EfRfCandidateRepository(factory);
var posRepo = new EfRfPositionRepository(factory);
var closedRepo = new EfRfClosedTradeRepository(factory);
var settingsRepo = new EfRfSettingsRepository(factory);
var svc = new FarmingExecutionService(new TradingState(), settingsRepo, candRepo, posRepo, closedRepo, new TerminalLogger());
return (svc, posRepo, candRepo);
}
[Fact]
public void RunDemo_opens_positions_for_accepted_candidates()
{
var (svc, posRepo, candRepo) = Build();
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "a", ClusterKey = "c1", Ask = 0.95m, Score = 5m });
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "b", ClusterKey = "c2", Ask = 0.95m, Score = 3m });
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = false, TokenId = "z", ClusterKey = "c3", Ask = 0.80m, Score = 9m }); // abgelehnt -> ignoriert
var opened = svc.RunDemoForAccount(1, bankrollUsd: 1000m, Settings());
Assert.Equal(2, opened.Count);
var stored = posRepo.GetOpen(1);
Assert.Equal(2, stored.Count);
var a = stored.First(p => p.TokenId == "a");
Assert.Equal(0.95m, a.EntryPrice);
Assert.Equal(26.31m, a.Size); // floor(25/0.95, 2 Dez.)
Assert.Equal(24.9945m, a.AmountUsd); // Maker (0 Fee): shares * ask
Assert.True(a.IsDemo);
Assert.Equal("Open", a.Status);
}
[Fact]
public void RunDemo_skips_already_held_market()
{
var (svc, posRepo, candRepo) = Build();
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", ClusterKey = "c1", AmountUsd = 20m });
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "a", ClusterKey = "c1", Ask = 0.95m, Score = 9m });
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "b", ClusterKey = "c2", Ask = 0.95m, Score = 3m });
var opened = svc.RunDemoForAccount(1, 1000m, Settings());
Assert.Single(opened);
Assert.Equal("b", opened[0].TokenId);
}
}
}
@@ -0,0 +1,69 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Modules.ResolutionFarming.Models;
using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
using PolyTrader.Modules.ResolutionFarming.Services;
using PolyTrader.Tests.Fakes;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>Orchestrierungstest des Resolution-Monitors: aufgelöste Positionen werden geschlossen, gebucht und dual-geschrieben.</summary>
public class FarmingResolutionMonitorTests
{
private sealed class FakeResolution : IMarketResolutionSource
{
private readonly Dictionary<string, (bool, bool)> _byToken;
public FakeResolution(Dictionary<string, (bool, bool)> byToken) => _byToken = byToken;
public Task<(bool isClosed, bool isWinner)> CheckAsync(string slug, string tokenId, CancellationToken ct)
=> Task.FromResult(_byToken.TryGetValue(tokenId, out var r) ? r : (false, false));
}
[Fact]
public async Task Closes_resolved_winner_and_dual_writes()
{
var factory = new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o));
var posRepo = new EfRfPositionRepository(factory);
var closedRepo = new EfRfClosedTradeRepository(factory);
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", MarketSlug = "m-a", Size = 100m, EntryPrice = 0.95m, AmountUsd = 95m, EntryFeeBps = 0, IsDemo = true });
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "b", MarketSlug = "m-b", Size = 50m, EntryPrice = 0.90m, AmountUsd = 45m, EntryFeeBps = 0, IsDemo = true });
var resolution = new FakeResolution(new() { ["a"] = (true, true) }); // nur a aufgelöst (Gewinner)
var coreLog = new FakeCoreTradeLogRepository();
var state = new TradingState();
var svc = new FarmingResolutionMonitorService(state, posRepo, closedRepo, resolution, coreLog, new TerminalLogger());
var closed = await svc.CheckAndCloseAsync(CancellationToken.None);
Assert.Single(closed);
Assert.Equal(5m, closed[0].RealizedPnl); // 100 - 95
Assert.Equal(5m, state.GlobalPnl); // fortgeschrieben
Assert.Null(posRepo.Find(1, "a")); // Position entfernt
Assert.NotNull(posRepo.Find(1, "b")); // b bleibt offen (nicht aufgelöst)
Assert.Single(closedRepo.Find(t => t.AccountId == 1));
Assert.Single(coreLog.Inserted); // Dual-Write ins Core-Log
Assert.Equal("ResolutionFarming", coreLog.Inserted[0].ModuleName);
}
[Fact]
public async Task Leaves_unresolved_positions_open()
{
var factory = new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o));
var posRepo = new EfRfPositionRepository(factory);
var closedRepo = new EfRfClosedTradeRepository(factory);
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", MarketSlug = "m-a", Size = 100m, EntryPrice = 0.95m, AmountUsd = 95m });
var svc = new FarmingResolutionMonitorService(new TradingState(), posRepo, closedRepo,
new FakeResolution(new()), new FakeCoreTradeLogRepository(), new TerminalLogger());
var closed = await svc.CheckAndCloseAsync(CancellationToken.None);
Assert.Empty(closed);
Assert.NotNull(posRepo.Find(1, "a"));
}
}
}
@@ -0,0 +1,44 @@
using System;
using PolyTrader.Modules.ResolutionFarming.Logic;
using PolyTrader.Modules.ResolutionFarming.Models;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>Sicherheitsnetz für den reinen Positions-Abschluss bei Auflösung (PnL, Fees, Redeem-Status).</summary>
public class FarmingResolutionTests
{
private static RfPosition Pos(int feeBps = 0) => new()
{
AccountId = 1, TokenId = "a", MarketQuestion = "Q", Size = 100m, EntryPrice = 0.95m,
AmountUsd = 95m, EntryFeeBps = feeBps, IsDemo = true, OpenedAt = DateTime.UtcNow.AddHours(-5)
};
[Fact]
public void Winner_maker_pays_out_one_per_share()
{
var t = FarmingResolution.BuildClosedTrade(Pos(), isWinner: true, DateTime.UtcNow);
Assert.Equal(1.0m, t.ExitPrice);
Assert.Equal(5m, t.RealizedPnl); // 100 - 95 - 0
Assert.Equal(0m, t.TotalFees);
Assert.Equal("Pending", t.RedeemStatus); // Gewinner müssen redeemt werden
}
[Fact]
public void Loser_loses_entry_cost()
{
var t = FarmingResolution.BuildClosedTrade(Pos(), isWinner: false, DateTime.UtcNow);
Assert.Equal(0.0m, t.ExitPrice);
Assert.Equal(-95m, t.RealizedPnl);
Assert.Equal("None", t.RedeemStatus);
}
[Fact]
public void Taker_entry_fee_reduces_winner_pnl()
{
var t = FarmingResolution.BuildClosedTrade(Pos(feeBps: 100), isWinner: true, DateTime.UtcNow);
Assert.Equal(4.05m, t.RealizedPnl); // 100 - 95 - 0.95
Assert.Equal(0.95m, t.TotalFees);
}
}
}