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>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// 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); });
|
||||
Reference in New Issue
Block a user