diff --git a/FIXPLAN-2026-07-09.md b/FIXPLAN-2026-07-09.md
index 8bdfcc6..deaa253 100644
--- a/FIXPLAN-2026-07-09.md
+++ b/FIXPLAN-2026-07-09.md
@@ -318,3 +318,93 @@ zukünftiges Pruning ist damit verlustfrei im Sinne der PnL-Summen.
7. Watchlist: Toggle auf der Detailseite wechselt sichtbar den Zustand; die neue Watchlist-Seite listet die
beobachteten Trader; Remove funktioniert. *(A10)*
8. „Run Deep Analysis" (KI) füllt die AI Strategy Analysis auf der Detailseite tatsächlich. *(A10)*
+
+---
+
+## Teil D — Ausbaustufe: Merkmals-Tags & HF-Trader-Tiering (ergänzt 2026-07-11)
+
+> **Bereits direkt erledigt (nicht Teil dieses Auftrags):** `TotalTrades` wird jetzt von der Engine aus dem
+> echten Row-Count gesetzt; der Kategorie-Mapper klassifiziert zusätzlich über den Frage-Text und matcht kurze
+> Tokens nur an Wortgrenzen; `UpdateMarketFields` überschreibt gute Kategorien nicht mehr mit "Other".
+> Teststand: **32 grün + 1 Skip** — das ist die neue Basis, Assertions unverändert lassen.
+
+### D1. `AggregatedCount`-Spalte (Grundlage für D2/D3)
+
+- Migration: `Trades.AggregatedCount INT NULL` (NULL = einzelner Roh-Trade).
+- Kompaktierung im `TradeRetentionWorker`: schreibt `AggregatedCount = Anzahl der ersetzten Trades`
+ (heute geht die Original-Anzahl verloren!) und summiert beim erneuten Kompaktieren
+ bestehende Aggregate (`Sum(t.AggregatedCount ?? 1)`).
+- Engine: `trader.TotalTrades = trades.Sum(t => t.AggregatedCount ?? 1)` (ersetzt `trades.Count`),
+ `analytics.Trades30d` analog.
+- **Test:** Kompaktierung von 5 Trades → 1 Aggregat mit `AggregatedCount = 5`; `TotalTrades` bleibt nach
+ Recalc 5, nicht 1.
+
+### D2. TraderTraits — heuristische Strategie-Merkmale (ohne KI)
+
+- Neue Tabelle `TraderTraits`: Id, TraderId (FK, Cascade), Trait (string ≤ 64), Value (decimal, Messwert),
+ ComputedAt. Unique-Index (TraderId, Trait). Pro Analyse-Lauf upserten, nicht mehr zutreffende Traits löschen.
+- Berechnung als **pure Funktion** `TraderTraitCalculator.Compute(trader, trades, positions)` →
+ Liste (Trait, Value); Aufruf im `TraderAnalyticsWorker` nach Engine + Estimator aus den **bereits geladenen**
+ Daten — keine zusätzlichen API-Calls.
+- Traits v1 (Trait vergeben, wenn Bedingung erfüllt; Value = Messgröße):
+
+| Trait | Regel |
+|---|---|
+| `sub_second_cadence` | Median-Intervall < 2 s bei ≥ 50 Trades (Value = Median in s) |
+| `always_on_24_7` | größte Inaktivitätslücke der letzten 7 Tage < 4 h bei ≥ 200 Trades/7d |
+| `uniform_sizes` | Variationskoeffizient der Size (letzte 200 Trades) < 0,1 |
+| `round_amounts` | > 60 % der Amounts ∈ {1,5,10,20,25,50,100,250,500,1000} ± 1 % |
+| `uses_split_merge` | Anteil Split+Merge > 10 % |
+| `both_sides_same_market` | Yes- UND No-Trades in > 20 % der Märkte |
+| `resolution_farming` | > 30 % der Buys mit Price ≥ 0,93 UND < 48 h vor `ClosedAt` (min. 10 Buys) |
+| `longshot_buyer` | > 30 % der Buys mit Price ≤ 0,10 |
+| `scalper` | mediane Haltedauer < 1 h |
+| `holds_to_resolution` | > 70 % der aufgelösten Positionen ohne vorherigen Sell |
+| `fresh_wallet` | erster Trade < 30 Tage (Value = Alter in Tagen) |
+| `stable_stake_fraction` | CV von Amount/EstimatedBankroll < 0,5 (nur bei Bankroll > 0) |
+
+- Schwellwerte als Konstanten im Calculator (v1 hart kodiert ist ok).
+- API/UI: `TraderDto`/`TraderDetailDto` um `Traits` (string-Liste) erweitern; Detailseite zeigt Chips
+ unter dem Strategy-Feld; Traders-Liste bekommt einen Trait-Filter.
+- KI-Integration: `AiStrategyAnalysisService`-Prompt bekommt die Merkmalsliste; Trade-Beispiele von 50 auf
+ 15 repräsentative reduzieren (5 größte, 5 jüngste, 5 zufällige) — die KI verifiziert Hypothesen statt zu raten.
+- **Tests:** pure-Function-Tests je Trait, mindestens Positiv- UND Negativfall für `resolution_farming`,
+ `sub_second_cadence`, `uniform_sizes`, `both_sides_same_market`.
+
+### D3. Trader-Tiering (`IngestMode`) — Umgang mit Ultra-HF-Tradern (RN1, Swisstony)
+
+**Hintergrund:** Ultra-HF-Trader werden heute schon NICHT vollständig erfasst (PollingWorker: 100 Trades/60 s
+gegen 300+/min) — das Trade-Replay-PnL ist für diese Klasse bereits falsch und frisst nur Speicher.
+
+- Enum `IngestMode { Full = 0, Aggregated = 1, SnapshotOnly = 2 }` + Spalte auf `Trader` (Default Full), Migration.
+- **Klassifizierung** im `TradeHistoryWorker` nach jedem Fetch: Zeitspanne der letzten 500 Trades →
+ Trades/Tag-Schätzung. > 5.000/Tag → SnapshotOnly; > 100/Tag → Aggregated. Hysterese: Rückstufung Richtung
+ Full erst nach 7 Tagen unter der halben Schwelle (kein Flattern).
+- **SnapshotOnly (Tier C):**
+ - Polling/History-Worker überspringen den Trade-Import komplett.
+ - Stündlich: `GetTraderPositionsAsync` (der ungenutzte `/positions`-Endpoint!) → `TraderPositions` upserten
+ (size→SharesHeld, avgPrice→AvgCost, cashPnl→RealizedPnl); `OverallPnL` aus Positions +
+ `GetLeaderboardAsync`-PnL für die Zeitfenster; `TraderDailySnapshot` weiter schreiben (Equity-Kurve bleibt).
+ - Wöchentliche „Biopsie": einmal 500 Trades via /activity ziehen, NUR durch den `TraderTraitCalculator`
+ schicken, NICHT persistieren.
+ - Engine überspringt Trade-Replay für SnapshotOnly; Estimator/Enrichment überspringen; CopytradingScore = 0
+ mit Trait `not_copyable_hf`.
+- **Aggregated (Tier B):** Aggregation beim Import statt nachträglicher Kompaktierung: Bucket
+ (TraderId, MarketOutcomeId, Side, Stunde) mit VWAP-Preis, Summen-Size/-Amount, `AggregatedCount`; gespeichert
+ als normale Trade-Zeile mit `PlatformTradeId = "AGG_{traderId}_{outcomeId}_{side}_{yyyyMMddHH}"`, laufende
+ Stunde per Upsert aktualisieren. Average-Cost-Engine bleibt damit verlustfrei.
+- Danach: `RetentionDays` für Full-Trader auf 180 erhöhen (Config) — die Bots stellen nicht mehr die Masse,
+ und längerer Track-Record nützt genau den kopierbaren Tradern.
+- **Tests:** Klassifizierungs-Schwellen + Hysterese als pure Funktion; PollingWorker importiert für
+ SnapshotOnly-Trader nichts; Aggregations-Upsert ist idempotent (2× dieselbe Stunde → 1 Zeile, korrekte Summen
+ und `AggregatedCount`).
+
+### D4. Abnahme Teil D
+
+1. `dotnet test`: alle bestehenden **32 + 1 Skip** bleiben grün (Assertions unverändert) + die neuen D-Tests.
+2. RN1/Swisstony stehen nach der Einstufung auf SnapshotOnly: PnL gefüllt (aus /positions/Leaderboard),
+ Traits gesetzt, **keine neuen Trade-Zeilen** mehr in der DB.
+3. Detailseite zeigt Trait-Chips; Traders-Liste filterbar nach Trait.
+4. Tägliches DB-Wachstum sichtbar reduziert (DB-Size-Anzeige im WinForms-Statusbar beobachten).
+
+Reihenfolge: **D1 → D2 → D3** (bei D3 zuerst Tier C, dann Tier B).
diff --git a/src/Predictalytics.Application.Tests/Services/MarketCategoryMapperTests.cs b/src/Predictalytics.Application.Tests/Services/MarketCategoryMapperTests.cs
new file mode 100644
index 0000000..7a4592e
--- /dev/null
+++ b/src/Predictalytics.Application.Tests/Services/MarketCategoryMapperTests.cs
@@ -0,0 +1,55 @@
+using Predictalytics.Domain.Enums;
+using Predictalytics.Infrastructure.Helpers;
+using Xunit;
+
+namespace Predictalytics.Application.Tests.Services;
+
+///
+/// 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").
+///
+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);
+ }
+}
diff --git a/src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs b/src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs
new file mode 100644
index 0000000..5fc434c
--- /dev/null
+++ b/src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs
@@ -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;
+
+///
+/// Tests for the market upsert paths (added 2026-07-11).
+/// Uses SQLite in-memory (same pattern as TradeRetentionWorkerTests).
+///
+public class MarketRepositoryTests
+{
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task AddOrUpdateAsync_DoesNotOverwriteGoodCategoryWithOther()
+ {
+ using var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+ var options = new DbContextOptionsBuilder()
+ .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().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);
+ }
+ }
+}
diff --git a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs
index 0ab51cc..9e1b714 100644
--- a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs
+++ b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs
@@ -778,4 +778,59 @@ public class PositionPnLEngineTests
Assert.Equal(30m, analytics.OverallPnL);
}
}
+
+ ///
+ /// 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.
+ ///
+ [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.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})");
+ }
+ }
}
diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs
index cf394ce..bd46b22 100644
--- a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs
+++ b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs
@@ -192,8 +192,15 @@ public class MarketRepository : IMarketRepository
existing.PlatformMarketId = updated.PlatformMarketId;
existing.QuestionId = updated.QuestionId;
existing.Description = updated.Description;
- existing.Category = updated.Category;
- existing.Subcategory = updated.Subcategory;
+
+ // The /markets endpoint (on-demand path) carries no event tags, so its
+ // classification is often just "Other". Never let an uninformative update
+ // overwrite a category previously derived from the tag-bearing /events sync.
+ if (updated.Category != MarketCategory.Other || existing.Category == MarketCategory.Other)
+ {
+ existing.Category = updated.Category;
+ existing.Subcategory = updated.Subcategory;
+ }
existing.Volume = updated.Volume;
existing.Volume24h = updated.Volume24h;
existing.Liquidity = updated.Liquidity;
diff --git a/src/Predictalytics.Infrastructure/Helpers/MarketCategoryMapper.cs b/src/Predictalytics.Infrastructure/Helpers/MarketCategoryMapper.cs
index 891316a..b76993f 100644
--- a/src/Predictalytics.Infrastructure/Helpers/MarketCategoryMapper.cs
+++ b/src/Predictalytics.Infrastructure/Helpers/MarketCategoryMapper.cs
@@ -1,33 +1,77 @@
+using System.Text.RegularExpressions;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Infrastructure.Helpers;
public static class MarketCategoryMapper
{
- public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags)
+ ///
+ /// Classification rules, first match wins. Two matching modes per category:
+ /// - Substrings: long, unambiguous fragments matched anywhere ("politic", "bitcoin").
+ /// - Words: short/ambiguous tokens matched only on word boundaries, so "eth"
+ /// cannot hit inside "whether" and "pop" cannot hit inside "popular".
+ ///
+ private static readonly (MarketCategory Category, string[] Substrings, string[] Words)[] Rules =
{
- var searchString = $"{rawCategory} {tags}".ToLowerInvariant();
+ (MarketCategory.Politics,
+ new[] { "politic", "election", "president", "senat", "congress", "parliament", "impeach",
+ "referendum", "governor", "minister", "chancellor", "nominee", "supreme court",
+ "white house", "ballot", "veto", "coalition", "legislation", "mayor" },
+ new[] { "trump", "biden", "harris", "vance", "scotus", "gop", "dnc", "rnc", "poll", "polls", "vote", "votes" }),
- if (searchString.Contains("politic") || searchString.Contains("election") || searchString.Contains("trump") || searchString.Contains("biden"))
- return (MarketCategory.Politics, GetSubcategory(rawCategory, tags, "Elections"));
-
- if (searchString.Contains("crypto") || searchString.Contains("bitcoin") || searchString.Contains("eth") || searchString.Contains("solana"))
- return (MarketCategory.Crypto, GetSubcategory(rawCategory, tags, "Crypto"));
-
- if (searchString.Contains("sport") || searchString.Contains("nfl") || searchString.Contains("nba") || searchString.Contains("soccer") || searchString.Contains("tennis"))
- return (MarketCategory.Sports, GetSubcategory(rawCategory, tags, "Sports"));
-
- if (searchString.Contains("pop") || searchString.Contains("culture") || searchString.Contains("movie") || searchString.Contains("oscars") || searchString.Contains("music"))
- return (MarketCategory.PopCulture, GetSubcategory(rawCategory, tags, "Pop Culture"));
-
- if (searchString.Contains("science") || searchString.Contains("space") || searchString.Contains("weather") || searchString.Contains("climate"))
- return (MarketCategory.Science, GetSubcategory(rawCategory, tags, "Science"));
-
- if (searchString.Contains("news") || searchString.Contains("global") || searchString.Contains("world"))
- return (MarketCategory.GlobalNews, GetSubcategory(rawCategory, tags, "Global News"));
+ (MarketCategory.Crypto,
+ new[] { "crypto", "bitcoin", "ethereum", "solana", "dogecoin", "blockchain", "stablecoin",
+ "binance", "coinbase", "airdrop", "halving", "memecoin", "altcoin", "defi", "satoshi" },
+ new[] { "btc", "eth", "sol", "xrp", "doge", "nft", "bnb", "ada", "usdt", "usdc" }),
- if (searchString.Contains("economy") || searchString.Contains("finance") || searchString.Contains("business") || searchString.Contains("fed"))
- return (MarketCategory.Economy, GetSubcategory(rawCategory, tags, "Economy"));
+ (MarketCategory.Sports,
+ new[] { "sport", "soccer", "tennis", "basketball", "baseball", "football", "hockey", "olympic",
+ "champions league", "premier league", "bundesliga", "la liga", "serie a", "world cup",
+ "super bowl", "grand slam", "wimbledon", "playoff", "esport", "cricket", "rugby",
+ "golf", "boxing", "marathon", "formula 1", "grand prix", "stanley cup", "world series",
+ "roland garros", "us open" },
+ new[] { "nfl", "nba", "mlb", "nhl", "ufc", "mma", "f1", "fifa", "uefa", "atp", "wta", "pga", "ncaa", "epl" }),
+
+ (MarketCategory.PopCulture,
+ new[] { "culture", "movie", "oscars", "music", "album", "billboard", "box office", "netflix",
+ "spotify", "grammy", "emmy", "celebrit", "taylor swift", "mrbeast", "youtube", "tiktok",
+ "video game", "rotten tomatoes", "eurovision" },
+ new[] { "pop", "gta", "oscar" }),
+
+ (MarketCategory.Science,
+ new[] { "science", "spacex", "nasa", "asteroid", "hurricane", "earthquake", "climate", "weather",
+ "temperature", "vaccine", "pandemic", "artificial intelligence", "openai", "chatgpt",
+ "quantum", "starship", "nobel", "space" },
+ new[] { "ai", "agi", "gpt", "llm" }),
+
+ (MarketCategory.GlobalNews,
+ new[] { "global", "world", "news", "ceasefire", "ukraine", "russia", "israel", "gaza", "iran",
+ "taiwan", "nato", "sanction", "treaty", "invasion", "north korea", "hostage",
+ "military", "missile", "nuclear" },
+ new[] { "war", "u.n." }),
+
+ (MarketCategory.Economy,
+ new[] { "econom", "finance", "business", "inflation", "recession", "interest rate", "rate cut",
+ "rate hike", "unemployment", "tariff", "treasury", "earnings", "market cap",
+ "stock price", "bankrupt", "acquisition", "merger" },
+ new[] { "fed", "fomc", "cpi", "gdp", "nasdaq", "dow", "ipo" }),
+ };
+
+ ///
+ /// Classifies a market. The question text is a first-class signal: the Gamma
+ /// /markets endpoint delivers neither a category field nor event tags, so for
+ /// on-demand fetched markets the question is often the ONLY signal available.
+ ///
+ public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags, string question = "")
+ {
+ var searchString = $"{rawCategory} {tags} {question}".ToLowerInvariant();
+ var tokens = new HashSet(Regex.Split(searchString, "[^a-z0-9.]+"));
+
+ foreach (var rule in Rules)
+ {
+ if (rule.Words.Any(tokens.Contains) || rule.Substrings.Any(searchString.Contains))
+ return (rule.Category, GetSubcategory(rawCategory, tags, rule.Category.ToString()));
+ }
return (MarketCategory.Other, GetSubcategory(rawCategory, tags, "Other"));
}
@@ -35,12 +79,12 @@ public static class MarketCategoryMapper
private static string GetSubcategory(string rawCategory, string tags, string fallback)
{
if (!string.IsNullOrWhiteSpace(rawCategory) && !rawCategory.Equals("OVERALL", StringComparison.OrdinalIgnoreCase))
- return rawCategory;
-
+ return rawCategory.Trim();
+
var firstTag = tags.Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim();
if (!string.IsNullOrWhiteSpace(firstTag))
return firstTag;
-
+
return fallback;
}
}
diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs
index 32d45e8..05fbf7a 100644
--- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs
+++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs
@@ -318,7 +318,7 @@ public class PolymarketProvider : IPlatformProvider
{
long.TryParse(raw.Id, out var marketNumericId);
- var catMap = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(raw.Category ?? "", parentTags);
+ var catMap = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(raw.Category ?? "", parentTags, raw.Question ?? "");
var market = new Market
{
diff --git a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs
index 9560ba3..82e01d6 100644
--- a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs
+++ b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs
@@ -367,6 +367,11 @@ public class PositionPnLEngine : IPositionPnLEngine
// Sync back to Trader record for quick sorting / UI display
trader.TotalPnl = overallPnl;
trader.WinRate = winRateOverall;
+ // Row count is the single source of truth for TotalTrades — the worker-side
+ // increment counters drift (INSERT IGNORE, deletions, historic imports) and
+ // produced impossible states like Trades30d > TotalTrades. Compacted rows
+ // count as 1 until an AggregatedCount column exists (FIXPLAN Teil D).
+ trader.TotalTrades = trades.Count;
if (trades.Count > 0 || trader.LastTradesUpdatedAt != null)
{
trader.LastAnalyzedAt = DateTime.UtcNow;
diff --git a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs b/src/Predictalytics.WinFormsHost/MainForm.Designer.cs
index eb8d899..c10239f 100644
--- a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs
+++ b/src/Predictalytics.WinFormsHost/MainForm.Designer.cs
@@ -23,162 +23,7 @@ partial class MainForm
statusStrip1 = new StatusStrip();
label_apiRatelimit = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
- tabControl1 = new TabControl();
- tabPage_terminal = new TabPage();
- rtb_terminal = new RichTextBox();
- tabPage2 = new TabPage();
- pg_settings = new PropertyGrid();
- menuStrip1 = new MenuStrip();
- filesToolStripMenuItem = new ToolStripMenuItem();
- editToolStripMenuItem = new ToolStripMenuItem();
- btn_logfolder = new ToolStripMenuItem();
- btn_openbrowser = new ToolStripMenuItem();
- developmentToolStripMenuItem = new ToolStripMenuItem();
- btn_dbReset = new ToolStripMenuItem();
- btn_syncmarkets = new ToolStripMenuItem();
- btn_dbUpdate = new ToolStripMenuItem();
- btn_recalcAll = new ToolStripMenuItem();
- toolStrip1.SuspendLayout();
- statusStrip1.SuspendLayout();
- tabControl1.SuspendLayout();
- tabPage_terminal.SuspendLayout();
- tabPage2.SuspendLayout();
- menuStrip1.SuspendLayout();
- SuspendLayout();
- //
- // toolStrip1
- //
- toolStrip1.ImageScalingSize = new Size(24, 24);
- toolStrip1.Items.AddRange(new ToolStripItem[] { btn_serverstart, btn_localWebserver });
- toolStrip1.Location = new Point(0, 33);
- toolStrip1.Name = "toolStrip1";
- toolStrip1.Size = new Size(1864, 34);
- toolStrip1.TabIndex = 0;
- //
- // btn_serverstart
- //
- btn_serverstart.ImageTransparentColor = Color.Magenta;
- btn_serverstart.Name = "btn_serverstart";
- btn_serverstart.Size = new Size(127, 29);
- btn_serverstart.Text = "▶ Start Server";
- //
- // btn_localWebserver
- //
- btn_localWebserver.ImageTransparentColor = Color.Magenta;
- btn_localWebserver.Name = "btn_localWebserver";
- btn_localWebserver.Size = new Size(161, 29);
- btn_localWebserver.Text = "▶ Start Webserver";
- //
- // statusStrip1
- //
- statusStrip1.ImageScalingSize = new Size(24, 24);
- statusStrip1.Items.AddRange(new ToolStripItem[] { label_apiRatelimit, label_buildVersion });
- statusStrip1.Location = new Point(0, 1000);
- statusStrip1.Name = "statusStrip1";
- statusStrip1.Size = new Size(1864, 32);
- statusStrip1.TabIndex = 1;
- //
- // label_apiRatelimit
- //
- label_apiRatelimit.Name = "label_apiRatelimit";
- label_apiRatelimit.Size = new Size(1782, 25);
- label_apiRatelimit.Spring = true;
- label_apiRatelimit.Text = "API: OK";
- label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
- //
- // label_buildVersion
- //
- label_buildVersion.Name = "label_buildVersion";
- label_buildVersion.Size = new Size(67, 25);
- label_buildVersion.Text = "Build: -";
- label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
- //
- // tabControl1
- //
- tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
- tabControl1.Controls.Add(tabPage_terminal);
- tabControl1.Controls.Add(tabPage2);
- tabControl1.Location = new Point(0, 61);
- tabControl1.Name = "tabControl1";
- tabControl1.SelectedIndex = 0;
- tabControl1.Size = new Size(1864, 946);
- tabControl1.TabIndex = 2;
- //
- // tabPage_terminal
- //
- tabPage_terminal.Controls.Add(rtb_terminal);
- tabPage_terminal.Location = new Point(4, 34);
- tabPage_terminal.Name = "tabPage_terminal";
- tabPage_terminal.Padding = new Padding(3);
- tabPage_terminal.Size = new Size(1856, 908);
- tabPage_terminal.TabIndex = 0;
- tabPage_terminal.Text = "Terminal";
- tabPage_terminal.UseVisualStyleBackColor = true;
- //
- // rtb_terminal
- //
- rtb_terminal.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
- rtb_terminal.Location = new Point(3, 6);
- rtb_terminal.Name = "rtb_terminal";
- rtb_terminal.Size = new Size(1847, 896);
- rtb_terminal.TabIndex = 0;
- rtb_terminal.Text = "";
- //
- // tabPage2
- //
- tabPage2.Controls.Add(pg_settings);
- tabPage2.Location = new Point(4, 34);
- tabPage2.Name = "tabPage2";
- tabPage2.Padding = new Padding(3);
- tabPage2.Size = new Size(1856, 908);
- tabPage2.TabIndex = 1;
- tabPage2.Text = "Settings";
- tabPage2.UseVisualStyleBackColor = true;
- //
- // pg_settings
- //
- pg_settings.Location = new Point(3, 6);
- pg_settings.Name = "pg_settings";
- pg_settings.Size = new Size(1850, 896);
- pg_settings.TabIndex = 0;
- //
- // menuStrip1
- //
- menuStrip1.ImageScalingSize = new Size(24, 24);
- menuStrip1.Items.AddRange(new ToolStripItem[] { filesToolStripMenuItem, editToolStripMenuItem, developmentToolStripMenuItem });
- menuStrip1.Location = new Point(0, 0);
- menuStrip1.Name = "menuStrip1";
- menuStrip1.Size = new Size(1864, 33);
- menuStrip1.TabIndex = 3;
- //
- // filesToolStripMenuItem
- //
- filesToolStripMenuItem.Name = "filesToolStripMenuItem";
- filesToolStripMenuItem.Size = new Size(62, 29);
- filesToolStripMenuItem.Text = "Files";
- //
- // editToolStripMenuItem
- //
- editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_logfolder, btn_openbrowser });
- editToolStripMenuItem.Name = "editToolStripMenuItem";
- editToolStripMenuItem.Size = new Size(58, 29);
- editToolStripMenuItem.Text = "Edit";
- //
- // btn_logfolder
- //
- btn_logfolder.Name = "btn_logfolder";
- btn_logfolder.Size = new Size(261, 34);
- btn_logfolder.Text = "Show Logfolder";
- btn_logfolder.Click += btn_logfolder_Click;
- //
- // btn_openbrowser
- toolStrip1 = new ToolStrip();
- btn_serverstart = new ToolStripButton();
- btn_localWebserver = new ToolStripButton();
- statusStrip1 = new StatusStrip();
- label_apiRatelimit = new ToolStripStatusLabel();
label_dbSize = new ToolStripStatusLabel();
- label_buildVersion = new ToolStripStatusLabel();
tabControl1 = new TabControl();
tabPage_terminal = new TabPage();
rtb_terminal = new RichTextBox();
@@ -242,13 +87,6 @@ partial class MainForm
label_apiRatelimit.Text = "API: OK";
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
//
- // label_dbSize
- //
- label_dbSize.Name = "label_dbSize";
- label_dbSize.Size = new Size(150, 25);
- label_dbSize.Text = "DB Size: -";
- label_dbSize.TextAlign = ContentAlignment.MiddleRight;
- //
// label_buildVersion
//
label_buildVersion.Name = "label_buildVersion";
@@ -256,6 +94,13 @@ partial class MainForm
label_buildVersion.Text = "Build: -";
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
//
+ // label_dbSize
+ //
+ label_dbSize.Name = "label_dbSize";
+ label_dbSize.Size = new Size(150, 25);
+ label_dbSize.Text = "DB Size: -";
+ label_dbSize.TextAlign = ContentAlignment.MiddleRight;
+ //
// tabControl1
//
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
@@ -351,27 +196,27 @@ partial class MainForm
// btn_dbReset
//
btn_dbReset.Name = "btn_dbReset";
- btn_dbReset.Size = new Size(270, 34);
+ btn_dbReset.Size = new Size(286, 34);
btn_dbReset.Text = "reset TradesDB";
//
// btn_syncmarkets
//
btn_syncmarkets.Name = "btn_syncmarkets";
- btn_syncmarkets.Size = new Size(270, 34);
+ btn_syncmarkets.Size = new Size(286, 34);
btn_syncmarkets.Text = "Sync Markets";
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
//
// btn_dbUpdate
//
btn_dbUpdate.Name = "btn_dbUpdate";
- btn_dbUpdate.Size = new Size(270, 34);
+ btn_dbUpdate.Size = new Size(286, 34);
btn_dbUpdate.Text = "UpdateDB";
btn_dbUpdate.Click += btn_dbUpdate_Click;
- //
+ //
// btn_recalcAll
- //
+ //
btn_recalcAll.Name = "btn_recalcAll";
- btn_recalcAll.Size = new Size(270, 34);
+ btn_recalcAll.Size = new Size(286, 34);
btn_recalcAll.Text = "Recalculate All Traders";
btn_recalcAll.Click += btn_recalcAll_Click;
//
@@ -385,6 +230,8 @@ partial class MainForm
Controls.Add(toolStrip1);
Controls.Add(menuStrip1);
MainMenuStrip = menuStrip1;
+ MaximumSize = new Size(1886, 1088);
+ MinimumSize = new Size(1886, 1088);
Name = "MainForm";
Text = "Predictalytics";
toolStrip1.ResumeLayout(false);