Files
cup26/scripts/buildSquadValues.ts
T
NilsBriggen f3b2a69b31 v3 Phase B core: Team-DC + backtested ensemble, squad values
- src/lib/model/teamDc.ts: per-team attack/defence Dixon-Coles via weighted MLE
  (Maher iterations), exponential time-decay, shrinkage for thin histories,
  learned home multiplier; log-pool of score matrices. 7 vitest cases.
- buildBacktest.ts rewritten as a strict three-way-split bake-off: params
  pre-2018, tune (xi, k, w) on 2018-2022 validation, final scores on untouched
  2022-2026 test (4,448 matches). Result: ensemble RPS 0.1721 beats Elo-DC
  0.1726 and Team-DC 0.1801; ECE stays 0.01. Tuned: xi=0.5 k=5 w=0.75.
- Runtime now IS the backtested model: ratings.json carries teamDc+ensembleW;
  matchMatrix() pools both members for predictions, Monte Carlo and advance
  probs; ModelEngine re-rating preserves ensemble params; lambdas reported as
  matrix expectations (drives in-play).
- scripts/buildSquadValues.ts: Transfermarkt FIWC participants page -> all 48
  squad market values (France 1.52bn ... Qatar 20m). Display/availability layer
  only — June-2026 values are NOT in the backtested model (would leak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:23:51 +02:00

59 lines
2.7 KiB
TypeScript

// Transfermarkt FIWC participants page → public/data/squadvalues.json
// One page lists all 48 squads with total + average market value — far more
// robust than scraping 48 squad pages. Values move slowly, so this is a
// build-time artifact (re-run data:build to refresh). Used by the squad-value
// model layer and the team/squad UI; never overrides results-based ratings.
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { canonicalTeam, isKnownTeam } from '../src/lib/teams';
const URL = 'https://www.transfermarkt.com/weltmeisterschaft/teilnehmer/pokalwettbewerb/FIWC';
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'data');
function euros(num: string, unit: string): number {
const n = Number(num);
return Math.round(n * (unit === 'bn' ? 1e9 : unit === 'm' ? 1e6 : 1e3));
}
async function main(): Promise<void> {
const res = await fetch(URL, {
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`transfermarkt ${res.status}`);
const html = await res.text();
const out: Record<string, { totalValue: number; avgValue: number }> = {};
for (const tr of html.split('<tr').slice(1)) {
const link = tr.match(/<a[^>]*title="([^"]+)"[^>]*href="\/[a-z0-9\-]+\/startseite\/verein\/\d+"/);
const vals = [...tr.matchAll(/€([\d.]+)(bn|m|k)/g)].map((m) => euros(m[1]!, m[2]!));
if (!link || vals.length < 1) continue;
const team = canonicalTeam(link[1]!);
if (!isKnownTeam(team) || out[team]) continue; // 48 qualifiers only, first table wins
const totalValue = Math.max(...vals);
const avgValue = vals.length > 1 ? Math.min(...vals) : Math.round(totalValue / 26);
out[team] = { totalValue, avgValue };
}
const teams = Object.keys(out);
if (teams.length < 40) {
throw new Error(`only parsed ${teams.length}/48 squads — page layout may have changed; aborting (stale file kept)`);
}
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(
join(OUT_DIR, 'squadvalues.json'),
JSON.stringify({ fetchedAt: new Date().toISOString(), source: 'transfermarkt.com', teams: out }),
);
const top = teams.sort((a, b) => out[b]!.totalValue - out[a]!.totalValue).slice(0, 6)
.map((t) => `${t}${(out[t]!.totalValue / 1e9).toFixed(2)}bn`).join(', ');
console.log(`squad values → public/data/squadvalues.json (${teams.length} teams)`);
console.log(` top: ${top}`);
}
main().catch((e) => { console.error(e); process.exit(1); });