Fix category mapping and TotalTrades drift, plan traits/tiering (Teil D)
- MarketCategoryMapper: classify from question text (the Gamma /markets
endpoint delivers neither category nor event tags, so on-demand markets
had no signal at all), match short tokens on word boundaries ("eth" no
longer hits inside "whether", "pop" not inside "popular"), widen the
keyword lists across all categories.
- UpdateMarketFields: never overwrite a tag-derived category with an
uninformative "Other" from the on-demand path.
- PositionPnLEngine: sync Trader.TotalTrades to the actual replayed row
count — the worker-side increment counters drift (INSERT IGNORE,
deletions, historic imports) and produced Trades30d > TotalTrades.
- Tests: 14 new (mapper classification + word-boundary regression,
TotalTrades sync + Trades30d invariant, category update guard via
SQLite) — suite now 32 green + 1 skip.
- FIXPLAN Teil D for the larger rebuilds (AggregatedCount column,
TraderTraits heuristics, IngestMode tiering for ultra-HF traders).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
940a99fec2
commit
d2f3ec2bd0
@@ -0,0 +1,55 @@
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Infrastructure.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Predictalytics.Application.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the keyword-based market classification (added 2026-07-11).
|
||||
/// The Gamma /markets endpoint carries neither a category field nor event tags,
|
||||
/// so for on-demand fetched markets the QUESTION TEXT is often the only signal —
|
||||
/// the mapper must classify from it. Short tokens must match on word boundaries
|
||||
/// ("eth" must not hit inside "whether", "pop" not inside "popular").
|
||||
/// </summary>
|
||||
public class MarketCategoryMapperTests
|
||||
{
|
||||
[Theory]
|
||||
// Question-text-only classification (the on-demand path has no tags):
|
||||
[InlineData("", "", "Will the Lakers win the NBA Finals?", MarketCategory.Sports)]
|
||||
[InlineData("", "", "Ethereum above $1,850 on July 10?", MarketCategory.Crypto)]
|
||||
[InlineData("", "", "Will Trump win the 2028 presidential election?", MarketCategory.Politics)]
|
||||
[InlineData("", "", "Fed rate cut in September?", MarketCategory.Economy)]
|
||||
[InlineData("", "", "Will SpaceX launch Starship this quarter?", MarketCategory.Science)]
|
||||
[InlineData("", "", "Russia-Ukraine ceasefire before August?", MarketCategory.GlobalNews)]
|
||||
[InlineData("", "", "New Rihanna album before GTA VI?", MarketCategory.PopCulture)]
|
||||
// Tag-based classification still works:
|
||||
[InlineData("", "Sports, NBA", "", MarketCategory.Sports)]
|
||||
[InlineData("", "Crypto", "", MarketCategory.Crypto)]
|
||||
// No signal at all → Other:
|
||||
[InlineData("", "", "Something entirely unclassifiable happens?", MarketCategory.Other)]
|
||||
public void Map_ClassifiesFromAvailableSignals(string rawCategory, string tags, string question, MarketCategory expected)
|
||||
{
|
||||
var (category, _) = MarketCategoryMapper.Map(rawCategory, tags, question);
|
||||
Assert.Equal(expected, category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_ShortTokens_RequireWordBoundaries()
|
||||
{
|
||||
// "whether" contains the substring "eth" — must NOT classify as Crypto.
|
||||
var (category, _) = MarketCategoryMapper.Map("", "", "Whether the government acts by Friday?");
|
||||
Assert.NotEqual(MarketCategory.Crypto, category);
|
||||
|
||||
// "popular vote" contains the substring "pop" — must be Politics ("vote"),
|
||||
// not PopCulture.
|
||||
var (category2, _) = MarketCategoryMapper.Map("", "", "Popular vote winner in Michigan?");
|
||||
Assert.Equal(MarketCategory.Politics, category2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_Subcategory_PrefersFirstTagOverFallback()
|
||||
{
|
||||
var (_, subcategory) = MarketCategoryMapper.Map("", "NBA, Basketball", "Lakers to win?");
|
||||
Assert.Equal("NBA", subcategory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Infrastructure.Data;
|
||||
using Predictalytics.Infrastructure.Data.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Predictalytics.Application.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the market upsert paths (added 2026-07-11).
|
||||
/// Uses SQLite in-memory (same pattern as TradeRetentionWorkerTests).
|
||||
/// </summary>
|
||||
public class MarketRepositoryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The on-demand /markets fetch carries no event tags and classifies markets
|
||||
/// as "Other". Such an uninformative update must NOT overwrite a category that
|
||||
/// was previously derived from the tag-bearing /events sync — while a
|
||||
/// legitimate reclassification (Other → Sports) must still go through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddOrUpdateAsync_DoesNotOverwriteGoodCategoryWithOther()
|
||||
{
|
||||
using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
using (var setup = new AppDbContext(options))
|
||||
{
|
||||
setup.Database.EnsureCreated();
|
||||
var ev = new Event { Id = 1, Platform = PlatformType.Polymarket, Slug = "e", Title = "E" };
|
||||
setup.Set<Event>().Add(ev);
|
||||
setup.Markets.Add(new Market
|
||||
{
|
||||
Id = 10, EventId = 1, Platform = PlatformType.Polymarket,
|
||||
ConditionId = "0xabc", PlatformMarketId = 1L, Question = "Lakers to win?",
|
||||
Category = MarketCategory.Sports, Subcategory = "NBA"
|
||||
});
|
||||
setup.SaveChanges();
|
||||
}
|
||||
|
||||
// Act 1: uninformative on-demand update (no tags → Other) must not downgrade.
|
||||
using (var ctx = new AppDbContext(options))
|
||||
{
|
||||
var repo = new MarketRepository(ctx);
|
||||
await repo.AddOrUpdateAsync(new Market
|
||||
{
|
||||
Platform = PlatformType.Polymarket, ConditionId = "0xabc",
|
||||
PlatformMarketId = 1L, Question = "Lakers to win?",
|
||||
Category = MarketCategory.Other, Subcategory = "Other"
|
||||
});
|
||||
}
|
||||
|
||||
using (var assertCtx = new AppDbContext(options))
|
||||
{
|
||||
var market = await assertCtx.Markets.SingleAsync(m => m.ConditionId == "0xabc");
|
||||
Assert.Equal(MarketCategory.Sports, market.Category);
|
||||
Assert.Equal("NBA", market.Subcategory);
|
||||
}
|
||||
|
||||
// Act 2: a real classification must still overwrite an existing "Other".
|
||||
using (var ctx = new AppDbContext(options))
|
||||
{
|
||||
var repo = new MarketRepository(ctx);
|
||||
await repo.AddOrUpdateAsync(new Market
|
||||
{
|
||||
Platform = PlatformType.Polymarket, ConditionId = "0xdef",
|
||||
PlatformMarketId = 2L, Question = "Unknown thing?",
|
||||
Category = MarketCategory.Other, Subcategory = "Other",
|
||||
Event = new Event { Platform = PlatformType.Polymarket, PlatformEventId = 2, Slug = "e2", Title = "E2" }
|
||||
});
|
||||
await repo.AddOrUpdateAsync(new Market
|
||||
{
|
||||
Platform = PlatformType.Polymarket, ConditionId = "0xdef",
|
||||
PlatformMarketId = 2L, Question = "Unknown thing?",
|
||||
Category = MarketCategory.Politics, Subcategory = "Elections"
|
||||
});
|
||||
}
|
||||
|
||||
using (var assertCtx = new AppDbContext(options))
|
||||
{
|
||||
var market = await assertCtx.Markets.SingleAsync(m => m.ConditionId == "0xdef");
|
||||
Assert.Equal(MarketCategory.Politics, market.Category);
|
||||
Assert.Equal("Elections", market.Subcategory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,4 +778,59 @@ public class PositionPnLEngineTests
|
||||
Assert.Equal(30m, analytics.OverallPnL);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Added 2026-07-11: Trader.TotalTrades was a drifting increment counter
|
||||
/// (INSERT IGNORE over-counts, historic imports never counted, deletions never
|
||||
/// subtracted), which produced impossible states like Trades30d > TotalTrades.
|
||||
/// The engine recalculation is the single source of truth: it must sync
|
||||
/// TotalTrades to the actual number of trade rows it just replayed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RecalculateTraderPositionsAsync_SyncsTotalTradesWithActualTradeCount()
|
||||
{
|
||||
// Arrange: counter is wildly wrong (both directions occur in production).
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
using (var db = CreateDbContext(dbName))
|
||||
{
|
||||
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1", TotalTrades = 999 };
|
||||
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
|
||||
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m });
|
||||
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(-2)
|
||||
});
|
||||
db.Trades.Add(new Trade
|
||||
{
|
||||
Id = 11, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
|
||||
Side = TradeSide.Sell, Price = 0.50m, Size = 50m, Amount = 25m,
|
||||
ExecutedAt = DateTime.UtcNow.AddDays(-1)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
using (var db = CreateDbContext(dbName))
|
||||
{
|
||||
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
|
||||
await pnlEngine.RecalculateTraderPositionsAsync(1);
|
||||
}
|
||||
|
||||
// Assert: counter equals the real row count, and the windowed count can
|
||||
// never exceed it again.
|
||||
using (var db = CreateDbContext(dbName))
|
||||
{
|
||||
var trader = await db.Traders.SingleAsync(t => t.Id == 1);
|
||||
Assert.Equal(2, trader.TotalTrades);
|
||||
|
||||
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
|
||||
Assert.True(analytics.Trades30d <= trader.TotalTrades,
|
||||
$"Trades30d ({analytics.Trades30d}) must never exceed TotalTrades ({trader.TotalTrades})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user