add derby page

This commit is contained in:
2026-09-20 02:23:12 +00:00
parent 97cbf90c3d
commit 6ec596bc24
6 changed files with 989 additions and 0 deletions

57
src/lib/derby/data.ts Normal file
View File

@@ -0,0 +1,57 @@
// Derby R 2026 · Duindigt · zondag 20 september · 2600 m autostart
// Bronnen: NDR deelnemerslijst, ZEturf koerskaart (records over 2600 m),
// Geny uitslagen Derby Selectie R (heat 1) en Merrie Derby R (heat 2), 4 september 2026.
export type Result = number | 'D';
export type Horse = {
n: number;
name: string;
driver: string;
trainer: string;
sex: 'M' | 'H' | 'R';
/** laatste vijf uitslagen, nieuwste eerst; D = gediskwalificeerd */
form: Result[];
/** beste kilometertijd over 2600 m in seconden, null = onbekend */
record: number | null;
/** Derbyproef 4 september (2000 m autostart) */
trial: { heat: 1 | 2; pos: number; time: number; odds: number };
note?: string;
};
export const race = {
name: 'Derby R 2026',
date: 'zo 20 sep · 17:40',
track: 'Duindigt',
distance: 2600,
start: 'Autostart',
purse: 74000,
surface: 'Sintelbaan · goed',
sources: [
{
label: 'NDR deelnemerslijst',
href: 'https://ndr.nl/wp-content/uploads/2026/09/Ingeschreven-paarden-in-de-Derby-R-Duindigt-zondag-20-september.pdf'
},
{
label: 'ZEturf koerskaart',
href: 'https://www.zeturf.be/nl/course-du-jour/2026-09-20/R14C6-duindigt-derby-r'
},
{ label: 'Proef heat 1', href: 'https://www.geny.com/course/1681375/arrivee-rapports' },
{ label: 'Proef heat 2', href: 'https://www.geny.com/course/1681376/arrivee-rapports' }
]
};
const t = (m: number, s: number) => m * 60 + s;
export const horses: Horse[] = [
{ n: 1, name: 'Rihane', driver: 'Micha Brouwer', trainer: 'Danny Brouwer', sex: 'M', form: [2, 3, 9, 7, 4], record: t(1, 13.7), trial: { heat: 2, pos: 2, time: t(1, 17.7), odds: 3.0 } },
{ n: 2, name: 'Rose Starlake', driver: 'Jeffrey Mieras', trainer: 'Dion Tesselaar', sex: 'M', form: [1, 2, 2, 1, 'D'], record: t(1, 13.4), trial: { heat: 2, pos: 1, time: t(1, 17.3), odds: 3.5 } },
{ n: 3, name: 'Rising Sun Boko', driver: 'Danny Brouwer', trainer: 'Danny Brouwer', sex: 'H', form: [2, 3, 1, 4, 1], record: t(1, 14.2), trial: { heat: 1, pos: 2, time: t(1, 13.8), odds: 23.1 } },
{ n: 4, name: 'Roy Schermer', driver: 'Robin Bakker', trainer: 'Paul Hagoort', sex: 'R', form: [3, 2, 'D', 2, 1], record: t(1, 11.8), trial: { heat: 1, pos: 3, time: t(1, 14.5), odds: 7.8 } },
{ n: 5, name: 'Rolleman Flevo', driver: 'Dion Tesselaar', trainer: 'Dion Tesselaar', sex: 'H', form: [1, 1, 1, 1, 1], record: null, trial: { heat: 1, pos: 1, time: t(1, 13.8), odds: 1.3 }, note: '13 uit 13' },
{ n: 6, name: 'Royal Express', driver: 'Jim Veldman', trainer: 'Paul Hagoort', sex: 'M', form: [3, 1, 6, 1, 3], record: t(1, 12.4), trial: { heat: 2, pos: 3, time: t(1, 17.8), odds: 1.8 } },
{ n: 7, name: 'Ram Jam', driver: 'Michel Rothengatter', trainer: 'Michel Rothengatter', sex: 'R', form: [4, 1, 6, 1, 1], record: t(1, 11.5), trial: { heat: 1, pos: 4, time: t(1, 14.6), odds: 8.8 } },
{ n: 8, name: 'Rex Beuckenswijk', driver: 'Jaap van Rijn', trainer: 'Jeroen Engwerda', sex: 'R', form: [6, 4, 2, 1, 1], record: null, trial: { heat: 1, pos: 6, time: t(1, 15.2), odds: 34.9 } },
{ n: 9, name: 'Ragna L Kite', driver: 'Rob de Vlieger', trainer: 'Rob de Vlieger', sex: 'M', form: [4, 2, 3, 1, 3], record: t(1, 14.8), trial: { heat: 2, pos: 4, time: t(1, 17.8), odds: 6.8 } },
{ n: 10, name: 'Ritchy Diamond', driver: 'Patrick de Haan', trainer: 'Michel Rothengatter', sex: 'R', form: [5, 3, 1, 'D', 2], record: null, trial: { heat: 1, pos: 5, time: t(1, 14.7), odds: 20.9 } }
];

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { horses } from './data';
import { betValue, defaultWeights, placings, predict } from './model';
describe('derby model', () => {
it('produces a probability distribution', () => {
const preds = predict(horses, defaultWeights);
const sum = preds.reduce((a, r) => a + r.p, 0);
expect(sum).toBeCloseTo(1, 9);
expect(preds[0].horse.n).toBe(5);
});
it('renormalises after scratching', () => {
const preds = predict(horses.filter((h) => h.n !== 5), defaultWeights);
expect(preds.reduce((a, r) => a + r.p, 0)).toBeCloseTo(1, 9);
expect(preds.find((r) => r.horse.n === 5)).toBeUndefined();
});
it('place probabilities are consistent', () => {
const preds = predict(horses, defaultWeights);
const { top, exacta, koppel, trio } = placings(preds, defaultWeights.lambda);
for (const key of ['win', 'place2', 'place3'] as const) {
expect(top.reduce((a, t) => a + t[key], 0)).toBeCloseTo(1, 9);
}
expect(exacta.reduce((a, e) => a + e.p, 0)).toBeCloseTo(1, 9);
expect(koppel.reduce((a, e) => a + e.p, 0)).toBeCloseTo(1, 9);
expect(trio.reduce((a, e) => a + e.p, 0)).toBeCloseTo(1, 9);
expect(koppel.length).toBe(45);
expect(trio.length).toBe(720);
});
it('kelly is zero without edge', () => {
expect(betValue(0.2, 4).kelly).toBe(0);
expect(betValue(0.5, 3).edge).toBeCloseTo(0.5);
expect(betValue(0.5, 3).kelly).toBeCloseTo(0.25);
});
});

184
src/lib/derby/model.ts Normal file
View File

@@ -0,0 +1,184 @@
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<string, Partial<Weights>> = {
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<number, number> = { 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<FeatureKey, number>;
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<FeatureKey, number[]>;
const rows = runners.map((horse, i) => {
const contributions = Object.fromEntries(
featureKeys.map((k) => [k, w[k] * z[k][i] * SCALE])
) as Record<FeatureKey, number>;
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<string, { a: number; b: number; p: number }>();
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 = <T extends { p: number }>(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('.', '"')}`;
}

44
src/lib/derby/practice.ts Normal file
View File

@@ -0,0 +1,44 @@
// Oefenrondes: verzonnen toto-noteringen om de pagina te leren lezen vóór de echte koers.
export type Scenario = {
key: string;
title: string;
story: string;
lesson: string;
scratched: number[];
/** win-noteringen per startnummer; ontbreekt = wordt uit de modelkans afgeleid */
odds?: Record<number, number>;
/** plaatsnoteringen per startnummer */
place?: Record<number, number>;
};
export const scenarios: Scenario[] = [
{
key: 'overladen',
title: 'De favoriet wordt overladen',
story: 'Iedereen op de baan zet op Rolleman Flevo. Zijn notering zakt naar 1,20 en de rest wordt duur.',
lesson:
'Rolleman winnend is rood: bij 1,20 moet hij meer dan 83% van de tijd winnen om quitte te spelen, en zo zeker is zelfs een ongeslagen paard niet. De waarde zit bij plaatsweddenschappen op paarden die de toto vergeet.',
scratched: [],
odds: { 5: 1.2, 3: 16, 2: 11, 4: 28, 7: 22, 6: 14, 1: 35, 9: 70, 10: 90, 8: 120 },
place: { 3: 3.2, 2: 2.6, 7: 4.5, 4: 5 }
},
{
key: 'eens',
title: 'De toto is het eens met het model',
story: 'De noteringen liggen precies in lijn met de kansen van het model, met de gebruikelijke marge van 20% erop.',
lesson:
'Alles is rood. Als de kansen kloppen, wint de marge van de toto altijd. Niet wedden is dan de juiste zet — en dat is een prima uitkomst van een middag kijken.',
scratched: []
},
{
key: 'nietstarter',
title: 'Rolleman Flevo start niet',
story: 'Vlak voor de start wordt nummer 5 teruggetrokken. De toto moet zich in een paar minuten herstellen.',
lesson:
'Zonder de favoriet worden de kansen herverdeeld en worden Rising Sun Boko en Rose Starlake de favorieten. Vink een niet-starter altijd meteen uit, anders klopt geen enkel getal meer.',
scratched: [5],
odds: { 3: 3.5, 2: 3.2, 4: 7, 7: 6, 6: 5, 1: 12, 9: 25, 10: 30, 8: 40 },
place: { 3: 1.6, 2: 1.5, 7: 2.2 }
}
];