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:
co-authored by
Claude Opus 4.8
parent
80e6ad9b2d
commit
1c3a364df2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user