diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts index acba804..02fc379 100644 --- a/app/api/matches/route.ts +++ b/app/api/matches/route.ts @@ -16,12 +16,16 @@ export async function GET() { try { const odds = await fetchOdds(); matches = attachOdds(rawMatches, teams, odds); - } catch { - // still ohne Wahrscheinlichkeiten + } catch (err) { + console.error("[polymarket] fetchOdds fehlgeschlagen:", err); } const groupTables = computeGroupTables(teams, matches); const groupTablesLive = computeGroupTables(teams, matches, true); + + // prob-Feld normalisieren: immer null statt undefined, damit JSON konsistent ist + const normalizedMatches = matches.map((m) => ({ ...m, prob: m.prob ?? null })); + const thirdTable = computeThirdPlaceTable(groupTablesLive); const qGroups = qualifiedThirdGroups(thirdTable); const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null; @@ -29,7 +33,7 @@ export async function GET() { return NextResponse.json({ updatedAt: new Date().toISOString(), teams, - matches, + matches: normalizedMatches, groupTables, groupTablesLive, thirdTable, diff --git a/lib/feeds.ts b/lib/feeds.ts index e9d70f6..9ebb3f8 100644 --- a/lib/feeds.ts +++ b/lib/feeds.ts @@ -159,79 +159,176 @@ export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams: } // ---------------------------------------------------------------------------- -// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel +// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel (Series soccer-fifwc) // ---------------------------------------------------------------------------- const PM_BASE = "https://gamma-api.polymarket.com"; +const PM_SERIES = "11433"; // WM 2026 Serie + +interface PmEvent { + id: string; + slug: string; + title: string; + markets?: PmMarket[]; +} interface PmMarket { - question: string; - outcomes: string; // JSON-String, z.B. "[\"Germany\",\"Draw\",\"Mexico\"]" - outcomePrices: string; // JSON-String, z.B. "[\"0.55\",\"0.25\",\"0.20\"]" slug: string; + groupItemTitle: string; + outcomes: string; // JSON-String ["Yes","No"] + outcomePrices: string; // JSON-String ["0.385","0.615"] + sportsMarketType?: string; closed: boolean; } -// Map: normalisierter Teamname-Schlüssel -> Wahrscheinlichkeit. -export interface OddsEntry { - outcomes: string[]; - prices: number[]; - question: string; +export interface ParsedOdds { + homeCode: string; + awayCode: string; + homeName: string; + awayName: string; + pHome: number; + pDraw: number; + pAway: number; +} + +// Normalisiert Teamcodes zwischen Polymarket-Slug und Feed (3-Buchstaben). +// Bekannte Abweichungen: kr/kor (Südkorea), nld/ned (Niederlande), cdr/cod (DR Kongo). +const CODE_NORM: Record = { + kor: "kr", + nld: "ned", + cdr: "cod", + cvi: "cpv", + rsa: "rsa", // ok + swe: "swe", // ok +}; + +function normCode(c: string): string { + return (CODE_NORM[c] ?? c).toLowerCase(); } function safeParse(s: string, fallback: T): T { try { return JSON.parse(s) as T; } catch { return fallback; } } -// Holt WM-bezogene Märkte über das Tag/Slug der Polymarket-WM-Kollektion. -// Hinweis: Der exakte Slug kann sich ändern; per ENV überschreibbar. -export async function fetchOdds(): Promise { - const slug = process.env.POLYMARKET_WC_SLUG || "world-cup-2026"; +// Holt alle WM-Spiele über den Series-Endpoint mit Pagination. +// Parst pro Event die drei Moneyline-Märkte (Heim/Draw/Auswärts). +export async function fetchOdds(): Promise { return cached("pm:odds", 120_000, async () => { - const res = await fetch( - `${PM_BASE}/events?slug=${encodeURIComponent(slug)}`, - { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" } }, - ); - if (!res.ok) throw new Error(`polymarket ${res.status}`); - const events = (await res.json()) as Array<{ markets?: PmMarket[] }>; - const entries: OddsEntry[] = []; - for (const ev of events) { + const allEvents: PmEvent[] = []; + for (let offset = 0; ; offset += 100) { + const url = `${PM_BASE}/events?series_id=${PM_SERIES}&active=true&closed=false&limit=100&offset=${offset}`; + const res = await fetch(url, { + cache: "no-store", + headers: { "User-Agent": "wm2026-board/1.0" }, + }); + if (!res.ok) throw new Error(`polymarket ${res.status}`); + const page = (await res.json()) as PmEvent[]; + allEvents.push(...page); + if (page.length < 100) break; + } + //console.log("[polymarket] events fetched:", allEvents.length); + + const parsed: ParsedOdds[] = []; + for (const ev of allEvents) { + // Slug: fifwc-{home}-{away}-{yyyy}-{mm}-{dd} + const slugParts = ev.slug.replace("fifwc-", "").split("-"); + if (slugParts.length < 5) continue; + const homeCode = normCode(slugParts[0]); + const awayCode = normCode(slugParts[1]); + + let pHome = 0, pDraw = 0, pAway = 0; + let homeName = "", awayName = ""; + for (const mk of ev.markets ?? []) { + if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue; if (mk.closed) continue; - const outcomes = safeParse(mk.outcomes, []); const prices = safeParse(mk.outcomePrices, []).map(Number); - if (outcomes.length && outcomes.length === prices.length) { - entries.push({ outcomes, prices, question: mk.question }); + if (prices.length < 2) continue; + const yes = prices[0]; // erster Preis = "Yes"-Wahrscheinlichkeit + + const gti = mk.groupItemTitle.toLowerCase(); + if (mk.slug.endsWith("-draw") || gti.startsWith("draw")) { + pDraw = yes; + } else { + // Heuristik: match gegen groupItemTitle (Teamname) + // Prüfe ob der Titel den Home-Code oder Away-Code enthält + // Besser: erster Markt = Home, dritter = Away; Draw in der Mitte. + // Da die Reihenfolge nicht garantiert ist: über slug-Suffix matchen. + if (mk.slug.endsWith("-" + slugParts[0].toLowerCase())) { + pHome = yes; + homeName = mk.groupItemTitle; + } else if (mk.slug.endsWith("-" + slugParts[1].toLowerCase())) { + pAway = yes; + awayName = mk.groupItemTitle; + } } } + + if (pHome > 0 || pDraw > 0 || pAway > 0) { + parsed.push({ homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway }); + } } - return entries; + + console.log("[polymarket] spiele:", parsed.length); + return parsed; }); } -// Verknüpft Polymarket-Märkte mit Spielen anhand der Teamnamen im Markttitel. -export function attachOdds(matches: Match[], teams: Team[], odds: OddsEntry[]): Match[] { - const nameById = new Map(teams.map((t) => [t.id, t.name.toLowerCase()])); +// Normalisiert Teamnamen für Matching (lowercase, diakritische Zeichen, aliase). +function normName(n: string): string { + return n + .toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // diakritische Zeichen entfernen + .replace(/[^a-z0-9 ]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +// Verknüpft Polymarket-Odds mit Feed-Matches über Teamnamen (primär) oder Codes. +export function attachOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] { + // Map: Team-ID → normierter Name + const nameById = new Map(teams.map((t) => [t.id, normName(t.name)])); + // Map: normierter Name → Team-ID (für Lookup) + const idByName = new Map(); + for (const t of teams) { + const nn = normName(t.name); + if (!idByName.has(nn)) idByName.set(nn, t.id); + } + // Map: normierter Code → Team-ID + const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id])); + + // Map: Polymarket ParsedOdds → { homeTeamId, awayTeamId } + const oddsMatch = new Map(); + for (const o of odds) { + const hn = normName(o.homeName); + const an = normName(o.awayName); + const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode); + const awayId = idByName.get(an) ?? idByCode.get(o.awayCode); + if (homeId && awayId) { + oddsMatch.set(o, { homeId, awayId }); + } else { + oddsMatch.set(o, null); + } + } + return matches.map((m) => { if (!m.homeTeamId || !m.awayTeamId) return m; - const hn = nameById.get(m.homeTeamId); - const an = nameById.get(m.awayTeamId); - if (!hn || !an) return m; - const hit = odds.find( - (o) => o.question.toLowerCase().includes(hn) && o.question.toLowerCase().includes(an), - ); - if (!hit) return m; - // Heuristik: 3 Outcomes = Heim/Unentschieden/Auswärts; 2 = Heim/Auswärts. - const idxHome = hit.outcomes.findIndex((o) => o.toLowerCase().includes(hn)); - const idxAway = hit.outcomes.findIndex((o) => o.toLowerCase().includes(an)); - const idxDraw = hit.outcomes.findIndex((o) => /draw|unentschieden|tie/i.test(o)); - if (idxHome < 0 || idxAway < 0) return m; - return { - ...m, - prob: { - home: hit.prices[idxHome] ?? 0, - draw: idxDraw >= 0 ? (hit.prices[idxDraw] ?? null) : null, - away: hit.prices[idxAway] ?? 0, - }, - }; + // Suche in oddsMatch nach einer Kombination die beide Team-IDs matcht + for (const [o, ids] of oddsMatch) { + if (!ids) continue; + // Beide Richtungen prüfen + if ((ids.homeId === m.homeTeamId && ids.awayId === m.awayTeamId) || + (ids.homeId === m.awayTeamId && ids.awayId === m.homeTeamId)) { + const swapped = ids.homeId === m.awayTeamId; + return { + ...m, + prob: { + home: swapped ? o.pAway : o.pHome, + draw: o.pDraw, + away: swapped ? o.pHome : o.pAway, + }, + }; + } + } + return m; }); } \ No newline at end of file diff --git a/lib/simulation.ts b/lib/simulation.ts index 613417b..97a3ded 100644 --- a/lib/simulation.ts +++ b/lib/simulation.ts @@ -23,18 +23,45 @@ export function saveOverrides(overrides: SimOverrides): void { } catch { /* quota exceeded */ } } -// Plausibles Standardergebnis aus Polymarket-Wahrscheinlichkeiten. -// Favorit gewinnt 1:0; bei hoher W'keit (>=0.65) 2:0. -// Ohne prob: Gruppe → 1:1, K.o. → null (Nutzer muss selbst eingeben). +// Plausibles Standardergebnis aus Polymarket-3-Wege-Wahrscheinlichkeiten. +// +// Schwellen (zentral): +// Draw am höchsten ODER max(home,away) < 0.40 → 0:0 (Gruppe) / 1:0 Heim (K.o.) +// Favorit 0.40–0.60 → 1:0 +// Favorit 0.60–0.78 → 2:0 +// Favorit > 0.78 → 3:0 +// +// Beispiele: +// Schweiz–Kanada (0.385/0.315/0.295) → 0:0 +// Schottland–Brasilien (0.10/0.17/0.75) → 0:2 +// Marokko–Haiti (0.83/0.13/0.05) → 3:0 export function defaultScore(match: Match): { homeScore: number; awayScore: number } | null { const prob = match.prob; if (!prob) { if (match.stage === "GROUP") return { homeScore: 1, awayScore: 1 }; return null; } + + const maxFA = Math.max(prob.home, prob.away); + const isDraw = prob.draw > prob.home && prob.draw > prob.away; + const isGroup = match.stage === "GROUP"; + + if (isDraw || maxFA < 0.40) { + if (isGroup) return { homeScore: 0, awayScore: 0 }; + return { homeScore: 1, awayScore: 0 }; // K.o.: knapp Heim (kein Remis) + } + + // Favorit bestimmen const homeFav = prob.home >= prob.away; - const favProb = Math.max(prob.home, prob.away); - const goals = favProb >= 0.65 ? 2 : 1; + let goals: number; + if (maxFA > 0.78) { + goals = 3; + } else if (maxFA >= 0.60) { + goals = 2; + } else { + goals = 1; // 0.40–0.60 + } + if (homeFav) return { homeScore: goals, awayScore: 0 }; return { homeScore: 0, awayScore: goals }; } diff --git a/lib/types.ts b/lib/types.ts index 0fee3a6..ecdf8a9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -33,7 +33,7 @@ export interface Match { homeScore: number | null; awayScore: number | null; // Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden - prob?: { home: number; draw: number | null; away: number } | null; + prob?: { home: number; draw: number; away: number } | null; venue?: string | null; // Austragungsort attendance?: number | null; // Zuschauerzahl, falls verfügbar }