polymarket slug

This commit is contained in:
2026-06-24 11:28:26 -05:00
parent 253b8cfbcd
commit ddaa1db876
4 changed files with 185 additions and 57 deletions

View File

@@ -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,

View File

@@ -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<string, string> = {
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<T>(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<OddsEntry[]> {
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<ParsedOdds[]> {
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<string[]>(mk.outcomes, []);
const prices = safeParse<string[]>(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<string, string>();
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<ParsedOdds, { homeId: string; awayId: string } | null>();
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;
});
}

View File

@@ -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.400.60 → 1:0
// Favorit 0.600.78 → 2:0
// Favorit > 0.78 → 3:0
//
// Beispiele:
// SchweizKanada (0.385/0.315/0.295) → 0:0
// SchottlandBrasilien (0.10/0.17/0.75) → 0:2
// MarokkoHaiti (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.400.60
}
if (homeFav) return { homeScore: goals, awayScore: 0 };
return { homeScore: 0, awayScore: goals };
}

View File

@@ -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
}