f3b2a69b31
- 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>
59 lines
2.7 KiB
TypeScript
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); });
|