Fix: Implement FixPlan part A9, A10, B
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user