add derby page
This commit is contained in:
57
src/lib/derby/data.ts
Normal file
57
src/lib/derby/data.ts
Normal 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 } }
|
||||
];
|
||||
37
src/lib/derby/model.test.ts
Normal file
37
src/lib/derby/model.test.ts
Normal 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
184
src/lib/derby/model.ts
Normal 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
44
src/lib/derby/practice.ts
Normal 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 }
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user