import type { Horse, Result } from './data'; export type Weights = { form: number; trial: number; record: number; draw: number; market: number; risk: number; /** halveringstijd van de vormweging, in koersen */ halfLife: number; /** seconden die de (langzamere) merrieproef wordt bestraft t.o.v. heat 1 */ heatGap: number; /** softmax-temperatuur: >1 vlakker, <1 scherper */ temperature: number; /** Stern-demping voor plaatskansen: lagere posities gebruiken p^lambda */ lambda: number; }; export const defaultWeights: Weights = { form: 1, trial: 1, record: 0.6, draw: 0.4, market: 0.6, risk: 1, halfLife: 2, heatGap: 0.8, temperature: 1, lambda: 0.8 }; export const presets: Record> = { Standaard: {}, Proef: { form: 0.5, trial: 1.6, record: 0.3, draw: 0.4, market: 0 }, Vorm: { form: 1.6, trial: 0.5, record: 0.5, draw: 0.3, market: 0 }, Markt: { form: 0.3, trial: 0.3, record: 0.3, draw: 0.2, market: 1.6 } }; export const featureKeys = ['form', 'trial', 'record', 'draw', 'market'] as const; export type FeatureKey = (typeof featureKeys)[number]; /** logit-schaal: z-scores × gewichten worden hiermee vermenigvuldigd */ const SCALE = 0.55; const POSITION_POINTS: Record = { 1: 1, 2: 0.75, 3: 0.6, 4: 0.45, 5: 0.35, 6: 0.25 }; const positionPoints = (r: Result) => (r === 'D' ? 0 : (POSITION_POINTS[r] ?? 0.1)); /** binnenste startnummers zijn goud, 9 en 10 staan in de tweede rij */ const DRAW_SCORE = [0, 0.6, 0.7, 0.6, 0.45, 0.4, 0.25, 0.1, 0, -0.2, -0.3]; export type Features = { form: number; trial: number; record: number; draw: number; market: number; pFail: number; recordEstimated: boolean; }; export function features(h: Horse, all: Horse[], w: Weights): Features { const decay = h.form.map((_, i) => Math.pow(0.5, i / w.halfLife)); const form = h.form.reduce((sum: number, r, i) => sum + decay[i] * positionPoints(r), 0) / decay.reduce((a, b) => a + b, 0); const heatWinner = Math.min(...all.filter((o) => o.trial.heat === h.trial.heat).map((o) => o.trial.time)); const trial = -(h.trial.time - heatWinner) - (h.trial.heat === 2 ? w.heatGap : 0); const known = all.filter((o) => o.record !== null).map((o) => o.record as number); const recordEstimated = h.record === null; const record = -(h.record ?? known.reduce((a, b) => a + b, 0) / known.length); const disq = h.form.filter((r) => r === 'D').length; const poor = h.form.filter((r) => r !== 'D' && r >= 7).length; const pFail = Math.min(0.5, 0.03 + 0.1 * disq + 0.03 * poor); return { form, trial, record, draw: DRAW_SCORE[h.n], market: -Math.log(h.trial.odds), pFail, recordEstimated }; } function zscores(values: number[]): number[] { const mean = values.reduce((a, b) => a + b, 0) / values.length; const sd = Math.sqrt(values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length); return values.map((v) => (sd === 0 ? 0 : (v - mean) / sd)); } export type Prediction = { horse: Horse; features: Features; /** bijdrage per feature aan de logit (gewicht × z × schaal) */ contributions: Record; riskPenalty: number; logit: number; p: number; }; export function predict(runners: Horse[], w: Weights): Prediction[] { if (runners.length === 0) return []; const feats = runners.map((h) => features(h, runners, w)); const z = Object.fromEntries(featureKeys.map((k) => [k, zscores(feats.map((f) => f[k]))])) as Record; const rows = runners.map((horse, i) => { const contributions = Object.fromEntries( featureKeys.map((k) => [k, w[k] * z[k][i] * SCALE]) ) as Record; const riskPenalty = w.risk * Math.log(1 - feats[i].pFail); const logit = Object.values(contributions).reduce((a, b) => a + b, 0) + riskPenalty; return { horse, features: feats[i], contributions, riskPenalty, logit, p: 0 }; }); const max = Math.max(...rows.map((r) => r.logit)); const exp = rows.map((r) => Math.exp((r.logit - max) / w.temperature)); const total = exp.reduce((a, b) => a + b, 0); rows.forEach((r, i) => (r.p = exp[i] / total)); return rows.sort((a, b) => b.p - a.p); } export type Placings = { /** kans op 1e, 2e, 3e per paard (index = positie in `preds`) */ top: { win: number; place2: number; place3: number }[]; exacta: { a: number; b: number; p: number }[]; koppel: { a: number; b: number; p: number }[]; trio: { a: number; b: number; c: number; p: number }[]; }; /** * Exacte Plackett–Luce-uitwerking over alle geordende drietallen, met * Stern-demping: voor de 2e en 3e plaats wordt p^lambda gebruikt, wat de * neiging van pure PL corrigeert om de favoriet te vaak tweede te laten worden. */ export function placings(preds: Prediction[], lambda: number): Placings { const n = preds.length; const p1 = preds.map((r) => r.p); const p2 = p1.map((p) => Math.pow(p, lambda)); const s2 = p2.reduce((a, b) => a + b, 0); const top = preds.map(() => ({ win: 0, place2: 0, place3: 0 })); const exacta: Placings['exacta'] = []; const trio: Placings['trio'] = []; for (let a = 0; a < n; a++) { top[a].win = p1[a]; for (let b = 0; b < n; b++) { if (b === a) continue; const pab = p1[a] * (p2[b] / (s2 - p2[a])); exacta.push({ a, b, p: pab }); top[b].place2 += pab; for (let c = 0; c < n; c++) { if (c === a || c === b) continue; const pabc = pab * (p2[c] / (s2 - p2[a] - p2[b])); trio.push({ a, b, c, p: pabc }); top[c].place3 += pabc; } } } const koppelMap = new Map(); for (const e of exacta) { const [a, b] = e.a < e.b ? [e.a, e.b] : [e.b, e.a]; const key = `${a}-${b}`; const cur = koppelMap.get(key) ?? { a, b, p: 0 }; cur.p += e.p; koppelMap.set(key, cur); } const desc = (xs: T[]) => xs.sort((x, y) => y.p - x.p); return { top, exacta: desc(exacta), koppel: desc([...koppelMap.values()]), trio: desc(trio) }; } /** verwachte waarde per euro en Kelly-fractie voor een winweddenschap */ export function betValue(p: number, odds: number) { const edge = p * odds - 1; const kelly = odds > 1 ? Math.max(0, edge / (odds - 1)) : 0; return { edge, kelly }; } export const fairOdds = (p: number) => (p > 0 ? 1 / p : Infinity); export function formatTime(seconds: number) { const m = Math.floor(seconds / 60); const s = seconds - m * 60; return `${m}'${s.toFixed(1).padStart(4, '0').replace('.', '"')}`; }