Merge pull request 'staging' (#38) from staging into main
Reviewed-on: #38
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,3 +19,5 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
.vscode/
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
CREATE TYPE "availability" AS ENUM('catchable', 'trade_only', 'transfer_only', 'event_only');--> statement-breakpoint
|
||||
CREATE TABLE "animals" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"species" text NOT NULL,
|
||||
"breed" text,
|
||||
"animal_name" text,
|
||||
"description" text,
|
||||
"ai_breed_suggestion" text,
|
||||
"ai_breed_confidence" real,
|
||||
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"accepted_at" timestamp with time zone,
|
||||
"denied_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "blog_articles" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" varchar(256),
|
||||
"slug" varchar(256),
|
||||
"content" varchar(8192),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "certificates" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" varchar(256),
|
||||
"progress" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "game" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" varchar(256) NOT NULL,
|
||||
"generation" integer DEFAULT 1 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "photos" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"sighting_id" uuid NOT NULL,
|
||||
"r2_key" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pokemon" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"number" integer NOT NULL,
|
||||
"generation" integer DEFAULT 1 NOT NULL,
|
||||
"name" varchar(256) NOT NULL,
|
||||
"type1" varchar(256) NOT NULL,
|
||||
"type2" varchar(256),
|
||||
"hp" integer DEFAULT 0 NOT NULL,
|
||||
"attack" integer DEFAULT 0 NOT NULL,
|
||||
"defense" integer DEFAULT 0 NOT NULL,
|
||||
"special_attack" integer DEFAULT 0 NOT NULL,
|
||||
"special_defense" integer DEFAULT 0 NOT NULL,
|
||||
"speed" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pokemon_game_entry" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"pokemon_id" integer NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
"availability" "availability" DEFAULT 'catchable'::"availability" NOT NULL,
|
||||
"note" varchar(256),
|
||||
CONSTRAINT "pokemon_game_entry_pokemon_id_game_id_unique" UNIQUE("pokemon_id","game_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "publications" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" varchar(256),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sightings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"animal_id" uuid NOT NULL,
|
||||
"reporter_name" text,
|
||||
"seen_at" timestamp with time zone NOT NULL,
|
||||
"lat" double precision NOT NULL,
|
||||
"lng" double precision NOT NULL,
|
||||
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"accepted_at" timestamp with time zone,
|
||||
"denied_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" varchar(256),
|
||||
"current_points" integer DEFAULT 0,
|
||||
"total_points" integer DEFAULT 1,
|
||||
"notes" varchar(8192),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks_to_topics" (
|
||||
"task_id" integer NOT NULL,
|
||||
"topic_id" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "topics" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" varchar(256),
|
||||
"emoji" varchar(256),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "universities" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" varchar(256),
|
||||
"progress" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_entry" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"pokemon_game_entry_id" integer NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
CONSTRAINT "user_entry_pokemon_game_entry_id_user_id_unique" UNIQUE("pokemon_game_entry_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_game" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"game_id" integer NOT NULL,
|
||||
CONSTRAINT "user_game_user_id_game_id_unique" UNIQUE("user_id","game_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "account" (
|
||||
"id" text PRIMARY KEY,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp,
|
||||
"refresh_token_expires_at" timestamp,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "session" (
|
||||
"id" text PRIMARY KEY,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"token" text NOT NULL UNIQUE,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" text PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL UNIQUE,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification" (
|
||||
"id" text PRIMARY KEY,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pokemon_game_entry_game_id_index" ON "pokemon_game_entry" ("game_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_entry_user_id_index" ON "user_entry" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_game_user_id_index" ON "user_game" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "account_userId_idx" ON "account" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "session_userId_idx" ON "session" ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification" ("identifier");--> statement-breakpoint
|
||||
ALTER TABLE "photos" ADD CONSTRAINT "photos_sighting_id_sightings_id_fkey" FOREIGN KEY ("sighting_id") REFERENCES "sightings"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "pokemon_game_entry" ADD CONSTRAINT "pokemon_game_entry_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "pokemon_game_entry" ADD CONSTRAINT "pokemon_game_entry_game_id_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "game"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "sightings" ADD CONSTRAINT "sightings_animal_id_animals_id_fkey" FOREIGN KEY ("animal_id") REFERENCES "animals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "tasks_to_topics" ADD CONSTRAINT "tasks_to_topics_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id");--> statement-breakpoint
|
||||
ALTER TABLE "tasks_to_topics" ADD CONSTRAINT "tasks_to_topics_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "topics"("id");--> statement-breakpoint
|
||||
ALTER TABLE "user_entry" ADD CONSTRAINT "user_entry_pokemon_game_entry_id_pokemon_game_entry_id_fkey" FOREIGN KEY ("pokemon_game_entry_id") REFERENCES "pokemon_game_entry"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "user_entry" ADD CONSTRAINT "user_entry_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "user_game" ADD CONSTRAINT "user_game_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "user_game" ADD CONSTRAINT "user_game_game_id_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "game"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;
|
||||
File diff suppressed because it is too large
Load Diff
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 }
|
||||
}
|
||||
];
|
||||
@@ -64,7 +64,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center gap-4 py-4 px-4">
|
||||
<header class="flex justify-between items-center gap-4 py-4 px-4 border-b-4 border-gray-300">
|
||||
<a href={resolve('/')} class="text-2xl font-bold underline">Stan Runge</a>
|
||||
<div class="flex gap-8">
|
||||
<div class="text-center py-2 font-bold text-xl ">
|
||||
|
||||
@@ -46,18 +46,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each data.blogArticles as article (article.slug)}
|
||||
<div class="flex gap-2 px-4">
|
||||
<a href={resolve('/blog/[slug]', { slug: article.slug })}>{article.title}</a>
|
||||
{#if data.isAdmin && article.status === 'draft'}
|
||||
<span class="text-xs uppercase text-orange-500">draft</span>
|
||||
<a href={resolve('/blog/[slug]/edit', { slug: article.slug })} class="text-xs underline"
|
||||
>edit</a
|
||||
>
|
||||
{/if}
|
||||
<p>{article.createdAt?.toDateString()}</p>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
{#each data.blogArticles as article (article.slug)}
|
||||
<a href={resolve('/blog/[slug]', { slug: article.slug })}>
|
||||
<div class="flex gap-2 py-2 px-4 border rounded min-w-100">
|
||||
<p class="font-bold text-md">{article.title}</p>
|
||||
{#if data.isAdmin && article.status === 'draft'}
|
||||
<span class="text-xs uppercase text-orange-500">draft</span>
|
||||
<a href={resolve('/blog/[slug]/edit', { slug: article.slug })} class="text-xs underline"
|
||||
>edit</a
|
||||
>
|
||||
{/if}
|
||||
<p>{article.createdAt?.toDateString()}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if createNewArticlePopup}
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
|
||||
|
||||
46
src/routes/projects/+page.svelte
Normal file
46
src/routes/projects/+page.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: 'Animaldex',
|
||||
href: '/projects/animaldex',
|
||||
description: 'World map with pets & other animals around the neighborhood'
|
||||
},
|
||||
{
|
||||
name: 'CatchEmAll',
|
||||
href: '/projects/catch-em-all',
|
||||
description: 'Table of all 1025 pokemon across 38 games with checkboxes for catching them all'
|
||||
},
|
||||
{
|
||||
name: 'Derby Lens',
|
||||
href: '/projects/derby',
|
||||
description: 'Experimental win-probability model for the Duindigt Derby R 2026 with scenario sliders'
|
||||
},
|
||||
{
|
||||
name: 'CS Prices',
|
||||
href: '/projects/cs-prices',
|
||||
description: 'Dashboard for CS2 skin price histories, trends and predictions'
|
||||
},
|
||||
{
|
||||
name: 'Spotify Beatmap Finder',
|
||||
href: '/projects/spotify-beatmap-finder',
|
||||
description: 'Search for osu! beatmaps with Spotify songs/albums/playlists'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold underline py-8">Projects ({projects.length})</h1>
|
||||
|
||||
<ul class="flex flex-col gap-4 items-center">
|
||||
{#each projects as project (project.name)}
|
||||
<a class="border min-w-160 py-2 text-center rounded-lg" href={resolve(project.href)}>
|
||||
<h1 class="font-bold text-xl">
|
||||
{project.name}
|
||||
</h1>
|
||||
<p>{project.description}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
662
src/routes/projects/derby/+page.svelte
Normal file
662
src/routes/projects/derby/+page.svelte
Normal file
@@ -0,0 +1,662 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { horses, race, type Horse } from '$lib/derby/data';
|
||||
import { scenarios } from '$lib/derby/practice';
|
||||
import {
|
||||
betValue,
|
||||
defaultWeights,
|
||||
fairOdds,
|
||||
featureKeys,
|
||||
formatTime,
|
||||
placings,
|
||||
predict,
|
||||
presets,
|
||||
type FeatureKey,
|
||||
type Prediction,
|
||||
type Weights
|
||||
} from '$lib/derby/model';
|
||||
|
||||
type Saved = {
|
||||
w: Weights;
|
||||
scratched: number[];
|
||||
odds: Record<number, number | null>;
|
||||
placeOdds: Record<number, number | null>;
|
||||
blend: number;
|
||||
bankroll: number;
|
||||
fraction: number;
|
||||
help: boolean;
|
||||
practice: string | null;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'derby-lens-v2';
|
||||
|
||||
let w = $state<Weights>({ ...defaultWeights });
|
||||
let scratched = $state<number[]>([]);
|
||||
let odds = $state<Record<number, number | null>>({});
|
||||
let placeOdds = $state<Record<number, number | null>>({});
|
||||
let blend = $state(0.5);
|
||||
let bankroll = $state(100);
|
||||
let fraction = $state(0.25);
|
||||
let help = $state(true);
|
||||
let practice = $state<string | null>(null);
|
||||
let scratchChecked = $state(false);
|
||||
let loaded = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
const runners = $derived(horses.filter((h) => !scratched.includes(h.n)));
|
||||
const preds = $derived(predict(runners, w));
|
||||
const pl = $derived(placings(preds, w.lambda));
|
||||
const favourite = $derived(preds[0]);
|
||||
const heatBest = $derived(
|
||||
[1, 2].map((heat) => Math.min(...runners.filter((h) => h.trial.heat === heat).map((h) => h.trial.time)))
|
||||
);
|
||||
const trialLabel = (h: Horse) => {
|
||||
const gap = h.trial.time - heatBest[h.trial.heat - 1];
|
||||
return `heat ${h.trial.heat} · ${h.trial.pos}e · ${gap === 0 ? formatTime(h.trial.time) : `+${gap.toFixed(1)} s`}`;
|
||||
};
|
||||
const activePreset = $derived(
|
||||
Object.entries(presets).find(([, p]) =>
|
||||
(Object.keys(defaultWeights) as (keyof Weights)[]).every(
|
||||
(k) => w[k] === (p[k] ?? defaultWeights[k])
|
||||
)
|
||||
)?.[0]
|
||||
);
|
||||
|
||||
// toto: winkansen volgens de noteringen; pas bruikbaar als élk paard er een heeft
|
||||
const toto = $derived.by(() => {
|
||||
const implied = preds.map((r) => {
|
||||
const o = odds[r.horse.n];
|
||||
return o && o > 1 ? 1 / o : 0;
|
||||
});
|
||||
const entered = implied.filter((x) => x > 0).length;
|
||||
const overround = implied.reduce((a, b) => a + b, 0);
|
||||
const complete = entered === preds.length && preds.length > 0;
|
||||
return { entered, overround, complete, p: complete ? implied.map((x) => x / overround) : null };
|
||||
});
|
||||
const blending = $derived(toto.p !== null && blend > 0);
|
||||
|
||||
// eindkans: model en toto log-lineair gemengd; zonder complete toto = model
|
||||
const final = $derived.by(() => {
|
||||
if (!toto.p || blend === 0) return preds.map((r) => r.p);
|
||||
const logs = preds.map((r, i) => (1 - blend) * Math.log(r.p) + blend * Math.log(toto.p![i]));
|
||||
const max = Math.max(...logs);
|
||||
const exp = logs.map((l) => Math.exp(l - max));
|
||||
const sum = exp.reduce((a, b) => a + b, 0);
|
||||
return exp.map((x) => x / sum);
|
||||
});
|
||||
const plFinal = $derived(placings(preds.map((r, i) => ({ ...r, p: final[i] })), w.lambda));
|
||||
const placeP = (i: number) => plFinal.top[i].win + plFinal.top[i].place2 + plFinal.top[i].place3;
|
||||
|
||||
type Bet = { type: 'Winnend' | 'Plaats'; r: Prediction; p: number; odds: number; edge: number; kelly: number; stake: number };
|
||||
const bets = $derived.by(() => {
|
||||
const list: Bet[] = [];
|
||||
preds.forEach((r, i) => {
|
||||
const add = (type: Bet['type'], p: number, o: number | null | undefined) => {
|
||||
if (!o || o <= 1) return;
|
||||
const { edge, kelly } = betValue(p, o);
|
||||
list.push({ type, r, p, odds: o, edge, kelly, stake: bankroll * fraction * kelly });
|
||||
};
|
||||
add('Winnend', final[i], odds[r.horse.n]);
|
||||
add('Plaats', placeP(i), placeOdds[r.horse.n]);
|
||||
});
|
||||
return list.sort((a, b) => b.edge - a.edge);
|
||||
});
|
||||
const goodBets = $derived(bets.filter((b) => b.kelly > 0));
|
||||
const scenario = $derived(scenarios.find((sc) => sc.key === practice) ?? null);
|
||||
|
||||
function startPractice(key: string) {
|
||||
const sc = scenarios.find((x) => x.key === key)!;
|
||||
scratched = [...sc.scratched];
|
||||
blend = 0.5;
|
||||
if (sc.odds) {
|
||||
odds = { ...sc.odds };
|
||||
placeOdds = { ...(sc.place ?? {}) };
|
||||
} else {
|
||||
// noteringen precies volgens het model, met 20% marge: fair / 1.2
|
||||
const rows = predict(horses.filter((h) => !sc.scratched.includes(h.n)), w);
|
||||
const pl2 = placings(rows, w.lambda);
|
||||
odds = Object.fromEntries(rows.map((r) => [r.horse.n, Math.round((10 / (r.p * 1.2)) ) / 10]));
|
||||
placeOdds = Object.fromEntries(
|
||||
rows.slice(0, 4).map((r, i) => {
|
||||
const t = pl2.top[i];
|
||||
return [r.horse.n, Math.round(10 / ((t.win + t.place2 + t.place3) * 1.2)) / 10];
|
||||
})
|
||||
);
|
||||
}
|
||||
practice = key;
|
||||
}
|
||||
|
||||
function clearPractice() {
|
||||
odds = {};
|
||||
placeOdds = {};
|
||||
scratched = [];
|
||||
practice = null;
|
||||
}
|
||||
|
||||
const steps = $derived([
|
||||
{
|
||||
title: 'Controleer de niet-starters',
|
||||
detail: 'Staat er een paard op het bord of in de app als teruggetrokken? Vink het uit in de tabel.',
|
||||
done: scratchChecked,
|
||||
status: scratchChecked ? 'gecontroleerd' : `${scratched.length} uitgevinkt`,
|
||||
href: '#winkans'
|
||||
},
|
||||
{
|
||||
title: 'Vul de win-noteringen in',
|
||||
detail: 'Alle tien, uit de kolom "Winnend" op het bord of in de app. Zo laat mogelijk: de toto beweegt tot de start.',
|
||||
done: toto.complete,
|
||||
status: `${toto.entered} van ${preds.length}`,
|
||||
href: '#winkans'
|
||||
},
|
||||
{
|
||||
title: 'Vul plaatsnoteringen in voor je kandidaten',
|
||||
detail: 'Alleen voor de drie of vier paarden die je overweegt. Plaats = bij de eerste drie.',
|
||||
done: Object.values(placeOdds).some((v) => (v ?? 0) > 1),
|
||||
status: `${Object.values(placeOdds).filter((v) => (v ?? 0) > 1).length} ingevuld`,
|
||||
href: '#plaats'
|
||||
},
|
||||
{
|
||||
title: 'Wed alleen op groen',
|
||||
detail: 'De lijst "Waarde & inzet" is het antwoord. Rood = niet doen, ook niet de favoriet. Niets groen = niet wedden.',
|
||||
done: bets.length > 0,
|
||||
status: bets.length === 0 ? 'nog geen noteringen' : goodBets.length === 0 ? 'niets groen: niet wedden' : `${goodBets.length} met waarde`,
|
||||
href: '#waarde'
|
||||
}
|
||||
]);
|
||||
|
||||
const featureLabel: Record<FeatureKey, string> = {
|
||||
form: 'Uitslagen',
|
||||
trial: 'Proef',
|
||||
record: 'Snelste tijd',
|
||||
draw: 'Startpositie',
|
||||
market: 'Wedmarkt'
|
||||
};
|
||||
const sexLabel: Record<Horse['sex'], string> = { M: 'merrie', H: 'hengst', R: 'ruin' };
|
||||
|
||||
const sliders: { key: keyof Weights; label: string; hint: string; min: number; max: number; step: number; fmt?: (v: number) => string }[] = [
|
||||
{ key: 'form', label: 'Recente uitslagen', hint: 'De laatste vijf koersen. Winnen telt het zwaarst, een D (uit de draf gegaan) telt als nul.', min: 0, max: 2, step: 0.1 },
|
||||
{ key: 'trial', label: 'Derbyproef', hint: 'De kwalificatiekoers van 4 september, waarin alle tien paarden liepen. Hoeveel seconden kwam een paard achter de winnaar van zijn heat binnen?', min: 0, max: 2, step: 0.1 },
|
||||
{ key: 'record', label: 'Snelste tijd', hint: 'Beste kilometertijd ooit over 2600 m. Drie paarden hebben er geen; die krijgen het gemiddelde.', min: 0, max: 2, step: 0.1 },
|
||||
{ key: 'draw', label: 'Startpositie', hint: 'Lage nummers starten aan de binnenkant en hebben de kortste weg. 9 en 10 starten op de tweede rij.', min: 0, max: 2, step: 0.1 },
|
||||
{ key: 'market', label: 'Wedmarkt bij de proef', hint: 'Hoeveel geld er op elk paard werd ingezet bij de Derbyproef. De markt weet vaak meer dan de cijfers.', min: 0, max: 2, step: 0.1 },
|
||||
{ key: 'risk', label: 'Straf voor uitvallen', hint: 'Paarden die eerder gediskwalificeerd werden of ver achteraan eindigden, krijgen aftrek.', min: 0, max: 3, step: 0.1 },
|
||||
{ key: 'halfLife', label: 'Geheugen', hint: 'Hoe snel oudere uitslagen minder meetellen. Bij 2 koersen weegt de koers van drie keer geleden nog de helft.', min: 1, max: 5, step: 0.5, fmt: (v) => `${v} koersen` },
|
||||
{ key: 'heatGap', label: 'Handicap merrieproef', hint: 'De merries liepen hun eigen, tragere proef (heat 2). Hoeveel seconden trek je daarvoor af?', min: 0, max: 2, step: 0.1, fmt: (v) => `${v.toFixed(1)} s` },
|
||||
{ key: 'temperature', label: 'Voorzichtigheid', hint: 'Hoger = kansen dichter bij elkaar (de favoriet krijgt minder). Lager = het model is zekerder van zijn zaak.', min: 0.5, max: 2, step: 0.05, fmt: (v) => v.toFixed(2) },
|
||||
{ key: 'lambda', label: 'Plaatskans-correctie', hint: 'Alleen voor 2e en 3e plaats. Lager = de favoriet eindigt minder vaak precies tweede, wat in de praktijk klopt.', min: 0.5, max: 1, step: 0.05, fmt: (v) => v.toFixed(2) }
|
||||
];
|
||||
|
||||
const pct = (p: number, d = 1) => `${(p * 100).toFixed(d)}%`;
|
||||
const oddsFmt = (o: number) => (o === Infinity ? '—' : o >= 10 ? o.toFixed(0) : o.toFixed(2));
|
||||
const signed = (v: number, d = 2) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(d)}`;
|
||||
const euro = (v: number) => `€${v.toFixed(v >= 100 ? 0 : 2)}`;
|
||||
const name = (i: number) => `${preds[i].horse.n} ${preds[i].horse.name}`;
|
||||
const cellStyle = (v: number, span = 1) => {
|
||||
const a = Math.min(0.55, Math.abs(v) / span);
|
||||
return v >= 0 ? `background:rgba(158,231,196,${a})` : `background:rgba(244,111,67,${a})`;
|
||||
};
|
||||
|
||||
function applyPreset(nameKey: string) {
|
||||
w = { ...defaultWeights, ...presets[nameKey] };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
w = { ...defaultWeights };
|
||||
scratched = [];
|
||||
odds = {};
|
||||
placeOdds = {};
|
||||
}
|
||||
|
||||
function toggleScratch(h: Horse) {
|
||||
scratched = scratched.includes(h.n) ? scratched.filter((n) => n !== h.n) : [...scratched, h.n];
|
||||
}
|
||||
|
||||
function snapshot(): Saved {
|
||||
return { w: { ...w }, scratched: [...scratched], odds: { ...odds }, placeOdds: { ...placeOdds }, blend, bankroll, fraction, help, practice };
|
||||
}
|
||||
|
||||
function restore(s: Partial<Saved>) {
|
||||
if (s.w) w = { ...defaultWeights, ...s.w };
|
||||
if (s.scratched) scratched = s.scratched;
|
||||
if (s.odds) odds = s.odds;
|
||||
if (s.placeOdds) placeOdds = s.placeOdds;
|
||||
if (typeof s.blend === 'number') blend = s.blend;
|
||||
if (typeof s.bankroll === 'number') bankroll = s.bankroll;
|
||||
if (typeof s.fraction === 'number') fraction = s.fraction;
|
||||
if (typeof s.help === 'boolean') help = s.help;
|
||||
if (s.practice !== undefined) practice = s.practice;
|
||||
}
|
||||
|
||||
function encodeHash(): string {
|
||||
const p = new URLSearchParams();
|
||||
p.set('w', (Object.keys(defaultWeights) as (keyof Weights)[]).map((k) => w[k]).join(','));
|
||||
if (scratched.length) p.set('s', scratched.join(','));
|
||||
const o = Object.entries(odds).filter(([, v]) => (v ?? 0) > 1);
|
||||
if (o.length) p.set('o', o.map(([n, v]) => `${n}:${v}`).join(','));
|
||||
const po = Object.entries(placeOdds).filter(([, v]) => (v ?? 0) > 1);
|
||||
if (po.length) p.set('po', po.map(([n, v]) => `${n}:${v}`).join(','));
|
||||
p.set('bl', String(blend));
|
||||
p.set('b', String(bankroll));
|
||||
p.set('f', String(fraction));
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
function decodeHash(hash: string): Partial<Saved> | null {
|
||||
const p = new URLSearchParams(hash.replace(/^#/, ''));
|
||||
if (!p.has('w')) return null;
|
||||
const keys = Object.keys(defaultWeights) as (keyof Weights)[];
|
||||
const vals = p.get('w')!.split(',').map(Number);
|
||||
const wv = Object.fromEntries(keys.map((k, i) => [k, Number.isFinite(vals[i]) ? vals[i] : defaultWeights[k]])) as Weights;
|
||||
const s = (p.get('s') ?? '').split(',').filter(Boolean).map(Number);
|
||||
const pairs = (key: string) =>
|
||||
Object.fromEntries((p.get(key) ?? '').split(',').filter(Boolean).map((x) => x.split(':').map(Number)));
|
||||
return {
|
||||
w: wv,
|
||||
scratched: s,
|
||||
odds: pairs('o'),
|
||||
placeOdds: pairs('po'),
|
||||
blend: p.has('bl') ? Number(p.get('bl')) : 0.5,
|
||||
bankroll: Number(p.get('b')) || 100,
|
||||
fraction: Number(p.get('f')) || 0.25
|
||||
};
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const url = `${location.origin}${location.pathname}#${encodeHash()}`;
|
||||
history.replaceState(null, '', `#${encodeHash()}`);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
} catch {
|
||||
/* clipboard geblokkeerd: de URL staat al in de adresbalk */
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const fromHash = decodeHash(location.hash);
|
||||
if (fromHash) restore(fromHash);
|
||||
else {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) restore(JSON.parse(raw));
|
||||
} catch {
|
||||
/* geen opslag beschikbaar */
|
||||
}
|
||||
}
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const s = snapshot();
|
||||
if (!loaded) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* geen opslag beschikbaar */
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Derby Lens — Duindigt 2026</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/derby-favicon.svg" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="shell">
|
||||
<main class="inner">
|
||||
<nav class="topbar">
|
||||
<div class="brand"><span class="mark"></span>DERBY LENS</div>
|
||||
<div class="topbar-right">
|
||||
<button class="help-toggle" class:on={help} onclick={() => (help = !help)}>Uitleg {help ? 'aan' : 'uit'}</button>
|
||||
<div class="event-pill">{race.date.toUpperCase()} · {race.track.toUpperCase()}</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{#if scenario}
|
||||
<div class="practice-banner">
|
||||
<span><b>Oefenmodus</b> · verzonnen noteringen — {scenario.title}</span>
|
||||
<button onclick={clearPractice}>Wis en begin echt</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-main">
|
||||
<div class="eyebrow">Kansmodel · 6 signalen</div>
|
||||
<h1>DERBY<br />R 2026</h1>
|
||||
<div class="facts">
|
||||
<div class="fact"><b>{race.distance.toLocaleString('nl')} m</b><span>{race.start}</span></div>
|
||||
<div class="fact"><b>€{(race.purse / 1000).toFixed(0)}K</b><span>Prijzengeld</span></div>
|
||||
<div class="fact"><b>{runners.length}</b><span>Starters</span></div>
|
||||
<div class="fact"><b>Goed</b><span>Sintelbaan</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{#if favourite}
|
||||
<div class="pick">
|
||||
<div class="pick-label">Model favoriet · nr {favourite.horse.n}</div>
|
||||
<div class="pick-name">{favourite.horse.name}</div>
|
||||
<div class="pick-meta">
|
||||
<span class="prob">{pct(favourite.p)}</span>
|
||||
<span class="confidence">
|
||||
winkans · fair notering {oddsFmt(fairOdds(favourite.p))}<br />
|
||||
bij de eerste drie: {pct(pl.top[0].win + pl.top[0].place2 + pl.top[0].place3, 0)}
|
||||
{#if favourite.horse.note}<br />{favourite.horse.note} gewonnen{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="guide" id="stappen">
|
||||
<div class="guide-intro">
|
||||
<div class="section-kicker">Zo gebruik je dit op de dag zelf</div>
|
||||
<h2>Vier stappen, twintig minuten voor 17:40</h2>
|
||||
<p>De toto houdt ongeveer 20% van elke euro in. Structureel winnen lukt bijna niemand, ook dit model niet. Wat deze pagina wél doet: de weddenschappen die zéker verliezen eruit halen, en de rest klein en netjes doseren. Tijdens de koers zelf hoef je niets te doen — die duurt ruim drie minuten.</p>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
{#each steps as st, i (st.title)}
|
||||
<li class:done={st.done}>
|
||||
<span class="step-n">{st.done ? '✓' : i + 1}</span>
|
||||
<div>
|
||||
<a href={st.href}>{st.title}</a>
|
||||
<p>{st.detail}</p>
|
||||
{#if i === 0}
|
||||
<label class="step-check"><input type="checkbox" bind:checked={scratchChecked} /> Ik heb het gecontroleerd</label>
|
||||
{:else}
|
||||
<span class="step-status">{st.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="practice">
|
||||
<div class="section-kicker">Oefenen</div>
|
||||
<p>Laad verzonnen noteringen en kijk wat er gebeurt. Wis daarna alles voordat je de echte invult.</p>
|
||||
<div class="scenarios">
|
||||
{#each scenarios as sc (sc.key)}
|
||||
<button class:active={practice === sc.key} onclick={() => startPractice(sc.key)}>{sc.title}</button>
|
||||
{/each}
|
||||
<button class="clear" onclick={clearPractice} disabled={!practice && bets.length === 0 && scratched.length === 0}>Wis</button>
|
||||
</div>
|
||||
{#if scenario}
|
||||
<div class="lesson">
|
||||
<p><b>Situatie.</b> {scenario.story}</p>
|
||||
<p><b>Wat je moet zien.</b> {scenario.lesson}</p>
|
||||
<a href="#waarde">Naar de lijst ↓</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<aside class="controls">
|
||||
<div class="section-kicker">Wat telt mee?</div>
|
||||
{#if help}<p class="hint">Het model geeft elk paard een score uit zes signalen. Met de schuiven bepaal je hoe zwaar elk signaal weegt; de knoppen zijn snelle instellingen.</p>{/if}
|
||||
<div class="presets">
|
||||
{#each Object.keys(presets) as key (key)}
|
||||
<button class:active={activePreset === key} onclick={() => applyPreset(key)}>{key}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each sliders as s (s.key)}
|
||||
<div class="control">
|
||||
<label for={s.key}>{s.label} <output>{s.fmt ? s.fmt(w[s.key]) : w[s.key].toFixed(1)}</output></label>
|
||||
<input id={s.key} type="range" min={s.min} max={s.max} step={s.step} bind:value={w[s.key]} />
|
||||
{#if help}<p class="hint">{s.hint}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="actions">
|
||||
<button class="ghost" onclick={reset}>Herstel</button>
|
||||
<button class="ghost" onclick={copyLink}>{copied ? 'Gekopieerd' : 'Kopieer link'}</button>
|
||||
</div>
|
||||
<p class="notice">Experimenteel model op openbare data, gemaakt door iemand zonder paardenkennis. Geen advies.</p>
|
||||
</aside>
|
||||
|
||||
<div class="board" id="winkans">
|
||||
<header class="board-head">
|
||||
<div><div class="section-kicker">{runners.length} starters{#if scenario} · oefening{/if}</div><h2>Winkans</h2></div>
|
||||
</header>
|
||||
{#if help}<p class="hint pad">Kans dat elk paard wint volgens het model, hoogste bovenaan. <b>Fair</b> is de notering waarbij een weddenschap precies quitte speelt. Vul bij <b>Notering</b> de echte toto-notering in: staat er dan een groene <b>Waarde</b>, dan schat het model dat paard hoger in dan de toto. Vink een paard uit als het niet start.<br />Onder de naam: rijder en de laatste vijf uitslagen, nieuwste eerst. D = gediskwalificeerd.</p>{/if}
|
||||
<div class="horse-list">
|
||||
<div class="horse head">
|
||||
<span></span><span>#</span><span>Paard</span><span></span>
|
||||
<span class="r">Kans</span><span class="r">Fair</span><span class="r">Notering</span><span class="r">Waarde</span>
|
||||
</div>
|
||||
{#each preds as r, i (r.horse.n)}
|
||||
{@const o = odds[r.horse.n]}
|
||||
{@const v = o && o > 1 ? betValue(r.p, o) : null}
|
||||
<div class="horse" class:winner={i === 0}>
|
||||
<input type="checkbox" checked aria-label="starter" onchange={() => toggleScratch(r.horse)} />
|
||||
<div class="num">{r.horse.n}</div>
|
||||
<div>
|
||||
<div class="horse-name">{r.horse.name}</div>
|
||||
<div class="driver">{r.horse.driver} · <span class="nowrap">{r.horse.form.join('-')}</span></div>
|
||||
</div>
|
||||
<div class="bar-wrap"><div class="bar" style:width={`${Math.max(1.5, r.p * 100)}%`}></div></div>
|
||||
<div class="chance">{pct(r.p)}</div>
|
||||
<div class="fair">{oddsFmt(fairOdds(r.p))}</div>
|
||||
<input class="odds" type="number" min="1.01" step="0.1" placeholder="—" bind:value={odds[r.horse.n]} aria-label="notering {r.horse.name}" />
|
||||
<div class="edge" class:pos={v && v.edge > 0} class:neg={v && v.edge <= 0}>{v ? signed(v.edge * 100, 0) + '%' : ''}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#each horses.filter((h) => scratched.includes(h.n)) as h (h.n)}
|
||||
<div class="horse scratched">
|
||||
<input type="checkbox" aria-label="starter" onchange={() => toggleScratch(h)} />
|
||||
<div class="num">{h.n}</div>
|
||||
<div><div class="horse-name">{h.name}</div><div class="driver">niet-starter</div></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lower">
|
||||
<article class="card" id="plaats">
|
||||
<div class="section-kicker">Wedvormen naast winnen · {blending ? 'model + toto gemengd' : 'volgens het model'}</div>
|
||||
<h2>Plaats & combinaties</h2>
|
||||
{#if help}<p class="hint">Bij een <b>plaatsweddenschap</b> win je als het paard bij de eerste drie eindigt — kleinere uitbetaling, grotere kans. Vul bij <b>Toto</b> de plaatsnotering in om te zien of er waarde zit. <b>Koppel</b>: je kiest de eerste twee, volgorde maakt niet uit. <b>Trio</b>: de eerste drie in de juiste volgorde. Fair = notering waarbij je quitte speelt.</p>{/if}
|
||||
<table class="tbl">
|
||||
<thead><tr><th>#</th><th>Paard</th><th class="r opt">1e</th><th class="r opt">2e</th><th class="r opt">3e</th><th class="r">Plaats</th><th class="r">Fair</th><th class="r">Toto</th><th class="r">Waarde</th></tr></thead>
|
||||
<tbody>
|
||||
{#each preds as r, i (r.horse.n)}
|
||||
{@const t = plFinal.top[i]}
|
||||
{@const place = placeP(i)}
|
||||
{@const po = placeOdds[r.horse.n]}
|
||||
{@const v = po && po > 1 ? betValue(place, po) : null}
|
||||
<tr>
|
||||
<td class="mono">{r.horse.n}</td>
|
||||
<td>{r.horse.name}</td>
|
||||
<td class="r mono opt">{pct(t.win, 0)}</td>
|
||||
<td class="r mono opt">{pct(t.place2, 0)}</td>
|
||||
<td class="r mono opt">{pct(t.place3, 0)}</td>
|
||||
<td class="r mono strong">{pct(place, 0)}</td>
|
||||
<td class="r mono">{oddsFmt(fairOdds(place))}</td>
|
||||
<td class="r"><input class="odds" type="number" min="1.01" step="0.1" placeholder="—" bind:value={placeOdds[r.horse.n]} aria-label="plaatsnotering {r.horse.name}" /></td>
|
||||
<td class="r mono strong" class:pos={v && v.edge > 0} class:neg={v && v.edge <= 0}>{v ? signed(v.edge * 100, 0) + '%' : ''}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="combos">
|
||||
<div>
|
||||
<div class="section-kicker">Koppel · 1–2 elke volgorde</div>
|
||||
{#each plFinal.koppel.slice(0, 6) as k (`${k.a}-${k.b}`)}
|
||||
<div class="combo"><span>{name(k.a)} + {name(k.b)}</span><b>{pct(k.p)}</b><i>{oddsFmt(fairOdds(k.p))}</i></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-kicker">Trio · 1–2–3 juiste volgorde</div>
|
||||
{#each plFinal.trio.slice(0, 6) as t (`${t.a}-${t.b}-${t.c}`)}
|
||||
<div class="combo"><span>{preds[t.a].horse.n} · {preds[t.b].horse.n} · {preds[t.c].horse.n}</span><b>{pct(t.p)}</b><i>{oddsFmt(fairOdds(t.p))}</i></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card" id="waarde">
|
||||
<div class="section-kicker">Model vs. toto</div>
|
||||
<h2>Waarde & inzet</h2>
|
||||
{#if help}<p class="hint">De toto is de mening van alle wedders samen en zit er meestal minder naast dan één model. Daarom wordt de <b>eindkans</b> gemengd: hoe hoger het vertrouwen in de toto, hoe sterker het model het oneens moet zijn voordat een paard waarde toont. <b>Waarde</b> is wat je per ingezette euro verwacht te winnen: +19% betekent €1,19 terug per €1. <b>Inzet</b> volgt uit Kelly: de wiskundig ideale inzet als deel van je bankroll, met een kwart als veilige fractie.</p>{/if}
|
||||
<div class="control blend">
|
||||
<label for="blend">Vertrouwen in de toto <output>{toto.complete ? pct(blend, 0) : `vul alle ${preds.length} noteringen in`}</output></label>
|
||||
<input id="blend" type="range" min="0" max="1" step="0.05" bind:value={blend} disabled={!toto.complete} />
|
||||
</div>
|
||||
<div class="bank">
|
||||
<label>Bankroll <input type="number" min="0" step="10" bind:value={bankroll} /></label>
|
||||
<label>Kelly-fractie <input type="number" min="0.05" max="1" step="0.05" bind:value={fraction} /></label>
|
||||
</div>
|
||||
{#if bets.length === 0}
|
||||
<p class="muted">Vul noteringen in bij de paarden (winnend hierboven, plaats hiernaast). Waarde = kans × notering − 1.</p>
|
||||
{:else}
|
||||
<div class="stats">
|
||||
<div><b>{toto.entered}/{preds.length}</b><span>noteringen</span></div>
|
||||
<div><b>{toto.complete ? signed((toto.overround - 1) * 100, 0) + '%' : '—'}</b><span>toto-marge</span></div>
|
||||
<div><b>{goodBets.length}</b><span>met waarde</span></div>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>#</th><th>Paard</th><th>Type</th><th class="r">Toto</th><th class="r">Eindkans</th><th class="r">Waarde</th><th class="r">Inzet</th></tr></thead>
|
||||
<tbody>
|
||||
{#each bets as b (`${b.type}-${b.r.horse.n}`)}
|
||||
<tr class:dim={b.kelly === 0}>
|
||||
<td class="mono">{b.r.horse.n}</td>
|
||||
<td>{b.r.horse.name}</td>
|
||||
<td class="muted">{b.type}</td>
|
||||
<td class="r mono">{oddsFmt(b.odds)}</td>
|
||||
<td class="r mono">{pct(b.p)}</td>
|
||||
<td class="r mono strong" class:pos={b.edge > 0} class:neg={b.edge <= 0}>{signed(b.edge * 100, 0)}%</td>
|
||||
<td class="r mono strong">{b.kelly > 0 ? euro(b.stake) : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if goodBets.length === 0}
|
||||
<p class="muted">Geen enkele weddenschap heeft positieve waarde bij deze noteringen. Dat is een prima uitkomst: niet wedden is dan de beste zet.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<article class="card wide">
|
||||
<div class="section-kicker">Per signaal · groen = pluspunt · oranje = minpunt</div>
|
||||
<h2>Waarom deze kansen</h2>
|
||||
{#if help}<p class="hint">Elk paard wordt per signaal vergeleken met de rest van het veld: beter dan gemiddeld is groen, slechter is oranje. Het grote getal is hoeveel het signaal bijdraagt aan de score (na weging), het kleine getal eronder is de ruwe waarde. De scores bij elkaar geven de kans.</p>{/if}
|
||||
<div class="scroll">
|
||||
<table class="tbl comp">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Paard</th>
|
||||
{#each featureKeys as k (k)}<th class="r">{featureLabel[k]}</th>{/each}
|
||||
<th class="r">Uitval</th><th class="r">Score</th><th class="r">Kans</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preds as r (r.horse.n)}
|
||||
{@const f = r.features}
|
||||
{@const h = r.horse}
|
||||
<tr>
|
||||
<td class="mono">{h.n}</td>
|
||||
<td>{h.name}<small>{sexLabel[h.sex]} · tr. {h.trainer}</small></td>
|
||||
<td class="r mono" style={cellStyle(r.contributions.form)}>{signed(r.contributions.form)}<small>{f.form.toFixed(2)} van 1</small></td>
|
||||
<td class="r mono" style={cellStyle(r.contributions.trial)}>{signed(r.contributions.trial)}<small>{trialLabel(h)}</small></td>
|
||||
<td class="r mono" style={cellStyle(r.contributions.record)}>{signed(r.contributions.record)}<small>{h.record ? formatTime(h.record) : 'onbekend'}</small></td>
|
||||
<td class="r mono" style={cellStyle(r.contributions.draw)}>{signed(r.contributions.draw)}<small>nr {h.n}{h.n > 8 ? ' · 2e rij' : ''}</small></td>
|
||||
<td class="r mono" style={cellStyle(r.contributions.market)}>{signed(r.contributions.market)}<small>notering {h.trial.odds.toFixed(1)}</small></td>
|
||||
<td class="r mono" style={cellStyle(r.riskPenalty, 0.5)}>{signed(r.riskPenalty)}<small>{pct(f.pFail, 0)} kans op D</small></td>
|
||||
<td class="r mono strong">{signed(r.logit)}</td>
|
||||
<td class="r mono strong">{pct(r.p)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<details class="method" open={help}>
|
||||
<summary>Begrippen voor wie nooit naar de draf kijkt</summary>
|
||||
<div class="method-body">
|
||||
<ul>
|
||||
<li><b>Draverij</b> — de paarden trekken een karretje (sulky) met de rijder erop en moeten in draf blijven. Gaat een paard in galop, dan wordt het gediskwalificeerd: de <b>D</b> in de uitslagen.</li>
|
||||
<li><b>Autostart</b> — de paarden starten achter een rijdende auto met uitklapbare hekken, op volgorde van startnummer: 1 aan de binnenkant, 1 t/m 8 op de eerste rij, 9 en 10 op de tweede rij.</li>
|
||||
<li><b>Kilometertijd</b> — gemiddelde tijd per 1000 m, de standaardmaat voor snelheid. 1'13"8 is 1 minuut 13,8 seconden per km. Lager is sneller; één seconde is ruim tien meter.</li>
|
||||
<li><b>Derbyproef</b> — de kwalificatiekoers van 4 september over 2000 m, in twee heats. Heat 1 (open) werd gewonnen door Rolleman Flevo; heat 2 (alleen merries) door Rose Starlake en was 3,5 s/km trager.</li>
|
||||
<li><b>Derby</b> — de belangrijkste koers van het jaar voor driejarigen. Alle tien starters zijn drie jaar en lopen 2600 m; hun namen beginnen met een R omdat het geboortejaar 2023 de letter R kreeg.</li>
|
||||
<li><b>Notering</b> — de uitbetaling per ingezette euro als het paard wint: bij 2,50 krijg je €2,50 terug. Hoe lager, hoe meer de toto in het paard gelooft.</li>
|
||||
<li><b>Merrie / hengst / ruin</b> — vrouwelijk, mannelijk, gecastreerd. Merries hadden in de proef een eigen heat.</li>
|
||||
<li><b>Rijder / trainer</b> — wie op de sulky zit tijdens de koers, en wie het paard dagelijks traint. Tesselaar doet bij Rolleman Flevo allebei.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="method">
|
||||
<summary>Hoe het model rekent</summary>
|
||||
<div class="method-body">
|
||||
<p class="mono">logit = 0.55 · Σ gewicht × z(signaal) + risicostraf · ln(1 − P(uitval)) · kans = softmax(logit / T)</p>
|
||||
<ul>
|
||||
<li>Elk signaal wordt een z-score over het veld (hoeveel standaardafwijkingen boven of onder het gemiddelde), zodat seconden, punten en noteringen vergelijkbaar zijn.</li>
|
||||
<li><b>Uitslagen</b> — 1e = 1, 2e = 0,75, 3e = 0,6, 4e = 0,45, 5e = 0,35, 6e = 0,25, verder 0,1, D = 0; gewogen met halveringstijd.</li>
|
||||
<li><b>Proef</b> — minus de achterstand in seconden op de heatwinnaar, minus de merriehandicap voor heat 2.</li>
|
||||
<li><b>Snelste tijd</b> — minus de record-kilometertijd over 2600 m; onbekend = veldgemiddelde.</li>
|
||||
<li><b>Startpositie</b> — vaste curve: 2 en 3 het best, dan 1, aflopend naar 8; 9 en 10 negatief.</li>
|
||||
<li><b>Wedmarkt</b> — −ln(notering bij de proef).</li>
|
||||
<li><b>Uitval</b> — 3% + 10% per D + 3% per plek ≥ 7 in de laatste vijf; telt mee als ln(1 − kans).</li>
|
||||
<li><b>Plaats & combinaties</b> — Plackett–Luce, exact uitgerekend over alle 720 mogelijke top-3's; voor 2e en 3e plaats met p<sup>λ</sup> (Stern-correctie).</li>
|
||||
<li><b>Eindkans</b> — log-lineaire menging: ln p = (1 − v) · ln p<sub>model</sub> + v · ln p<sub>toto</sub>, daarna genormaliseerd; p<sub>toto</sub> = 1/notering gedeeld door de som (zo valt de toto-marge weg). Waarde en inzet gebruiken de eindkans.</li>
|
||||
<li>De startpositie-curve en de merriehandicap zijn aannames, geen gemeten waarden. Daarom zijn ze instelbaar.</li>
|
||||
</ul>
|
||||
<div class="source-links">
|
||||
{#each race.sources as s (s.href)}
|
||||
<a href={s.href} target="_blank" rel="noreferrer">{s.label} ↗</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<footer><span>DERBY LENS · {race.name}</span><span>SvelteKit</span></footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell{background:#f2f3e8;color:#07100c;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px;line-height:normal}.shell button,.shell input{font:inherit}.shell button{cursor:pointer}.shell a{color:inherit}
|
||||
.inner{max-width:1440px;margin:auto;padding:20px 24px 56px}
|
||||
.topbar{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #07100c;padding:6px 0 18px;gap:16px}.brand{display:flex;align-items:center;gap:12px;font-weight:850;letter-spacing:-.04em;font-size:1.2rem}.mark{width:30px;height:30px;border-radius:50%;background:#dcff57;border:8px solid #0c1812;box-shadow:inset 0 0 0 2px #dcff57}.event-pill{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;border:1px solid #07100c;padding:8px 12px;border-radius:99px;font-size:.78rem;background:#fbfcf4}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.nowrap{white-space:nowrap}.hint{margin:6px 0 0;font-size:.78rem;line-height:1.5;color:#3f4b44}.hint b{font-weight:800}.hint.pad{padding:14px 24px 0;margin:0}.card>.hint{margin:-8px 0 16px}.controls>.hint{margin:8px 0 4px}.control .hint{color:#68736d;font-size:.72rem;margin-top:4px}
|
||||
.practice-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding:10px 16px;border-radius:14px;background:#f46f43;color:#07100c;font-size:.85rem}.practice-banner button{border:1px solid #07100c;border-radius:99px;padding:6px 12px;background:#07100c;color:#fff;font-weight:700;font-size:.78rem}
|
||||
.guide{display:grid;grid-template-columns:minmax(260px,.9fr) minmax(0,1.3fr) minmax(260px,.9fr);gap:22px;margin:0 0 22px;padding:22px 24px;border:1px solid #07100c;border-radius:24px;background:#fbfcf4}.guide-intro h2{margin:8px 0 12px}.guide-intro p,.practice p{font-size:.85rem;line-height:1.55;color:#3f4b44;margin:0}
|
||||
.steps{list-style:none;padding:0;margin:0;display:grid;gap:10px}.steps li{display:grid;grid-template-columns:30px 1fr;gap:12px;align-items:start;padding:10px 0;border-top:1px solid #cfd4c5}.steps li:first-child{border-top:0;padding-top:0}.step-n{width:28px;height:28px;border-radius:50%;border:1px solid #07100c;display:grid;place-items:center;font-weight:900;font-size:.85rem}.steps .done .step-n{background:#9ee7c4}.steps a{font-weight:800;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.steps p{margin:3px 0 5px;font-size:.8rem;line-height:1.45;color:#3f4b44}.step-status{font:700 .72rem ui-monospace,SFMono-Regular,Menlo,monospace;color:#68736d;text-transform:uppercase;letter-spacing:.08em}.steps .done .step-status{color:#1d7a4c}.step-check{display:flex;align-items:center;gap:6px;font-size:.78rem;font-weight:700}.step-check input{accent-color:#07100c}
|
||||
.practice{border-left:1px solid #cfd4c5;padding-left:22px}.practice>p{margin:8px 0 12px}.scenarios{display:flex;flex-wrap:wrap;gap:6px}.scenarios button{border:1px solid #07100c;border-radius:99px;padding:7px 12px;background:transparent;font-size:.78rem;font-weight:700;text-align:left}.scenarios button.active{background:#f46f43}.scenarios .clear{background:#07100c;color:#fff}.scenarios button:disabled{opacity:.35;cursor:default}.lesson{margin-top:14px;padding:12px 14px;border-radius:14px;background:#f1dfd6;font-size:.8rem;line-height:1.5}.lesson p{margin:0 0 6px;color:#07100c;font-size:.8rem}.lesson a{font-weight:800;text-decoration:underline}
|
||||
.topbar-right{display:flex;align-items:center;gap:10px}.help-toggle{border:1px solid #07100c;border-radius:99px;padding:7px 12px;background:transparent;font:700 .74rem ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.08em}.help-toggle.on{background:#07100c;color:#dcff57}.r{text-align:right}.strong{font-weight:800}.muted{color:#68736d;font-size:.85rem;line-height:1.5;margin:0}.pos{color:#1d7a4c}.neg{color:#c2461f}
|
||||
.eyebrow,.section-kicker{font:700 .78rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.12em}.eyebrow{color:#dcff57}.section-kicker{font-size:.73rem;color:#68736d}
|
||||
h2{font-size:1.65rem;letter-spacing:-.045em;margin:7px 0 18px}
|
||||
|
||||
.hero{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(280px,.75fr);gap:22px;padding:24px 0 18px}.hero-main{background:#0c1812;color:#f7f8ec;border-radius:28px;padding:30px;position:relative;overflow:hidden;display:flex;flex-direction:column;justify-content:space-between;gap:24px}.hero-main:after{content:"R";position:absolute;right:-6px;bottom:-70px;font-size:15rem;line-height:1;font-weight:950;color:rgba(220,255,87,.1);letter-spacing:-.09em}.hero h1{font-size:clamp(3rem,7vw,6rem);line-height:.83;letter-spacing:-.075em;margin:22px 0 0}
|
||||
.facts{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid rgba(247,248,236,.25);border-radius:18px;overflow:hidden;position:relative;z-index:1}.fact{padding:14px 16px;border-right:1px solid rgba(247,248,236,.25)}.fact:last-child{border:0}.fact b{display:block;font-size:1.05rem}.fact span{font-size:.7rem;color:#9aa69f;text-transform:uppercase;letter-spacing:.08em}
|
||||
.pick{background:#dcff57;border:1px solid #07100c;border-radius:28px;padding:26px;display:flex;flex-direction:column;justify-content:space-between}.pick-label{font:700 .75rem ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.1em}.pick-name{font-size:clamp(2.2rem,3.6vw,3.8rem);font-weight:900;line-height:.86;letter-spacing:-.06em;margin:28px 0 18px}.pick-meta{display:flex;align-items:end;justify-content:space-between;gap:15px}.prob{font-size:2.6rem;font-weight:900;letter-spacing:-.06em}.confidence{font:700 .74rem/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:right}
|
||||
|
||||
.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:22px;margin-top:6px}.controls,.board,.card{border:1px solid #07100c;border-radius:24px;background:#fbfcf4}.controls{padding:20px;align-self:start;position:sticky;top:16px}
|
||||
.presets{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 6px}.presets button{border:1px solid #07100c;border-radius:99px;padding:6px 12px;background:transparent;font-size:.8rem;font-weight:700}.presets button.active{background:#07100c;color:#dcff57}
|
||||
.control{padding:11px 0;border-top:1px solid #cfd4c5}.control:first-of-type{border-top:0}.control label{display:flex;justify-content:space-between;font-weight:700;font-size:.88rem;margin-bottom:5px}.control output{font:700 .76rem ui-monospace,SFMono-Regular,Menlo,monospace;color:#68736d}input[type=range]{width:100%;accent-color:#f46f43;margin:0}
|
||||
.actions{display:flex;gap:8px;margin-top:14px}.ghost{flex:1;border:1px solid #07100c;border-radius:12px;padding:10px;background:transparent;font-weight:700;font-size:.85rem}.ghost:hover{background:#07100c;color:#fff}.notice{margin:14px 0 0;font-size:.74rem;color:#68736d;line-height:1.4}
|
||||
|
||||
.board{overflow:hidden}.board-head{padding:22px 24px 16px;display:flex;align-items:end;justify-content:space-between;gap:16px;border-bottom:1px solid #07100c}.board-head h2{margin:6px 0 0}
|
||||
.horse-list{padding:6px 14px 14px}.horse{display:grid;grid-template-columns:20px 36px minmax(150px,1.1fr) minmax(120px,2fr) 66px 56px 76px 60px;grid-template-areas:"c n name bar p fair odds edge";align-items:center;gap:12px;padding:12px 8px;border-bottom:1px solid #cfd4c5;transition:.2s}.horse>:nth-child(1){grid-area:c}.horse>:nth-child(2){grid-area:n}.horse>:nth-child(3){grid-area:name}.horse>:nth-child(4){grid-area:bar}.horse>:nth-child(5){grid-area:p}.horse>:nth-child(6){grid-area:fair}.horse>:nth-child(7){grid-area:odds}.horse>:nth-child(8){grid-area:edge}.horse:hover{background:#f2f5df}.horse:last-child{border:0}.horse.head{padding:10px 8px 6px;font:700 .68rem ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.1em;color:#68736d}.horse.head:hover{background:transparent}
|
||||
.horse input[type=checkbox]{width:16px;height:16px;accent-color:#07100c;margin:0}.num{width:34px;height:34px;border-radius:50%;border:1px solid #07100c;display:grid;place-items:center;font-weight:900}.winner .num{background:#dcff57}.horse-name{font-weight:850;letter-spacing:-.025em}.driver{font-size:.74rem;color:#68736d;margin-top:3px}.scratched{opacity:.45}
|
||||
.bar-wrap{height:12px;background:#e2e6d8;border-radius:20px;overflow:hidden}.bar{height:100%;background:#9ee7c4;border-radius:20px;transition:width .5s cubic-bezier(.2,.8,.2,1)}.winner .bar{background:#f46f43}.chance{font:800 .94rem ui-monospace,SFMono-Regular,Menlo,monospace;text-align:right}.fair{font:700 .8rem ui-monospace,SFMono-Regular,Menlo,monospace;color:#68736d;text-align:right}
|
||||
.odds{width:100%;border:1px solid #cfd4c5;border-radius:8px;padding:6px 8px;background:#fff;font:700 .85rem ui-monospace,SFMono-Regular,Menlo,monospace;text-align:right}.odds:focus{outline:2px solid #f46f43;border-color:transparent}.edge{font:800 .85rem ui-monospace,SFMono-Regular,Menlo,monospace;text-align:right}
|
||||
|
||||
.lower{display:grid;grid-template-columns:1.15fr .85fr;gap:22px;margin-top:22px}.card{padding:24px;min-width:0}.card h2{margin-top:6px}.card.wide{margin-top:22px}
|
||||
.tbl{width:100%;border-collapse:collapse;font-size:.86rem}.tbl th{font:700 .66rem ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.1em;color:#68736d;padding:6px 8px;border-bottom:1px solid #07100c;text-align:left}.tbl td{padding:8px;border-bottom:1px solid #cfd4c5;vertical-align:top}.tbl tr:last-child td{border-bottom:0}.tbl small{display:block;font:500 .68rem ui-monospace,SFMono-Regular,Menlo,monospace;color:#68736d;margin-top:2px}
|
||||
.comp td{white-space:nowrap}.comp td:nth-child(2){font-weight:700;white-space:normal;min-width:130px}.scroll{overflow-x:auto;margin:0 -8px}
|
||||
.combos{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px}.combo{display:grid;grid-template-columns:1fr auto auto;gap:10px;padding:7px 0;border-bottom:1px solid #cfd4c5;font-size:.84rem}.combo:last-child{border:0}.combo b{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.combo i{font:700 .78rem ui-monospace,SFMono-Regular,Menlo,monospace;color:#68736d;min-width:34px;text-align:right}
|
||||
.blend{padding:0 0 14px;border:0}.blend label{font-size:.88rem}.tbl .odds{width:64px;padding:4px 6px;font-size:.8rem}.dim td{opacity:.55}.tbl td.muted{font-size:.78rem;color:#68736d}
|
||||
.bank{display:flex;gap:10px;margin-bottom:16px}.bank label{flex:1;display:flex;flex-direction:column;gap:4px;font:700 .68rem ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.1em;color:#68736d}.bank input{min-width:0;width:100%;border:1px solid #07100c;border-radius:10px;padding:9px 10px;background:#fff;font:700 .95rem ui-monospace,SFMono-Regular,Menlo,monospace;color:#07100c}
|
||||
.stats{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid #07100c;border-radius:16px;overflow:hidden;margin-bottom:16px}.stats div{padding:12px 14px;border-right:1px solid #07100c}.stats div:last-child{border:0}.stats b{display:block;font-size:1.2rem;letter-spacing:-.03em}.stats span{font-size:.68rem;color:#68736d;text-transform:uppercase;letter-spacing:.08em}
|
||||
|
||||
.method+.method{margin-top:12px}.method{margin-top:22px;border:1px solid #07100c;border-radius:24px;overflow:hidden}.method summary{list-style:none;padding:18px 24px;font-weight:850;cursor:pointer;background:#fbfcf4;display:flex;justify-content:space-between}.method summary::-webkit-details-marker{display:none}.method summary:after{content:"+"}.method[open] summary:after{content:"−"}.method-body{padding:20px 24px;border-top:1px solid #07100c;background:#e7ebdc}.method-body p{margin:0 0 12px;font-size:.8rem}.method-body ul{padding-left:18px;list-style:disc;margin:0}.method-body li{color:#3f4b44;line-height:1.5;font-size:.84rem}.source-links{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}.source-links a{font-size:.76rem;border:1px solid #07100c;border-radius:99px;padding:7px 12px;text-decoration:none;background:#fbfcf4}
|
||||
footer{display:flex;justify-content:space-between;gap:20px;color:#68736d;font-size:.75rem;padding:22px 2px 0}
|
||||
|
||||
@media(max-width:1100px){.horse{grid-template-columns:20px 36px minmax(140px,1fr) 66px 56px 76px 60px;grid-template-areas:"c n name p fair odds edge" ". . bar bar bar bar bar";row-gap:6px}.horse .bar-wrap{height:8px}.horse.head{grid-template-areas:"c n name p fair odds edge"}.horse.head span:nth-child(4){display:none}}
|
||||
@media(max-width:900px){.hero,.workspace,.lower,.combos,.guide{grid-template-columns:1fr}.practice{border-left:0;padding-left:0;border-top:1px solid #cfd4c5;padding-top:16px}.controls{position:static}.facts{grid-template-columns:repeat(2,1fr)}.fact:nth-child(2){border-right:0}.fact:nth-child(-n+2){border-bottom:1px solid rgba(247,248,236,.25)}}
|
||||
@media(max-width:560px){.inner{padding:14px 12px 34px}.event-pill{display:none}.hero-main,.pick,.controls,.card,.guide{padding:18px;border-radius:20px}.practice-banner{flex-direction:column;align-items:flex-start}.hero h1{font-size:3.2rem}.hero-main:after{font-size:9rem}.board-head{align-items:start;flex-direction:column}
|
||||
.horse{grid-template-columns:18px 32px minmax(0,1fr) 60px 64px;grid-template-areas:"c n name p odds" ". . bar bar bar";gap:8px;padding:11px 4px;row-gap:6px}.horse.head{grid-template-areas:"c n name p odds"}.fair,.edge,.horse.head span:nth-child(6),.horse.head span:nth-child(8){display:none}.chance{font-size:.85rem}.stats b{font-size:1rem}.tbl{font-size:.78rem}.tbl td,.tbl th{padding:6px 4px}.opt{display:none}.tbl .odds{width:52px}.combo{font-size:.78rem}}
|
||||
</style>
|
||||
5
static/derby-favicon.svg
Normal file
5
static/derby-favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0b1511"/>
|
||||
<path d="M10 42c12-1 17-17 29-17 8 0 12 5 15 11-9-3-14-1-20 3-7 5-15 8-24 3Z" fill="#e8ff75"/>
|
||||
<circle cx="41" cy="20" r="7" fill="#f06a3d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
Reference in New Issue
Block a user