From d2f3ec2bd001cbba5a7dc5c02be7322a40d96daa Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 11 Jul 2026 14:09:12 +0200 Subject: [PATCH] Fix category mapping and TotalTrades drift, plan traits/tiering (Teil D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- FIXPLAN-2026-07-09.md | 90 +++++++++ .../Services/MarketCategoryMapperTests.cs | 55 ++++++ .../Services/MarketRepositoryTests.cs | 93 +++++++++ .../Services/PositionPnLEngineTests.cs | 55 ++++++ .../Data/Repositories/MarketRepository.cs | 11 +- .../Helpers/MarketCategoryMapper.cs | 92 ++++++--- .../Polymarket/PolymarketProvider.cs | 2 +- .../Services/PositionPnLEngine.cs | 5 + .../MainForm.Designer.cs | 183 ++---------------- 9 files changed, 391 insertions(+), 195 deletions(-) create mode 100644 src/Predictalytics.Application.Tests/Services/MarketCategoryMapperTests.cs create mode 100644 src/Predictalytics.Application.Tests/Services/MarketRepositoryTests.cs 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);