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.
- {korb-0.3.0 → korb-0.3.2}/.github/workflows/release.yml +10 -10
- {korb-0.3.0 → korb-0.3.2}/.github/workflows/test.yml +3 -3
- {korb-0.3.0 → korb-0.3.2}/.gitignore +0 -3
- {korb-0.3.0 → korb-0.3.2}/CHANGELOG.md +46 -0
- {korb-0.3.0 → korb-0.3.2}/PKG-INFO +36 -11
- {korb-0.3.0 → korb-0.3.2}/README.md +35 -10
- {korb-0.3.0 → korb-0.3.2}/docs/coding-standard.md +6 -6
- {korb-0.3.0 → korb-0.3.2}/korb/__init__.py +1 -1
- {korb-0.3.0 → korb-0.3.2}/korb/__main__.py +48 -15
- korb-0.3.2/korb/backtest.py +150 -0
- {korb-0.3.0 → korb-0.3.2}/korb/core.py +5 -5
- {korb-0.3.0 → korb-0.3.2}/korb/download.py +23 -3
- korb-0.3.2/korb/model.py +420 -0
- korb-0.3.2/korb/predict.py +186 -0
- {korb-0.3.0 → korb-0.3.2}/korb/schedule.py +3 -17
- {korb-0.3.0 → korb-0.3.2}/pyproject.toml +5 -0
- korb-0.3.2/tests/test_backtest.py +111 -0
- {korb-0.3.0 → korb-0.3.2}/tests/test_cli.py +166 -1
- {korb-0.3.0 → korb-0.3.2}/tests/test_core.py +35 -2
- {korb-0.3.0 → korb-0.3.2}/tests/test_download.py +32 -2
- korb-0.3.2/tests/test_model.py +211 -0
- korb-0.3.2/tests/test_predict.py +124 -0
- {korb-0.3.0 → korb-0.3.2}/tests/test_schedule.py +24 -16
- {korb-0.3.0 → korb-0.3.2}/tests/test_standings.py +12 -0
- {korb-0.3.0 → korb-0.3.2}/tests/test_team.py +18 -0
- korb-0.3.2/uv.lock +518 -0
- korb-0.3.0/korb/predict.py +0 -414
- korb-0.3.0/tests/test_predict.py +0 -223
- {korb-0.3.0 → korb-0.3.2}/LICENSE +0 -0
- {korb-0.3.0 → korb-0.3.2}/files/.gitkeep +0 -0
- {korb-0.3.0 → korb-0.3.2}/korb/ergebnisse.py +0 -0
- {korb-0.3.0 → korb-0.3.2}/korb/py.typed +0 -0
- {korb-0.3.0 → korb-0.3.2}/korb/standings.py +0 -0
- {korb-0.3.0 → korb-0.3.2}/korb/team.py +0 -0
- {korb-0.3.0 → korb-0.3.2}/tests/__init__.py +0 -0
- {korb-0.3.0 → korb-0.3.2}/tests/conftest.py +0 -0
- {korb-0.3.0 → korb-0.3.2}/tests/fixtures/ergebnisse_minimal.html +0 -0
- {korb-0.3.0 → korb-0.3.2}/tests/fixtures/ergebnisse_nested.html +0 -0
- {korb-0.3.0 → korb-0.3.2}/tests/fixtures/spielplan_finalized.html +0 -0
- {korb-0.3.0 → korb-0.3.2}/tests/fixtures/spielplan_minimal.html +0 -0
- {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'
|
|
@@ -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.
|
|
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** —
|
|
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
|
|
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
|
-
| **
|
|
160
|
-
| **
|
|
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
|
|
163
|
-
| **
|
|
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
|
-
| **
|
|
166
|
-
| **
|
|
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
|
-
├──
|
|
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** —
|
|
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
|
|
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
|
-
| **
|
|
134
|
-
| **
|
|
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
|
|
137
|
-
| **
|
|
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
|
-
| **
|
|
140
|
-
| **
|
|
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
|
-
├──
|
|
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`, `
|
|
130
|
-
models, HTML parsing,
|
|
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
|
|
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.
|
|
@@ -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
|
|
17
|
-
|
|
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": [
|
|
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
|
-
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
123
|
-
print(f"Error:
|
|
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
|
|