ghost-tdf-predictor 0.6.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,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghost-tdf-predictor
3
+ Version: 0.6.0
4
+ Summary: Tour de France race outcome prediction for the Ghost Stack.
5
+ Project-URL: Homepage, https://git.thomaspeoples.com/thomaspeoples/ghost-tdf-predictor
6
+ Project-URL: Repository, https://git.thomaspeoples.com/thomaspeoples/ghost-tdf-predictor.git
7
+ Project-URL: Documentation, https://www.thomaspeoples.com/gitea-repos/ghost-tdf-predictor/
8
+ Project-URL: Issues, https://git.thomaspeoples.com/thomaspeoples/ghost-tdf-predictor/issues
9
+ Project-URL: Changelog, https://git.thomaspeoples.com/thomaspeoples/ghost-tdf-predictor/src/branch/main/CHANGELOG.md
10
+ Author-email: Thomas Peoples <hello@thomaspeoples.com>
11
+ License: MIT License
12
+
13
+ Copyright (c) 2026 Thomas Peoples
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE
33
+ Keywords: cycling,ghost-stack,prediction,tour-de-france
34
+ Classifier: Development Status :: 3 - Alpha
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Requires-Python: >=3.12
40
+ Requires-Dist: cloudscraper>=1.2.71
41
+ Requires-Dist: procyclingstats>=0.2.8
42
+ Provides-Extra: dev
43
+ Requires-Dist: commitizen; extra == 'dev'
44
+ Requires-Dist: detect-secrets; extra == 'dev'
45
+ Requires-Dist: genbadge[coverage]>=1.1.1; extra == 'dev'
46
+ Requires-Dist: mkdocs; extra == 'dev'
47
+ Requires-Dist: mkdocs-material; extra == 'dev'
48
+ Requires-Dist: mkdocstrings[python]; extra == 'dev'
49
+ Requires-Dist: poethepoet; extra == 'dev'
50
+ Requires-Dist: pre-commit; extra == 'dev'
51
+ Requires-Dist: pydoc-markdown; extra == 'dev'
52
+ Requires-Dist: pymdown-extensions; extra == 'dev'
53
+ Requires-Dist: pytest; extra == 'dev'
54
+ Requires-Dist: pytest-cov; extra == 'dev'
55
+ Requires-Dist: ruff; extra == 'dev'
56
+ Requires-Dist: ty; extra == 'dev'
57
+ Requires-Dist: typer; extra == 'dev'
58
+ Provides-Extra: scrape
59
+ Requires-Dist: playwright>=1.40; extra == 'scrape'
60
+ Description-Content-Type: text/markdown
61
+
62
+ # ghost-tdf-predictor 🚴💨
63
+
64
+ Who cracks the top 10 in July? Six seasons of ProCyclingStats results,
65
+ a from-scratch model, and a Tissot fantasy team picked by exact
66
+ knapsack — all in pure Python. No torch, no sklearn, no 200 MB wheel
67
+ for a model with nine weights.
68
+
69
+ The 2026 hot take so far: Pogačar. Groundbreaking stuff.
70
+
71
+ ## What it does
72
+
73
+ - **Scrapes** grand tours, one-week stage races, monuments and top
74
+ classics (men's elite, 2021–2026) into SQLite
75
+ - **Clusters** riders into archetypes with hand-rolled k-means
76
+ - **Predicts** top-10 finishes with class-weighted logistic regression
77
+ (test AUC ~0.89 — form really is everything in cycling)
78
+ - **Picks** a 10-rider, 120-star Tissot fantasy team via 0/1 knapsack
79
+ with cardinality, backtested leave-one-year-out against the
80
+ hindsight-optimal team
81
+
82
+ ## Run it
83
+
84
+ ```bash
85
+ uv run poe setup # once
86
+ uv run python -m tdf_predictor.ingest # scrape PCS -> data/raw/*.json
87
+ uv run python -m tdf_predictor.db # load -> data/races.db (SQLite)
88
+ uv run python -m tdf_predictor.model # archetypes + top-10 model
89
+ uv run python -m tdf_predictor.fantasy # backtest + 2026 team pick
90
+ docker compose up -d # browse the DB at :8080
91
+ ```
92
+
93
+ Outputs land in `data/`: `archetypes.json`, `model.json`, and a
94
+ printed team that will absolutely not win your league.
95
+
96
+ Where the data comes from (and how politely it's scraped):
97
+ [docs/SOURCES.md](docs/SOURCES.md).
98
+
99
+ ## Hacking on it
100
+
101
+ ```bash
102
+ uv run poe test # pytest, coverage >80% enforced
103
+ uv run poe tidy # ruff lint + format
104
+ uv run poe docs-serve # MkDocs on :8000
105
+ uv run cz commit # commits go through commitizen, no exceptions
106
+ ```
107
+
108
+ Full workflow in [CONTRIBUTING.md](CONTRIBUTING.md).
109
+
110
+ ## License
111
+
112
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ tdf_predictor/__init__.py,sha256=ZkI1j4FVZlI9jatTLjxpwaxywTnRcFy2ne8Wx5OfUmY,89
2
+ tdf_predictor/db.py,sha256=Ann5ulPm--AlB8TfRNOXDcJFv4l0O_ljM6v42GMX-GI,2174
3
+ tdf_predictor/fantasy.py,sha256=CCFTWWyK5BMFlzoC2cM7a_qm24cdikUnBotvvHvFVag,14712
4
+ tdf_predictor/ingest.py,sha256=_DFt1ac59JGlc9I9No7ZzEI0YxEjFhfB2YOi4AWcxPo,5826
5
+ tdf_predictor/model.py,sha256=JisblKQa8wUgAzn1pey3DscEex7OQE_N85ziGUkYlZQ,7207
6
+ tdf_predictor/tissot.py,sha256=TEZFlTV1gO6xDgrJni50_Ya-h0QHuiP2gVJ0rlSnsS4,3368
7
+ tdf_predictor/tissot_ingest.py,sha256=l8nDrrJzdZdp3oW8U2Qp73O8VOYO5R9WQR0xt-p9NxQ,6035
8
+ tdf_predictor/web.py,sha256=DU9t-rdyUj9FQy2-Z4qROf89sV9SUt0zwuDoHbc0lv8,20553
9
+ tdf_predictor/weights_fit.py,sha256=haXqvYnUQaON58i4h2pmkGkIJJWf_0zFgOdUnD4TA38,8184
10
+ ghost_tdf_predictor-0.6.0.dist-info/METADATA,sha256=gmIv9yMHiaJZscOH_KdW1WTEuic8hPHtm1mxNM2fK20,4897
11
+ ghost_tdf_predictor-0.6.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ ghost_tdf_predictor-0.6.0.dist-info/licenses/LICENSE,sha256=6ltFzoIuR61KUbnrwaJk7m_CT_gjTIIxZhBvHZ4ZY9s,1071
13
+ ghost_tdf_predictor-0.6.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Peoples
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ """Tour de France race outcome prediction for the Ghost Stack."""
2
+
3
+ __version__ = "0.1.0"
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()