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
+90
View File
@@ -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 7. Watchlist: Toggle auf der Detailseite wechselt sichtbar den Zustand; die neue Watchlist-Seite listet die
beobachteten Trader; Remove funktioniert. *(A10)* beobachteten Trader; Remove funktioniert. *(A10)*
8. „Run Deep Analysis" (KI) füllt die AI Strategy Analysis auf der Detailseite tatsächlich. *(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).
@@ -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); 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})");
}
}
} }
@@ -192,8 +192,15 @@ public class MarketRepository : IMarketRepository
existing.PlatformMarketId = updated.PlatformMarketId; existing.PlatformMarketId = updated.PlatformMarketId;
existing.QuestionId = updated.QuestionId; existing.QuestionId = updated.QuestionId;
existing.Description = updated.Description; 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.Volume = updated.Volume;
existing.Volume24h = updated.Volume24h; existing.Volume24h = updated.Volume24h;
existing.Liquidity = updated.Liquidity; existing.Liquidity = updated.Liquidity;
@@ -1,33 +1,77 @@
using System.Text.RegularExpressions;
using Predictalytics.Domain.Enums; using Predictalytics.Domain.Enums;
namespace Predictalytics.Infrastructure.Helpers; namespace Predictalytics.Infrastructure.Helpers;
public static class MarketCategoryMapper public static class MarketCategoryMapper
{ {
public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags) /// <summary>
/// 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".
/// </summary>
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")) (MarketCategory.Crypto,
return (MarketCategory.Politics, GetSubcategory(rawCategory, tags, "Elections")); new[] { "crypto", "bitcoin", "ethereum", "solana", "dogecoin", "blockchain", "stablecoin",
"binance", "coinbase", "airdrop", "halving", "memecoin", "altcoin", "defi", "satoshi" },
if (searchString.Contains("crypto") || searchString.Contains("bitcoin") || searchString.Contains("eth") || searchString.Contains("solana")) new[] { "btc", "eth", "sol", "xrp", "doge", "nft", "bnb", "ada", "usdt", "usdc" }),
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"));
if (searchString.Contains("economy") || searchString.Contains("finance") || searchString.Contains("business") || searchString.Contains("fed")) (MarketCategory.Sports,
return (MarketCategory.Economy, GetSubcategory(rawCategory, tags, "Economy")); 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" }),
};
/// <summary>
/// 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.
/// </summary>
public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags, string question = "")
{
var searchString = $"{rawCategory} {tags} {question}".ToLowerInvariant();
var tokens = new HashSet<string>(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")); 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) private static string GetSubcategory(string rawCategory, string tags, string fallback)
{ {
if (!string.IsNullOrWhiteSpace(rawCategory) && !rawCategory.Equals("OVERALL", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrWhiteSpace(rawCategory) && !rawCategory.Equals("OVERALL", StringComparison.OrdinalIgnoreCase))
return rawCategory; return rawCategory.Trim();
var firstTag = tags.Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); var firstTag = tags.Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim();
if (!string.IsNullOrWhiteSpace(firstTag)) if (!string.IsNullOrWhiteSpace(firstTag))
return firstTag; return firstTag;
return fallback; return fallback;
} }
} }
@@ -318,7 +318,7 @@ public class PolymarketProvider : IPlatformProvider
{ {
long.TryParse(raw.Id, out var marketNumericId); 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 var market = new Market
{ {
@@ -367,6 +367,11 @@ public class PositionPnLEngine : IPositionPnLEngine
// Sync back to Trader record for quick sorting / UI display // Sync back to Trader record for quick sorting / UI display
trader.TotalPnl = overallPnl; trader.TotalPnl = overallPnl;
trader.WinRate = winRateOverall; 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) if (trades.Count > 0 || trader.LastTradesUpdatedAt != null)
{ {
trader.LastAnalyzedAt = DateTime.UtcNow; trader.LastAnalyzedAt = DateTime.UtcNow;
+15 -168
View File
@@ -23,162 +23,7 @@ partial class MainForm
statusStrip1 = new StatusStrip(); statusStrip1 = new StatusStrip();
label_apiRatelimit = new ToolStripStatusLabel(); label_apiRatelimit = new ToolStripStatusLabel();
label_buildVersion = 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_dbSize = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
tabControl1 = new TabControl(); tabControl1 = new TabControl();
tabPage_terminal = new TabPage(); tabPage_terminal = new TabPage();
rtb_terminal = new RichTextBox(); rtb_terminal = new RichTextBox();
@@ -242,13 +87,6 @@ partial class MainForm
label_apiRatelimit.Text = "API: OK"; label_apiRatelimit.Text = "API: OK";
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft; 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
// //
label_buildVersion.Name = "label_buildVersion"; label_buildVersion.Name = "label_buildVersion";
@@ -256,6 +94,13 @@ partial class MainForm
label_buildVersion.Text = "Build: -"; label_buildVersion.Text = "Build: -";
label_buildVersion.TextAlign = ContentAlignment.MiddleRight; 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
// //
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
@@ -351,27 +196,27 @@ partial class MainForm
// btn_dbReset // btn_dbReset
// //
btn_dbReset.Name = "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_dbReset.Text = "reset TradesDB";
// //
// btn_syncmarkets // btn_syncmarkets
// //
btn_syncmarkets.Name = "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.Text = "Sync Markets";
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click; btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
// //
// btn_dbUpdate // btn_dbUpdate
// //
btn_dbUpdate.Name = "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.Text = "UpdateDB";
btn_dbUpdate.Click += btn_dbUpdate_Click; btn_dbUpdate.Click += btn_dbUpdate_Click;
// //
// btn_recalcAll // btn_recalcAll
// //
btn_recalcAll.Name = "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.Text = "Recalculate All Traders";
btn_recalcAll.Click += btn_recalcAll_Click; btn_recalcAll.Click += btn_recalcAll_Click;
// //
@@ -385,6 +230,8 @@ partial class MainForm
Controls.Add(toolStrip1); Controls.Add(toolStrip1);
Controls.Add(menuStrip1); Controls.Add(menuStrip1);
MainMenuStrip = menuStrip1; MainMenuStrip = menuStrip1;
MaximumSize = new Size(1886, 1088);
MinimumSize = new Size(1886, 1088);
Name = "MainForm"; Name = "MainForm";
Text = "Predictalytics"; Text = "Predictalytics";
toolStrip1.ResumeLayout(false); toolStrip1.ResumeLayout(false);