korb 0.3.0__tar.gz → 0.3.2__tar.gz

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.
Files changed (41) hide show
  1. {korb-0.3.0 → korb-0.3.2}/.github/workflows/release.yml +10 -10
  2. {korb-0.3.0 → korb-0.3.2}/.github/workflows/test.yml +3 -3
  3. {korb-0.3.0 → korb-0.3.2}/.gitignore +0 -3
  4. {korb-0.3.0 → korb-0.3.2}/CHANGELOG.md +46 -0
  5. {korb-0.3.0 → korb-0.3.2}/PKG-INFO +36 -11
  6. {korb-0.3.0 → korb-0.3.2}/README.md +35 -10
  7. {korb-0.3.0 → korb-0.3.2}/docs/coding-standard.md +6 -6
  8. {korb-0.3.0 → korb-0.3.2}/korb/__init__.py +1 -1
  9. {korb-0.3.0 → korb-0.3.2}/korb/__main__.py +48 -15
  10. korb-0.3.2/korb/backtest.py +150 -0
  11. {korb-0.3.0 → korb-0.3.2}/korb/core.py +5 -5
  12. {korb-0.3.0 → korb-0.3.2}/korb/download.py +23 -3
  13. korb-0.3.2/korb/model.py +420 -0
  14. korb-0.3.2/korb/predict.py +186 -0
  15. {korb-0.3.0 → korb-0.3.2}/korb/schedule.py +3 -17
  16. {korb-0.3.0 → korb-0.3.2}/pyproject.toml +5 -0
  17. korb-0.3.2/tests/test_backtest.py +111 -0
  18. {korb-0.3.0 → korb-0.3.2}/tests/test_cli.py +166 -1
  19. {korb-0.3.0 → korb-0.3.2}/tests/test_core.py +35 -2
  20. {korb-0.3.0 → korb-0.3.2}/tests/test_download.py +32 -2
  21. korb-0.3.2/tests/test_model.py +211 -0
  22. korb-0.3.2/tests/test_predict.py +124 -0
  23. {korb-0.3.0 → korb-0.3.2}/tests/test_schedule.py +24 -16
  24. {korb-0.3.0 → korb-0.3.2}/tests/test_standings.py +12 -0
  25. {korb-0.3.0 → korb-0.3.2}/tests/test_team.py +18 -0
  26. korb-0.3.2/uv.lock +518 -0
  27. korb-0.3.0/korb/predict.py +0 -414
  28. korb-0.3.0/tests/test_predict.py +0 -223
  29. {korb-0.3.0 → korb-0.3.2}/LICENSE +0 -0
  30. {korb-0.3.0 → korb-0.3.2}/files/.gitkeep +0 -0
  31. {korb-0.3.0 → korb-0.3.2}/korb/ergebnisse.py +0 -0
  32. {korb-0.3.0 → korb-0.3.2}/korb/py.typed +0 -0
  33. {korb-0.3.0 → korb-0.3.2}/korb/standings.py +0 -0
  34. {korb-0.3.0 → korb-0.3.2}/korb/team.py +0 -0
  35. {korb-0.3.0 → korb-0.3.2}/tests/__init__.py +0 -0
  36. {korb-0.3.0 → korb-0.3.2}/tests/conftest.py +0 -0
  37. {korb-0.3.0 → korb-0.3.2}/tests/fixtures/ergebnisse_minimal.html +0 -0
  38. {korb-0.3.0 → korb-0.3.2}/tests/fixtures/ergebnisse_nested.html +0 -0
  39. {korb-0.3.0 → korb-0.3.2}/tests/fixtures/spielplan_finalized.html +0 -0
  40. {korb-0.3.0 → korb-0.3.2}/tests/fixtures/spielplan_minimal.html +0 -0
  41. {korb-0.3.0 → korb-0.3.2}/tests/test_ergebnisse.py +0 -0
@@ -16,18 +16,18 @@ jobs:
16
16
  matrix:
17
17
  python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
18
18
  steps:
19
- - uses: actions/checkout@v6
19
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
20
20
 
21
21
  - name: Install uv
22
22
  uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
23
23
 
24
24
  - name: Set up Python ${{ matrix.python-version }}
25
- uses: actions/setup-python@v6
25
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
26
26
  with:
27
27
  python-version: ${{ matrix.python-version }}
28
28
 
29
29
  - name: Install dependencies
30
- run: uv sync --group dev
30
+ run: uv sync --group dev --locked
31
31
 
32
32
  - name: Lint
33
33
  if: matrix.python-version == '3.12'
@@ -57,7 +57,7 @@ jobs:
57
57
  runs-on: ubuntu-latest
58
58
  environment: pypi
59
59
  steps:
60
- - uses: actions/checkout@v6
60
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
61
61
  with:
62
62
  fetch-depth: 0
63
63
 
@@ -65,7 +65,7 @@ jobs:
65
65
  uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
66
66
 
67
67
  - name: Set up Python
68
- uses: actions/setup-python@v6
68
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
69
69
  with:
70
70
  python-version: "3.12"
71
71
 
@@ -78,13 +78,13 @@ jobs:
78
78
  exit 1
79
79
  fi
80
80
 
81
- - name: Create GitHub Release
82
- uses: softprops/action-gh-release@v2
83
- with:
84
- generate_release_notes: true
85
-
86
81
  - name: Build package
87
82
  run: uv build
88
83
 
89
84
  - name: Publish to PyPI
90
85
  run: uv publish --trusted-publishing always
86
+
87
+ - name: Create GitHub Release
88
+ uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
89
+ with:
90
+ generate_release_notes: true
@@ -13,18 +13,18 @@ jobs:
13
13
  matrix:
14
14
  python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
15
15
  steps:
16
- - uses: actions/checkout@v6
16
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
17
17
 
18
18
  - name: Install uv
19
19
  uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
20
20
 
21
21
  - name: Set up Python ${{ matrix.python-version }}
22
- uses: actions/setup-python@v6
22
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
23
23
  with:
24
24
  python-version: ${{ matrix.python-version }}
25
25
 
26
26
  - name: Install dependencies
27
- run: uv sync --group dev
27
+ run: uv sync --group dev --locked
28
28
 
29
29
  - name: Lint
30
30
  if: matrix.python-version == '3.12'
@@ -11,9 +11,6 @@ build/
11
11
  .coverage.*
12
12
  htmlcov/
13
13
 
14
- # Lock file
15
- uv.lock
16
-
17
14
  # Downloaded HTML data (keep folders via .gitkeep)
18
15
  files/**/*.html
19
16
 
@@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.3.2] — 2026-10-25
8
+
9
+ ### Added
10
+
11
+ - `backtest` command with chronological winner accuracy, Brier score,
12
+ score MAE, and score RMSE.
13
+ - Per-game win probabilities in text and JSON predictions.
14
+ - Opponent-adjusted offensive and defensive ratings via weighted ridge
15
+ regression.
16
+ - Fitted home advantage with a 3% prior and fitted score volatility.
17
+
18
+ ### Changed
19
+
20
+ - Prediction ratings are now additive strength coefficients converted
21
+ to the multiplicative score model.
22
+ - Recent form uses opponent-adjusted residuals instead of raw scoring.
23
+ - Removed the hard three-game rating cutoff; ridge regularization
24
+ handles thin samples.
25
+ - Split the prediction code into `model.py`, `predict.py`, and
26
+ `backtest.py`.
27
+
28
+ ## [0.3.1] — 2026-10-25
29
+
30
+ ### Changed
31
+
32
+ - Committed `uv.lock`; CI now installs with `uv sync --locked`.
33
+ - Release workflow publishes to PyPI before creating the GitHub release
34
+ and pins third-party actions by commit.
35
+ - Team JSON results are chronological, matching text output and the
36
+ other data commands.
37
+
38
+ ### Fixed
39
+
40
+ - Prediction fatigue uses the latest game before each fixture and never
41
+ treats a later game as fatigue.
42
+ - Forfeits are excluded from offensive/defensive ratings, recent form,
43
+ and fatigue inputs while remaining in standings.
44
+ - Mismatched results and schedule league numbers now exit with an error.
45
+ - Downloaded pages must have the expected `Ergebnisse`/`Spielplan`
46
+ title, preventing non-empty error pages from replacing cached data.
47
+ - `read_file_safe` reports directories and permission failures cleanly.
48
+ - `schedule --json --b2b` now includes per-game `b2b` flags.
49
+ - Team-name matching uses Unicode case folding (`Straße` matches
50
+ `STRASSE`).
51
+ - Removed the unused, inconsistent `is_season_finalized` helper.
52
+
7
53
  ## [0.3.0] — 2026-10-25
8
54
 
9
55
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: korb
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A CLI toolkit for DBB basketball league analysis
5
5
  Project-URL: Homepage, https://github.com/malvavisc0/korb
6
6
  Project-URL: Repository, https://github.com/malvavisc0/korb
@@ -47,8 +47,10 @@ platform and provides standings, results, schedules, predictions, and more.
47
47
  - 🏀 **Team** — game-by-game results, sparklines, quality metrics
48
48
  - 📋 **Ergebnisse** — all completed game results with optional team filter
49
49
  - 📅 **Schedule** — with back-to-back detection, filtering, pending games
50
- - 🔮 **Predict** — efficiency-model-based final standings forecast
50
+ - 🔮 **Predict** — opponent-adjusted final standings forecast with
51
+ win probabilities
51
52
  - 🥇 **Top** — quick leaderboard with ASCII bar chart
53
+ - 🧪 **Backtest** — chronological holdout metrics for the prediction model
52
54
  - 📥 **Download** — fetch fresh HTML data directly from basketball-bund.net
53
55
  - 🔧 **`--json`** — machine-readable output for data commands (not `download`)
54
56
 
@@ -131,8 +133,19 @@ uv run korb --ligaid 12345 schedule --team "Hawks" --pending --b2b
131
133
 
132
134
  ```bash
133
135
  uv run korb --ligaid 12345 predict
136
+ uv run korb --ligaid 12345 --json predict
134
137
  ```
135
138
 
139
+ ### `backtest` — Evaluate the prediction model
140
+
141
+ ```bash
142
+ uv run korb --ligaid 12345 backtest
143
+ uv run korb --ligaid 12345 backtest --min-train 10
144
+ ```
145
+
146
+ Trains on each game's prior results only and reports winner accuracy,
147
+ Brier score, score MAE, and score RMSE.
148
+
136
149
  ### `top` — Quick leaderboard
137
150
 
138
151
  ```bash
@@ -152,18 +165,28 @@ uv run korb --ligaid 12345 top -n 5
152
165
 
153
166
  ## How Predictions Work
154
167
 
155
- The `predict` command estimates final standings using a **multiplicative efficiency model**:
168
+ The `predict` command fits opponent-adjusted ratings and converts them
169
+ into a **multiplicative efficiency model**:
156
170
 
157
171
  | Factor | How it works |
158
172
  |---|---|
159
- | **Offensive rating** | Team's scored points vs. league average (>1.0 = above avg) |
160
- | **Defensive rating** | Points allowed vs. league average (>1.0 = worse defense) |
173
+ | **Opponent adjustment** | Weighted ridge regression on point differentials |
174
+ | **Offensive rating** | Adjusted scoring strength relative to league average (>1.0 = above avg) |
175
+ | **Defensive rating** | Adjusted points allowed relative to league average (>1.0 = worse defense) |
161
176
  | **Recency weighting** | 60-day half-life — recent games count more |
162
- | **Recent form** | Last 5 games blended at 30% weight into ratings |
163
- | **Home advantage** | 3% scoring boost applied symmetrically |
177
+ | **Recent form** | Last 5 opponent-adjusted residuals blended at 30% weight |
178
+ | **Regularization** | Ridge shrinkage replaces the hard 3-game cutoff |
179
+ | **Home advantage** | Fitted from results with a 3% prior |
180
+ | **Win probability** | Normal margin model using fitted score volatility |
164
181
  | **B2B fatigue** | ≤36h between games → 5% offense/defense penalty |
165
- | **New teams** | <3 games → ratings blended toward league average |
166
- | **No draws** | Ties broken by home advantage (basketball has OT) |
182
+ | **No draws** | The higher win probability decides ties |
183
+ | **Backtest** | `backtest` evaluates the model chronologically |
184
+
185
+ ## Data Notes
186
+
187
+ - DBB encodes forfeits as `20:0` / `0:20`; korb counts them as completed
188
+ games, awards the winner 2 points, and deducts 1 point from the loser.
189
+ - Struck-through and cancelled rows are skipped.
167
190
 
168
191
  ## Project Structure
169
192
 
@@ -171,10 +194,12 @@ The `predict` command estimates final standings using a **multiplicative efficie
171
194
  korb/
172
195
  ├── __init__.py # Package marker & version
173
196
  ├── __main__.py # CLI entry point & command handlers
197
+ ├── backtest.py # Chronological model evaluation
174
198
  ├── core.py # Shared models, HTML parsing, utilities
175
199
  ├── download.py # HTTP downloader, headers, rate limiting
176
200
  ├── ergebnisse.py # Game results viewer & filter
177
- ├── predict.py # Multiplicative efficiency prediction model
201
+ ├── model.py # Opponent-adjusted ratings & score model
202
+ ├── predict.py # Final standings orchestration & output
178
203
  ├── schedule.py # HTML schedule parser & filters
179
204
  ├── standings.py # Standings calculator
180
205
  └── team.py # Team results viewer & metrics
@@ -190,7 +215,7 @@ korb/
190
215
 
191
216
  ```bash
192
217
  # Install with dev tools
193
- uv sync --group dev
218
+ uv sync --group dev --locked
194
219
 
195
220
  # Lint, format & type-check
196
221
  uv run ruff check korb/ tests/
@@ -21,8 +21,10 @@ platform and provides standings, results, schedules, predictions, and more.
21
21
  - 🏀 **Team** — game-by-game results, sparklines, quality metrics
22
22
  - 📋 **Ergebnisse** — all completed game results with optional team filter
23
23
  - 📅 **Schedule** — with back-to-back detection, filtering, pending games
24
- - 🔮 **Predict** — efficiency-model-based final standings forecast
24
+ - 🔮 **Predict** — opponent-adjusted final standings forecast with
25
+ win probabilities
25
26
  - 🥇 **Top** — quick leaderboard with ASCII bar chart
27
+ - 🧪 **Backtest** — chronological holdout metrics for the prediction model
26
28
  - 📥 **Download** — fetch fresh HTML data directly from basketball-bund.net
27
29
  - 🔧 **`--json`** — machine-readable output for data commands (not `download`)
28
30
 
@@ -105,8 +107,19 @@ uv run korb --ligaid 12345 schedule --team "Hawks" --pending --b2b
105
107
 
106
108
  ```bash
107
109
  uv run korb --ligaid 12345 predict
110
+ uv run korb --ligaid 12345 --json predict
108
111
  ```
109
112
 
113
+ ### `backtest` — Evaluate the prediction model
114
+
115
+ ```bash
116
+ uv run korb --ligaid 12345 backtest
117
+ uv run korb --ligaid 12345 backtest --min-train 10
118
+ ```
119
+
120
+ Trains on each game's prior results only and reports winner accuracy,
121
+ Brier score, score MAE, and score RMSE.
122
+
110
123
  ### `top` — Quick leaderboard
111
124
 
112
125
  ```bash
@@ -126,18 +139,28 @@ uv run korb --ligaid 12345 top -n 5
126
139
 
127
140
  ## How Predictions Work
128
141
 
129
- The `predict` command estimates final standings using a **multiplicative efficiency model**:
142
+ The `predict` command fits opponent-adjusted ratings and converts them
143
+ into a **multiplicative efficiency model**:
130
144
 
131
145
  | Factor | How it works |
132
146
  |---|---|
133
- | **Offensive rating** | Team's scored points vs. league average (>1.0 = above avg) |
134
- | **Defensive rating** | Points allowed vs. league average (>1.0 = worse defense) |
147
+ | **Opponent adjustment** | Weighted ridge regression on point differentials |
148
+ | **Offensive rating** | Adjusted scoring strength relative to league average (>1.0 = above avg) |
149
+ | **Defensive rating** | Adjusted points allowed relative to league average (>1.0 = worse defense) |
135
150
  | **Recency weighting** | 60-day half-life — recent games count more |
136
- | **Recent form** | Last 5 games blended at 30% weight into ratings |
137
- | **Home advantage** | 3% scoring boost applied symmetrically |
151
+ | **Recent form** | Last 5 opponent-adjusted residuals blended at 30% weight |
152
+ | **Regularization** | Ridge shrinkage replaces the hard 3-game cutoff |
153
+ | **Home advantage** | Fitted from results with a 3% prior |
154
+ | **Win probability** | Normal margin model using fitted score volatility |
138
155
  | **B2B fatigue** | ≤36h between games → 5% offense/defense penalty |
139
- | **New teams** | <3 games → ratings blended toward league average |
140
- | **No draws** | Ties broken by home advantage (basketball has OT) |
156
+ | **No draws** | The higher win probability decides ties |
157
+ | **Backtest** | `backtest` evaluates the model chronologically |
158
+
159
+ ## Data Notes
160
+
161
+ - DBB encodes forfeits as `20:0` / `0:20`; korb counts them as completed
162
+ games, awards the winner 2 points, and deducts 1 point from the loser.
163
+ - Struck-through and cancelled rows are skipped.
141
164
 
142
165
  ## Project Structure
143
166
 
@@ -145,10 +168,12 @@ The `predict` command estimates final standings using a **multiplicative efficie
145
168
  korb/
146
169
  ├── __init__.py # Package marker & version
147
170
  ├── __main__.py # CLI entry point & command handlers
171
+ ├── backtest.py # Chronological model evaluation
148
172
  ├── core.py # Shared models, HTML parsing, utilities
149
173
  ├── download.py # HTTP downloader, headers, rate limiting
150
174
  ├── ergebnisse.py # Game results viewer & filter
151
- ├── predict.py # Multiplicative efficiency prediction model
175
+ ├── model.py # Opponent-adjusted ratings & score model
176
+ ├── predict.py # Final standings orchestration & output
152
177
  ├── schedule.py # HTML schedule parser & filters
153
178
  ├── standings.py # Standings calculator
154
179
  └── team.py # Team results viewer & metrics
@@ -164,7 +189,7 @@ korb/
164
189
 
165
190
  ```bash
166
191
  # Install with dev tools
167
- uv sync --group dev
192
+ uv sync --group dev --locked
168
193
 
169
194
  # Lint, format & type-check
170
195
  uv run ruff check korb/ tests/
@@ -33,7 +33,7 @@ into `files/<ligaid>/`.
33
33
 
34
34
  ## Commands
35
35
  ```bash
36
- uv sync --group dev
36
+ uv sync --group dev --locked
37
37
  uv run korb --help
38
38
  uv run korb --ligaid 12345 download
39
39
  uv run ruff check --fix korb/ tests/
@@ -126,9 +126,9 @@ Write the simplest correct solution. Delete anything that isn't needed.
126
126
  `set_defaults(func=cmd_<name>)`. Global flags are `--results`,
127
127
  `--schedule`, `--json`, `--ligaid`, `--download`.
128
128
  - Feature modules sit next to the CLI (`standings.py`, `team.py`,
129
- `schedule.py`, `predict.py`, `ergebnisse.py`, `download.py`); shared
130
- models, HTML parsing, date/score helpers, and output headers live in
131
- `core.py`.
129
+ `schedule.py`, `model.py`, `predict.py`, `backtest.py`,
130
+ `ergebnisse.py`, `download.py`); shared models, HTML parsing,
131
+ date/score helpers, and output headers live in `core.py`.
132
132
  - HTML parsing extends the shared public `DBBRowParser`
133
133
  (`<td>`/`<tr>`/`<strike>` state machine). Parser changes need a
134
134
  fixture in `tests/fixtures/` and a test; cover cancelled rows
@@ -136,8 +136,8 @@ Write the simplest correct solution. Delete anything that isn't needed.
136
136
  - Parsers return `(data, LeagueInfo)`. Every data command's `--json`
137
137
  response is a wrapped object built with `_liga_meta(...)` plus a
138
138
  named data key: `standings`, `results` (team), `ergebnisse`,
139
- `schedule`, `predictions`, and `top`. `download` is a side-effect
140
- command and emits no JSON.
139
+ `schedule`, `predictions`, `backtest`, and `top`. `download` is a
140
+ side-effect command and emits no JSON.
141
141
  - Data paths resolve as `files/<ligaid>/ergebnisse.html` and
142
142
  `files/<ligaid>/spielplan.html`; explicit `--results` / `--schedule`
143
143
  win. Use `_resolve_results` / `_resolve_schedule`, not ad hoc paths.
@@ -3,4 +3,4 @@
3
3
  Target: DBB Version ≤12.01.1-f733da5 (legacy JSP platform).
4
4
  """
5
5
 
6
- __version__ = "0.3.0"
6
+ __version__ = "0.3.2"
@@ -10,11 +10,18 @@ import sys
10
10
  from pathlib import Path
11
11
 
12
12
  from . import __version__
13
+ from .backtest import DEFAULT_MIN_TRAIN, backtest, print_backtest
13
14
  from .core import LeagueInfo, read_games
14
15
  from .download import discover_league_ids, download_league, wait_between_requests
15
16
  from .ergebnisse import filter_ergebnisse, print_ergebnisse
16
- from .predict import predict_standings, print_predicted_standings, print_predictions
17
- from .schedule import filter_schedule, parse_schedule, print_schedule
17
+ from .predict import (
18
+ PredictionError,
19
+ format_prediction,
20
+ predict_standings,
21
+ print_predicted_standings,
22
+ print_predictions,
23
+ )
24
+ from .schedule import b2b_flags, filter_schedule, parse_schedule, print_schedule
18
25
  from .standings import Standing, calculate_standings, print_table
19
26
  from .team import get_team_results, print_bars, print_metrics, print_results
20
27
 
@@ -103,7 +110,7 @@ def cmd_team(args: argparse.Namespace) -> None:
103
110
  fp = _resolve_results(args, "team results")
104
111
  results, league_info = get_team_results(args.name, fp)
105
112
  if args.json:
106
- data = [r.to_dict() for r in results]
113
+ data = [r.to_dict() for r in sorted(results, key=lambda r: r.date)]
107
114
  _json_out(
108
115
  {
109
116
  **_liga_meta(league_info, args.ligaid),
@@ -154,11 +161,15 @@ def cmd_schedule(args: argparse.Namespace) -> None:
154
161
  filtered = filter_schedule(
155
162
  games, show_all=args.all, pending=args.pending, team=args.team
156
163
  )
164
+ flags = b2b_flags(filtered, games) if args.b2b else {}
157
165
  if args.json:
158
166
  _json_out(
159
167
  {
160
168
  **_liga_meta(league_info, args.ligaid),
161
- "schedule": [g.to_dict() for g in filtered],
169
+ "schedule": [
170
+ {**g.to_dict(), "b2b": flags.get(i, False)}
171
+ for i, g in enumerate(filtered)
172
+ ],
162
173
  }
163
174
  )
164
175
  else:
@@ -170,7 +181,11 @@ def cmd_predict(args: argparse.Namespace) -> None:
170
181
  rp = _resolve_results(args, "prediction results")
171
182
  sp = _resolve_schedule(args, "prediction schedule")
172
183
 
173
- standings, preds, league_info = predict_standings(rp, sp)
184
+ try:
185
+ standings, preds, league_info = predict_standings(rp, sp)
186
+ except PredictionError as exc:
187
+ print(f"Error: {exc}", file=sys.stderr)
188
+ sys.exit(1)
174
189
  if not preds:
175
190
  print(
176
191
  "Error: Season is finalized (no pending games). "
@@ -182,16 +197,7 @@ def cmd_predict(args: argparse.Namespace) -> None:
182
197
  _json_out(
183
198
  {
184
199
  **_liga_meta(league_info, args.ligaid),
185
- "predictions": [
186
- {
187
- "home": g.home,
188
- "away": g.away,
189
- "winner": w,
190
- "home_score": hs,
191
- "away_score": aws,
192
- }
193
- for g, w, hs, aws in preds
194
- ],
200
+ "predictions": [format_prediction(pred) for pred in preds],
195
201
  "standings": [s.to_dict() for s in standings],
196
202
  }
197
203
  )
@@ -200,6 +206,21 @@ def cmd_predict(args: argparse.Namespace) -> None:
200
206
  print_predicted_standings(standings)
201
207
 
202
208
 
209
+ def cmd_backtest(args: argparse.Namespace) -> None:
210
+ """Handle 'backtest' subcommand."""
211
+ fp = _resolve_results(args, "backtest")
212
+ result, league_info = backtest(fp, min_train_games=args.min_train)
213
+ if args.json:
214
+ _json_out(
215
+ {
216
+ **_liga_meta(league_info, args.ligaid),
217
+ "backtest": result.to_dict(),
218
+ }
219
+ )
220
+ else:
221
+ print_backtest(result)
222
+
223
+
203
224
  def _blocks(value: int, scale: int) -> str:
204
225
  return "█" * max(0, value // scale)
205
226
 
@@ -400,6 +421,18 @@ def _build_parser() -> argparse.ArgumentParser:
400
421
  )
401
422
  p_pr.set_defaults(func=cmd_predict)
402
423
 
424
+ p_bt = subs.add_parser(
425
+ "backtest",
426
+ help="Backtest the prediction model on completed games",
427
+ )
428
+ p_bt.add_argument(
429
+ "--min-train",
430
+ type=_positive_int,
431
+ default=DEFAULT_MIN_TRAIN,
432
+ help=f"Minimum prior games before evaluating (default: {DEFAULT_MIN_TRAIN})",
433
+ )
434
+ p_bt.set_defaults(func=cmd_backtest)
435
+
403
436
  p_top = subs.add_parser(
404
437
  "top",
405
438
  help="Show top teams from standings",
@@ -0,0 +1,150 @@
1
+ """Chronological backtesting for the prediction model."""
2
+
3
+ from dataclasses import dataclass
4
+ from math import sqrt
5
+
6
+ from korb.core import Game, LeagueInfo, read_games
7
+ from korb.model import (
8
+ FATIGUE_THRESHOLD_H,
9
+ Winner,
10
+ fatigue,
11
+ fit_model,
12
+ game_times,
13
+ predict_game,
14
+ )
15
+
16
+ DEFAULT_MIN_TRAIN = 5
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class BacktestResult:
21
+ """Chronological holdout metrics for the prediction model."""
22
+
23
+ games: int
24
+ winner_games: int
25
+ winner_accuracy: float
26
+ brier: float
27
+ score_mae: float
28
+ score_rmse: float
29
+
30
+ def to_dict(self) -> dict[str, object]:
31
+ """Serializable metrics."""
32
+ return {
33
+ "games": self.games,
34
+ "winner_games": self.winner_games,
35
+ "winner_accuracy": round(self.winner_accuracy, 4),
36
+ "brier": round(self.brier, 4),
37
+ "score_mae": round(self.score_mae, 2),
38
+ "score_rmse": round(self.score_rmse, 2),
39
+ }
40
+
41
+
42
+ def _actual_home_win(game: Game) -> float | None:
43
+ if game.home_score > game.away_score:
44
+ return 1.0
45
+ if game.home_score < game.away_score:
46
+ return 0.0
47
+ return None
48
+
49
+
50
+ def _predict_historical(
51
+ game: Game, prior: list[Game]
52
+ ) -> tuple[Winner, int, int, float] | None:
53
+ model = fit_model(prior)
54
+ if game.home not in model.ratings or game.away not in model.ratings:
55
+ return None
56
+ times = game_times(prior)
57
+ timestamp = game.date.timestamp()
58
+ threshold_s = FATIGUE_THRESHOLD_H * 3600
59
+ return predict_game(
60
+ game.home,
61
+ game.away,
62
+ model.ratings,
63
+ model.league_avg,
64
+ home_fatigue=fatigue(game.home, timestamp, times, threshold_s),
65
+ away_fatigue=fatigue(game.away, timestamp, times, threshold_s),
66
+ home_advantage=model.home_advantage,
67
+ sigma=model.sigma,
68
+ )
69
+
70
+
71
+ def backtest(
72
+ results_path: str,
73
+ min_train_games: int = DEFAULT_MIN_TRAIN,
74
+ ) -> tuple[BacktestResult, LeagueInfo]:
75
+ """Evaluate the model chronologically on completed games."""
76
+ games, league_info = read_games(results_path)
77
+ games = sorted(
78
+ (game for game in games if not game.forfeit),
79
+ key=lambda game: game.date,
80
+ )
81
+
82
+ evaluated = 0
83
+ winner_games = 0
84
+ correct = 0
85
+ brier = 0.0
86
+ absolute_errors: list[float] = []
87
+ squared_errors: list[float] = []
88
+
89
+ for index, game in enumerate(games):
90
+ prior = games[:index]
91
+ if len(prior) < min_train_games:
92
+ continue
93
+ prediction = _predict_historical(game, prior)
94
+ if prediction is None:
95
+ continue
96
+ winner, home_score, away_score, home_probability = prediction
97
+
98
+ evaluated += 1
99
+ absolute_errors.extend(
100
+ [
101
+ abs(home_score - game.home_score),
102
+ abs(away_score - game.away_score),
103
+ ]
104
+ )
105
+ squared_errors.extend(
106
+ [
107
+ float((home_score - game.home_score) ** 2),
108
+ float((away_score - game.away_score) ** 2),
109
+ ]
110
+ )
111
+
112
+ actual_home = _actual_home_win(game)
113
+ if actual_home is not None:
114
+ winner_games += 1
115
+ correct += int((winner == "home") == (actual_home == 1.0))
116
+ brier += (home_probability - actual_home) ** 2
117
+
118
+ if not evaluated:
119
+ return BacktestResult(0, 0, 0.0, 0.0, 0.0, 0.0), league_info
120
+
121
+ return (
122
+ BacktestResult(
123
+ games=evaluated,
124
+ winner_games=winner_games,
125
+ winner_accuracy=correct / winner_games if winner_games else 0.0,
126
+ brier=brier / winner_games if winner_games else 0.0,
127
+ score_mae=sum(absolute_errors) / len(absolute_errors),
128
+ score_rmse=sqrt(sum(squared_errors) / len(squared_errors)),
129
+ ),
130
+ league_info,
131
+ )
132
+
133
+
134
+ def print_backtest(result: BacktestResult) -> None:
135
+ """Print backtest metrics."""
136
+ print("\n" + "=" * 70)
137
+ print(" Prediction Backtest")
138
+ print("=" * 70)
139
+
140
+ if result.games == 0:
141
+ print(" Not enough completed games to backtest.")
142
+ return
143
+
144
+ print(f" Games evaluated: {result.games}")
145
+ print(
146
+ f" Winner accuracy: {result.winner_accuracy:.1%} ({result.winner_games} games)"
147
+ )
148
+ print(f" Brier score: {result.brier:.4f}")
149
+ print(f" Score MAE: {result.score_mae:.2f}")
150
+ print(f" Score RMSE: {result.score_rmse:.2f}")
@@ -59,7 +59,7 @@ def normalize_whitespace(value: str) -> str:
59
59
 
60
60
  def normalize_name(value: str) -> str:
61
61
  """Normalize a team name for case-insensitive matching."""
62
- return normalize_whitespace(value).lower()
62
+ return normalize_whitespace(value).casefold()
63
63
 
64
64
 
65
65
  def is_forfeit_score(home_score: int, away_score: int) -> bool:
@@ -105,7 +105,7 @@ def parse_score(score_str: str | None) -> tuple[int | None, int | None]:
105
105
 
106
106
 
107
107
  def read_file_safe(filepath: str) -> str:
108
- """Read file with user-friendly error on missing file.
108
+ """Read a UTF-8 file with a user-friendly error on failure.
109
109
 
110
110
  Args:
111
111
  filepath: Path to file to read.
@@ -114,13 +114,13 @@ def read_file_safe(filepath: str) -> str:
114
114
  File contents as string.
115
115
 
116
116
  Raises:
117
- SystemExit: If file not found.
117
+ SystemExit: If the file cannot be read.
118
118
  """
119
119
  try:
120
120
  with open(filepath, encoding="utf-8") as f:
121
121
  return f.read()
122
- except FileNotFoundError:
123
- print(f"Error: File not found: {filepath}", file=sys.stderr)
122
+ except OSError as exc:
123
+ print(f"Error: Cannot read {filepath}: {exc}", file=sys.stderr)
124
124
  sys.exit(1)
125
125
 
126
126