Fix: Implement FixPlan part A9, A10, B

This commit is contained in:
Richard
2026-07-10 12:04:27 +02:00
parent 44f48284a2
commit 1787422243
28 changed files with 1025 additions and 79 deletions
@@ -654,4 +654,128 @@ public class PositionPnLEngineTests
Assert.Equal(0m, analytics.PnL24h);
}
}
// ═════════════════════════════════════════════════════════════════════════
// Invariant tests added 2026-07-10 (review round 5).
// Root cause found via live Gamma API: the response contains NO
// "resolution_outcome" and NO "resolved" field. Market.ResolutionOutcome is
// therefore ALWAYS NULL in our DB, and IsResolved is effectively just
// "closed". Winner detection must use the snapped outcomePrices (winner→1,
// loser→0, persisted in MarketOutcome.CurrentPrice) and/or
// "umaResolutionStatus". EXPECTED TO BE RED until fixed.
// ═════════════════════════════════════════════════════════════════════════
/// <summary>
/// Defect 11a: A resolved market whose ResolutionOutcome string is NULL
/// (which is ALL markets today) must still pay out winners. The winner is
/// identifiable by its snapped price (CurrentPrice ≈ 1). Booking payout 0
/// for every winner is what currently makes every trader show
/// WinRate 0% and negative PnL.
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_ResolvedMarketWithoutResolutionOutcome_PaysWinnerViaSnappedPrice()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market
{
Id = 10, PlatformMarketId = 1L, Question = "Q?",
IsResolved = true,
ResolutionOutcome = null // ← reality: Gamma never delivers this field
};
// Snapped prices after resolution: this outcome won.
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 1.00m });
market.Outcomes.Add(new MarketOutcome { Id = 101, MarketId = 10, Label = "No", TokenId = "t101", CurrentPrice = 0.00m });
db.Traders.Add(trader);
db.Markets.Add(market);
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.40m, Size = 100m, Amount = 40m,
ExecutedAt = DateTime.UtcNow.AddDays(-3)
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: virtual payout 100 × (1.00 0.40) = +60 — NOT 40.
using (var db = CreateDbContext(dbName))
{
var pos = await db.TraderPositions.SingleAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.Equal(60m, pos.RealizedPnl);
Assert.Equal(0m, pos.SharesHeld);
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(60m, analytics.OverallPnL);
}
}
/// <summary>
/// Defect 11b: "closed" is NOT "resolved". The Gamma mapper sets
/// IsResolved = closed, so markets that closed for trading but are still
/// awaiting UMA resolution (prices NOT snapped, e.g. 0.70) currently get a
/// virtual payout of 0 → every open position is booked as a total loss
/// days before the real outcome is known. The engine must only settle a
/// position when the outcome is actually decidable (ResolutionOutcome set,
/// or prices snapped to 0/1); otherwise the position stays open with
/// unrealized PnL.
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_ClosedButUnresolvedMarket_DoesNotBookPrematurePayout()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market
{
Id = 10, PlatformMarketId = 1L, Question = "Q?",
IsResolved = true, // ← buggy mapper sets this for merely CLOSED markets
ResolutionOutcome = null
};
// Prices NOT snapped → UMA has not resolved yet, outcome undecided.
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.70m });
market.Outcomes.Add(new MarketOutcome { Id = 101, MarketId = 10, Label = "No", TokenId = "t101", CurrentPrice = 0.30m });
db.Traders.Add(trader);
db.Markets.Add(market);
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.40m, Size = 100m, Amount = 40m,
ExecutedAt = DateTime.UtcNow.AddDays(-3)
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: position must remain OPEN (no premature settlement at payout 0).
using (var db = CreateDbContext(dbName))
{
var pos = await db.TraderPositions.SingleAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.Equal(100m, pos.SharesHeld);
Assert.Equal(0m, pos.RealizedPnl);
// Unrealized: 100 × (0.70 0.40) = +30
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(30m, analytics.OverallPnL);
}
}
}