wraith-tdf-predictor 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """Tour de France race outcome prediction."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("wraith-tdf-predictor")
tdf_predictor/db.py ADDED
@@ -0,0 +1,85 @@
1
+ """Load data/raw/*.json into data/races.db (SQLite).
2
+
3
+ Run: uv run python -m tdf_predictor.db
4
+ """
5
+
6
+ # ponytail: stdlib sqlite3 file DB; swap for Turso when multi-host
7
+ # access is actually needed
8
+
9
+ import json
10
+ import sqlite3
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from tdf_predictor.ingest import DATA_DIR, RAW_DIR
15
+
16
+ DB_PATH = DATA_DIR / "races.db"
17
+
18
+ SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS races (
20
+ race TEXT NOT NULL,
21
+ year INTEGER NOT NULL,
22
+ kind TEXT NOT NULL,
23
+ date TEXT,
24
+ source TEXT,
25
+ PRIMARY KEY (race, year)
26
+ );
27
+ CREATE TABLE IF NOT EXISTS results (
28
+ race TEXT NOT NULL,
29
+ year INTEGER NOT NULL,
30
+ rider TEXT NOT NULL,
31
+ rider_url TEXT,
32
+ team TEXT,
33
+ rank INTEGER,
34
+ status TEXT,
35
+ age INTEGER,
36
+ nationality TEXT,
37
+ time TEXT,
38
+ pcs_points REAL,
39
+ uci_points REAL,
40
+ PRIMARY KEY (race, year, rider)
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_results_rider ON results (rider_url);
43
+ """
44
+
45
+
46
+ def load(db_path: Path = DB_PATH, raw_dir: Path = RAW_DIR) -> int:
47
+ con = sqlite3.connect(db_path)
48
+ con.executescript(SCHEMA)
49
+ n = 0
50
+ for f in sorted(raw_dir.glob("*.json")):
51
+ if f.name.startswith("_"):
52
+ continue
53
+ d = json.loads(f.read_text())
54
+ con.execute(
55
+ "INSERT OR REPLACE INTO races VALUES (?,?,?,?,?)",
56
+ (d["race"], d["year"], d["kind"], d["date"], d["source"]),
57
+ )
58
+ con.executemany(
59
+ "INSERT OR REPLACE INTO results VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
60
+ [
61
+ (
62
+ d["race"],
63
+ d["year"],
64
+ r["rider_name"],
65
+ r["rider_url"],
66
+ r["team_name"],
67
+ r["rank"],
68
+ r["status"],
69
+ r["age"],
70
+ r["nationality"],
71
+ r["time"],
72
+ r["pcs_points"],
73
+ r["uci_points"],
74
+ )
75
+ for r in d["results"]
76
+ ],
77
+ )
78
+ n += len(d["results"])
79
+ con.commit()
80
+ con.close()
81
+ return n
82
+
83
+
84
+ if __name__ == "__main__":
85
+ print(f"loaded {load()} result rows into {DB_PATH}", file=sys.stderr)
@@ -0,0 +1,439 @@
1
+ """Tissot fantasy team picker: 8 riders, <=120 stars, max points.
2
+
3
+ Proxy for fantasy points: PCS points over TDF stages + final GC.
4
+ Predict a rider's TDF points from pre-Tour season form (ridge
5
+ regression), synthesize past-year star costs by quantile-mapping form
6
+ onto the 2026 cost distribution, pick the team by exact knapsack DP,
7
+ and backtest leave-one-year-out against the hindsight-optimal team.
8
+ Run: uv run python -m tdf_predictor.fantasy
9
+ """
10
+
11
+ import csv
12
+ import json
13
+ import math
14
+ import sys
15
+ import unicodedata
16
+ from collections import defaultdict
17
+
18
+ from tdf_predictor import tissot
19
+ from tdf_predictor.ingest import DATA_DIR, RAW_DIR
20
+
21
+ STAGE_DIR = RAW_DIR / "stages"
22
+ RIDERS_CSV = DATA_DIR / "riders.csv"
23
+ BUDGET = 120
24
+ TEAM_SIZE = 8
25
+ BACKTEST_YEARS = range(2021, 2026)
26
+
27
+ # Tissot squad caps (rules art 4.1.2.5 / "Building your team")
28
+ CATEGORY_CAPS = {
29
+ "Leaders": 3,
30
+ "All-rounders": 5,
31
+ "Sprinters": 3,
32
+ "Climbers": 3,
33
+ }
34
+
35
+
36
+ # NFKD drops these instead of transliterating them
37
+ _TRANS = str.maketrans(
38
+ {
39
+ "ß": "ss",
40
+ "ł": "l",
41
+ "Ł": "L",
42
+ "ø": "o",
43
+ "Ø": "O",
44
+ "đ": "d",
45
+ "Đ": "D",
46
+ "æ": "ae",
47
+ "Æ": "AE",
48
+ }
49
+ )
50
+
51
+
52
+ def name_key(name: str) -> frozenset:
53
+ """Order/accent/case-insensitive name key.
54
+
55
+ 'Tadej POGAČAR' and 'Pogačar Tadej' -> same key.
56
+ """
57
+ s = unicodedata.normalize("NFKD", name.translate(_TRANS))
58
+ s = s.encode("ascii", "ignore").decode()
59
+ for ch in "'’":
60
+ s = s.replace(ch, "")
61
+ for ch in "-.":
62
+ s = s.replace(ch, " ")
63
+ return frozenset(s.upper().split())
64
+
65
+
66
+ def match_rider(key: frozenset, by_key: dict) -> str | None:
67
+ """Exact key match, else unique-subset match.
68
+
69
+ Tissot uses full legal names ('Jonas VINGEGAARD HANSEN'); PCS
70
+ doesn't ('Vingegaard Jonas') — PCS key ⊆ Tissot key counts.
71
+ """
72
+ if key in by_key:
73
+ return by_key[key]
74
+ cands = {u for k, u in by_key.items() if k <= key}
75
+ return cands.pop() if len(cands) == 1 else None
76
+
77
+
78
+ def load_stage_points() -> dict:
79
+ """(year, rider_url) -> summed Tissot stage points (finish + KOM +
80
+ jersey bonuses; see tdf_predictor.tissot)."""
81
+ pts = defaultdict(float)
82
+ for f in sorted(STAGE_DIR.glob("*.json")):
83
+ d = json.loads(f.read_text())
84
+ for url, p in tissot.stage_points(d).items():
85
+ pts[(d["year"], url)] += p
86
+ return dict(pts)
87
+
88
+
89
+ def proxy_points(rows: list[dict], stage_pts: dict) -> dict:
90
+ """(year, rider_url) -> summed Tissot stage points, for riders who
91
+ appear in a TDF GC (i.e. started and finished). GC standing is
92
+ already scored per stage via the yellow-jersey bonus, so there is no
93
+ separate final-GC term."""
94
+ out = {}
95
+ for r in rows:
96
+ if r["race"] != "tour-de-france":
97
+ continue
98
+ key = (r["year"], r["rider_url"])
99
+ out[key] = stage_pts.get(key, 0.0)
100
+ return out
101
+
102
+
103
+ def season_form(rows: list[dict], year: int) -> dict:
104
+ """rider_url -> feature vector from results before July of year.
105
+
106
+ Last feature is freshness: previous-year pre-July race count minus
107
+ this year's — positive means racing less than usual, fresher legs.
108
+ """
109
+ cutoff = f"{year}-07-01"
110
+ prev_cutoff = f"{year - 1}-07-01"
111
+ cur = defaultdict(list)
112
+ prev, prev_n = defaultdict(float), defaultdict(int)
113
+ for r in rows:
114
+ if r["year"] == year and r["date"] < cutoff:
115
+ cur[r["rider_url"]].append(r)
116
+ elif r["year"] == year - 1:
117
+ prev[r["rider_url"]] += r["pcs_points"] or 0
118
+ if r["date"] < prev_cutoff:
119
+ prev_n[r["rider_url"]] += 1
120
+ feats = {}
121
+ for u, rs in cur.items():
122
+ ranks = [x["rank"] for x in rs]
123
+ pcs = [x["pcs_points"] or 0 for x in rs]
124
+ gc = sum(p for x, p in zip(rs, pcs) if x["kind"] == "gc")
125
+ feats[u] = [
126
+ len(rs),
127
+ sum(pcs),
128
+ gc,
129
+ sum(pcs) - gc,
130
+ min(ranks),
131
+ sum(k <= 10 for k in ranks) / len(ranks),
132
+ prev[u],
133
+ prev_n[u] - len(rs),
134
+ ]
135
+ return feats
136
+
137
+
138
+ def tour_teams(year: int) -> dict:
139
+ """rider_url -> team name from that year's first TDF stage file
140
+ (startlist proxy)."""
141
+ for f in sorted(STAGE_DIR.glob(f"tdf-{year}-stage*.json")):
142
+ d = json.loads(f.read_text())
143
+ return {r["rider_url"]: r["team_name"] for r in d["results"]}
144
+ return {}
145
+
146
+
147
+ def build_features(rows, actual, year, teams=None) -> dict:
148
+ """Season form + team strength: teammates' previous-Tour proxy
149
+ points — cohesion/tactics proxy, strong squads control races."""
150
+ feats = season_form(rows, year)
151
+ teams = teams or tour_teams(year)
152
+ tot = defaultdict(float)
153
+ for u, t in teams.items():
154
+ tot[t] += actual.get((year - 1, u), 0.0)
155
+ out = {}
156
+ for u, f in feats.items():
157
+ mates = 0.0
158
+ if u in teams:
159
+ mates = tot[teams[u]] - actual.get((year - 1, u), 0.0)
160
+ out[u] = f + [mates]
161
+ return out
162
+
163
+
164
+ def _solve(a: list[list[float]], b: list[float]) -> list[float]:
165
+ """Gaussian elimination with partial pivoting."""
166
+ n = len(a)
167
+ m = [row[:] + [bv] for row, bv in zip(a, b)]
168
+ for i in range(n):
169
+ p = max(range(i, n), key=lambda r: abs(m[r][i]))
170
+ m[i], m[p] = m[p], m[i]
171
+ for r in range(i + 1, n):
172
+ f = m[r][i] / m[i][i]
173
+ for c in range(i, n + 1):
174
+ m[r][c] -= f * m[i][c]
175
+ x = [0.0] * n
176
+ for i in range(n - 1, -1, -1):
177
+ s = sum(m[i][c] * x[c] for c in range(i + 1, n))
178
+ x[i] = (m[i][n] - s) / m[i][i]
179
+ return x
180
+
181
+
182
+ def fit_ridge(X, y, lam: float = 1.0):
183
+ """Closed-form ridge on log1p(points); returns weight vector."""
184
+ # ponytail: pure-python normal equations; features stay <10 wide
185
+ rows = [list(r) + [1.0] for r in X]
186
+ n = len(rows[0])
187
+ yl = [math.log1p(v) for v in y]
188
+ a = [
189
+ [
190
+ sum(r[i] * r[j] for r in rows) + (lam if i == j else 0.0)
191
+ for j in range(n)
192
+ ]
193
+ for i in range(n)
194
+ ]
195
+ b = [sum(r[i] * v for r, v in zip(rows, yl)) for i in range(n)]
196
+ return _solve(a, b)
197
+
198
+
199
+ def predict_points(w, X) -> list[float]:
200
+ return [
201
+ max(math.expm1(sum(a * b for a, b in zip(list(r) + [1.0], w))), 0.0)
202
+ for r in X
203
+ ]
204
+
205
+
206
+ def synth_costs(pred: list[float], cost_pool: list[int]) -> list[int]:
207
+ """Map predicted-points ranks onto the 2026 star distribution."""
208
+ pool = sorted(cost_pool, reverse=True)
209
+ order = sorted(range(len(pred)), key=lambda i: -pred[i])
210
+ costs = [0] * len(pred)
211
+ for rank, i in enumerate(order):
212
+ q = rank / max(len(pred) - 1, 1)
213
+ costs[i] = pool[round(q * (len(pool) - 1))]
214
+ return costs
215
+
216
+
217
+ def _cat_exact(idxs, costs, points, budget, cap):
218
+ """Per-category knapsack: dp[k][b] = max points for exactly k riders
219
+ of this category at total cost exactly b. Returns (dp, take)."""
220
+ neg = float("-inf")
221
+ dp = [[neg] * (budget + 1) for _ in range(cap + 1)]
222
+ dp[0][0] = 0.0
223
+ take = {}
224
+ for pos, i in enumerate(idxs):
225
+ c, p = costs[i], points[i]
226
+ for k in range(min(cap, pos + 1), 0, -1):
227
+ for b in range(budget, c - 1, -1):
228
+ cand = dp[k - 1][b - c] + p
229
+ if cand > dp[k][b]:
230
+ dp[k][b] = cand
231
+ take[(pos, k, b)] = True
232
+ return dp, take
233
+
234
+
235
+ def pick_team(
236
+ costs: list[int],
237
+ points: list[float],
238
+ categories: list[str] | None = None,
239
+ budget: int = BUDGET,
240
+ size: int = TEAM_SIZE,
241
+ caps: dict[str, int] | None = None,
242
+ ) -> list[int]:
243
+ """Exact 0/1 knapsack with cardinality: indices of the best team.
244
+
245
+ With `categories` (one per rider), also enforces the Tissot squad
246
+ caps in `caps` (default CATEGORY_CAPS): solved as per-category
247
+ knapsacks combined over the budget so no category exceeds its cap.
248
+ """
249
+ if categories is None:
250
+ neg = float("-inf")
251
+ dp = [[neg] * (budget + 1) for _ in range(size + 1)]
252
+ dp[0][0] = 0.0
253
+ take = {}
254
+ for i, (c, p) in enumerate(zip(costs, points)):
255
+ for n in range(min(i + 1, size), 0, -1):
256
+ for b in range(budget, c - 1, -1):
257
+ cand = dp[n - 1][b - c] + p
258
+ if cand > dp[n][b]:
259
+ dp[n][b] = cand
260
+ take[(i, n, b)] = True
261
+ b = max(range(budget + 1), key=lambda x: dp[size][x])
262
+ if dp[size][b] == neg:
263
+ raise ValueError("no feasible team")
264
+ team, n = [], size
265
+ for i in range(len(costs) - 1, -1, -1):
266
+ if take.get((i, n, b)):
267
+ team.append(i)
268
+ b -= costs[i]
269
+ n -= 1
270
+ return sorted(team)
271
+
272
+ caps = caps or CATEGORY_CAPS
273
+ neg = float("-inf")
274
+ groups: dict[str, list[int]] = defaultdict(list)
275
+ for i, cat in enumerate(categories):
276
+ groups[cat].append(i)
277
+ tables = {
278
+ cat: _cat_exact(idxs, costs, points, budget, caps.get(cat, size))
279
+ for cat, idxs in groups.items()
280
+ }
281
+
282
+ # combine categories over the budget; bp[ci][(k,b)] = (pk,pb,kc,bc)
283
+ cat_list = list(tables)
284
+ comb = [[neg] * (budget + 1) for _ in range(size + 1)]
285
+ comb[0][0] = 0.0
286
+ bp = []
287
+ for cat in cat_list:
288
+ dp, _ = tables[cat]
289
+ cap = caps.get(cat, size)
290
+ new = [[neg] * (budget + 1) for _ in range(size + 1)]
291
+ choice = {}
292
+ for k in range(size + 1):
293
+ for b in range(budget + 1):
294
+ if comb[k][b] == neg:
295
+ continue
296
+ base = comb[k][b]
297
+ for kc in range(min(cap, size - k) + 1):
298
+ for bc in range(budget - b + 1):
299
+ v = dp[kc][bc]
300
+ if v == neg:
301
+ continue
302
+ if base + v > new[k + kc][b + bc]:
303
+ new[k + kc][b + bc] = base + v
304
+ choice[(k + kc, b + bc)] = (k, b, kc, bc)
305
+ comb, _ = new, bp.append(choice)
306
+
307
+ b = max(range(budget + 1), key=lambda x: comb[size][x])
308
+ if comb[size][b] == neg:
309
+ raise ValueError("no feasible team")
310
+ team, k = [], size
311
+ for ci in range(len(cat_list) - 1, -1, -1):
312
+ pk, pb, kc, bc = bp[ci][(k, b)]
313
+ idxs = groups[cat_list[ci]]
314
+ _, take = tables[cat_list[ci]]
315
+ kk, bb = kc, bc
316
+ for pos in range(len(idxs) - 1, -1, -1):
317
+ if kk and take.get((pos, kk, bb)):
318
+ team.append(idxs[pos])
319
+ bb -= costs[idxs[pos]]
320
+ kk -= 1
321
+ k, b = pk, pb
322
+ return sorted(team)
323
+
324
+
325
+ def _soft_cap(p: float, cap: float) -> float:
326
+ """Compress predictions above cap instead of clipping, so
327
+ co-favourites keep their ordering (there can be only one)."""
328
+ return p if p <= cap else cap + math.log1p(p - cap)
329
+
330
+
331
+ def _train_predict(rows, actual, train_years, target_year, teams=None):
332
+ Xtr, ytr = [], []
333
+ for yr in train_years:
334
+ feats = build_features(rows, actual, yr)
335
+ for u, f in feats.items():
336
+ if (yr, u) in actual:
337
+ Xtr.append(f)
338
+ ytr.append(actual[(yr, u)])
339
+ feats = build_features(rows, actual, target_year, teams)
340
+ urls = sorted(feats)
341
+ Xte = [feats[u] for u in urls]
342
+ preds = predict_points(fit_ridge(Xtr, ytr), Xte)
343
+ cap = max(ytr) # expm1 extrapolation can explode off-scale
344
+ return {u: _soft_cap(p, cap) for u, p in zip(urls, preds)}
345
+
346
+
347
+ def load_tissot_roster() -> list[dict]:
348
+ with RIDERS_CSV.open() as f:
349
+ return [
350
+ {
351
+ "name": r["name"],
352
+ "team": r["team"],
353
+ "category": r["category"],
354
+ "stars": int(r["stars"]),
355
+ }
356
+ for r in csv.DictReader(f)
357
+ if r["stars"].isdigit()
358
+ ]
359
+
360
+
361
+ def backtest() -> None: # pragma: no cover
362
+ from tdf_predictor.model import load_rows
363
+
364
+ rows = load_rows()
365
+ actual = proxy_points(rows, load_stage_points())
366
+ cost_pool = [r["stars"] for r in load_tissot_roster()]
367
+ print("year ridge optimal share random")
368
+ for yr in BACKTEST_YEARS:
369
+ train = [y for y in BACKTEST_YEARS if y != yr]
370
+ preds = _train_predict(rows, actual, train, yr)
371
+ starters = sorted(u for (y, u) in actual if y == yr and u in preds)
372
+ pred = [preds[u] for u in starters]
373
+ real = [actual[(yr, u)] for u in starters]
374
+ costs = synth_costs(pred, cost_pool)
375
+ team = pick_team(costs, pred)
376
+ got = sum(real[i] for i in team)
377
+ best = sum(real[i] for i in pick_team(costs, real))
378
+ rnd = sum(real) / len(real) * TEAM_SIZE
379
+ print(f"{yr} {got:5.0f} {best:7.0f} {got / best:5.0%} {rnd:6.0f}")
380
+
381
+
382
+ def roster_predictions() -> list[dict]: # pragma: no cover
383
+ """Tissot roster with predicted 2026 TDF proxy points attached."""
384
+ from tdf_predictor.model import load_rows
385
+
386
+ rows = load_rows()
387
+ actual = proxy_points(rows, load_stage_points())
388
+ by_key = {}
389
+ for r in rows:
390
+ by_key.setdefault(name_key(r["rider"]), r["rider_url"])
391
+
392
+ roster = load_tissot_roster()
393
+ for r in roster:
394
+ r["url"] = match_rider(name_key(r["name"]), by_key)
395
+ teams = {r["url"]: r["team"] for r in roster if r["url"]}
396
+ preds = _train_predict(
397
+ rows, actual, list(BACKTEST_YEARS), 2026, teams=teams
398
+ )
399
+ # expanding-window backcast (2023-25): 0.5*ridge + 0.5*prior-TDF
400
+ # actual beat pure ridge every year (66/63/61% vs 65/60/45% of
401
+ # the hindsight-optimal team)
402
+ prior_year = max(BACKTEST_YEARS)
403
+ for r in roster:
404
+ base = preds.get(r["url"], 0.0) if r["url"] else 0.0
405
+ prior = actual.get((prior_year, r["url"]), 0.0)
406
+ r["pred"] = 0.5 * base + 0.5 * prior
407
+ return roster
408
+
409
+
410
+ def pick_2026() -> None: # pragma: no cover
411
+ roster = roster_predictions()
412
+ points = [r["pred"] for r in roster]
413
+ unmatched = [r["name"] for r in roster if not r["url"]]
414
+ costs = [r["stars"] for r in roster]
415
+ cats = [r["category"] for r in roster]
416
+ team = pick_team(costs, points, cats)
417
+
418
+ print(
419
+ f"\n=== 2026 Tissot team ({TEAM_SIZE} riders, "
420
+ f"{sum(costs[i] for i in team)}/{BUDGET} stars) ==="
421
+ )
422
+ for i in sorted(team, key=lambda i: -points[i]):
423
+ r = roster[i]
424
+ print(
425
+ f"{points[i]:6.0f} {r['stars']:>2}★ "
426
+ f"{r['name']:<26.26} {r['category']:<12} {r['team']}"
427
+ )
428
+ print(f"expected proxy points: {sum(points[i] for i in team):.0f}")
429
+ if unmatched:
430
+ print(
431
+ f"({len(unmatched)} roster riders with no PCS match, "
432
+ f"treated as 0 pts)",
433
+ file=sys.stderr,
434
+ )
435
+
436
+
437
+ if __name__ == "__main__":
438
+ backtest()
439
+ pick_2026()