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:
Richard
2026-07-11 14:09:12 +02:00
co-authored by Claude Fable 5
parent 940a99fec2
commit d2f3ec2bd0
9 changed files with 391 additions and 195 deletions
@@ -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})");
}
}
}