korb 0.3.1__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.1 → korb-0.3.2}/CHANGELOG.md +21 -0
- {korb-0.3.1 → korb-0.3.2}/PKG-INFO +29 -10
- {korb-0.3.1 → korb-0.3.2}/README.md +28 -9
- {korb-0.3.1 → korb-0.3.2}/docs/coding-standard.md +5 -5
- {korb-0.3.1 → korb-0.3.2}/korb/__init__.py +1 -1
- {korb-0.3.1 → korb-0.3.2}/korb/__main__.py +41 -12
- korb-0.3.2/korb/backtest.py +150 -0
- korb-0.3.2/korb/model.py +420 -0
- korb-0.3.2/korb/predict.py +186 -0
- {korb-0.3.1 → korb-0.3.2}/pyproject.toml +5 -0
- korb-0.3.2/tests/test_backtest.py +111 -0
- {korb-0.3.1 → korb-0.3.2}/tests/test_cli.py +131 -0
- {korb-0.3.1 → korb-0.3.2}/tests/test_core.py +13 -0
- korb-0.3.2/tests/test_model.py +211 -0
- korb-0.3.2/tests/test_predict.py +124 -0
- {korb-0.3.1 → korb-0.3.2}/tests/test_schedule.py +21 -0
- {korb-0.3.1 → korb-0.3.2}/tests/test_standings.py +12 -0
- {korb-0.3.1 → korb-0.3.2}/tests/test_team.py +18 -0
- korb-0.3.1/korb/predict.py +0 -445
- korb-0.3.1/tests/test_predict.py +0 -285
- {korb-0.3.1 → korb-0.3.2}/.github/workflows/release.yml +0 -0
- {korb-0.3.1 → korb-0.3.2}/.github/workflows/test.yml +0 -0
- {korb-0.3.1 → korb-0.3.2}/.gitignore +0 -0
- {korb-0.3.1 → korb-0.3.2}/LICENSE +0 -0
- {korb-0.3.1 → korb-0.3.2}/files/.gitkeep +0 -0
- {korb-0.3.1 → korb-0.3.2}/korb/core.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/korb/download.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/korb/ergebnisse.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/korb/py.typed +0 -0
- {korb-0.3.1 → korb-0.3.2}/korb/schedule.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/korb/standings.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/korb/team.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/__init__.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/conftest.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/fixtures/ergebnisse_minimal.html +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/fixtures/ergebnisse_nested.html +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/fixtures/spielplan_finalized.html +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/fixtures/spielplan_minimal.html +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/test_download.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/tests/test_ergebnisse.py +0 -0
- {korb-0.3.1 → korb-0.3.2}/uv.lock +0 -0
|
@@ -4,6 +4,27 @@ 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
|
+
|
|
7
28
|
## [0.3.1] — 2026-10-25
|
|
8
29
|
|
|
9
30
|
### Changed
|
|
@@ -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,22 @@ 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 |
|
|
167
184
|
|
|
168
185
|
## Data Notes
|
|
169
186
|
|
|
@@ -177,10 +194,12 @@ The `predict` command estimates final standings using a **multiplicative efficie
|
|
|
177
194
|
korb/
|
|
178
195
|
├── __init__.py # Package marker & version
|
|
179
196
|
├── __main__.py # CLI entry point & command handlers
|
|
197
|
+
├── backtest.py # Chronological model evaluation
|
|
180
198
|
├── core.py # Shared models, HTML parsing, utilities
|
|
181
199
|
├── download.py # HTTP downloader, headers, rate limiting
|
|
182
200
|
├── ergebnisse.py # Game results viewer & filter
|
|
183
|
-
├──
|
|
201
|
+
├── model.py # Opponent-adjusted ratings & score model
|
|
202
|
+
├── predict.py # Final standings orchestration & output
|
|
184
203
|
├── schedule.py # HTML schedule parser & filters
|
|
185
204
|
├── standings.py # Standings calculator
|
|
186
205
|
└── team.py # Team results viewer & metrics
|
|
@@ -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,22 @@ 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 |
|
|
141
158
|
|
|
142
159
|
## Data Notes
|
|
143
160
|
|
|
@@ -151,10 +168,12 @@ The `predict` command estimates final standings using a **multiplicative efficie
|
|
|
151
168
|
korb/
|
|
152
169
|
├── __init__.py # Package marker & version
|
|
153
170
|
├── __main__.py # CLI entry point & command handlers
|
|
171
|
+
├── backtest.py # Chronological model evaluation
|
|
154
172
|
├── core.py # Shared models, HTML parsing, utilities
|
|
155
173
|
├── download.py # HTTP downloader, headers, rate limiting
|
|
156
174
|
├── ergebnisse.py # Game results viewer & filter
|
|
157
|
-
├──
|
|
175
|
+
├── model.py # Opponent-adjusted ratings & score model
|
|
176
|
+
├── predict.py # Final standings orchestration & output
|
|
158
177
|
├── schedule.py # HTML schedule parser & filters
|
|
159
178
|
├── standings.py # Standings calculator
|
|
160
179
|
└── team.py # Team results viewer & metrics
|
|
@@ -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,10 +10,17 @@ 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
|
+
from .predict import (
|
|
18
|
+
PredictionError,
|
|
19
|
+
format_prediction,
|
|
20
|
+
predict_standings,
|
|
21
|
+
print_predicted_standings,
|
|
22
|
+
print_predictions,
|
|
23
|
+
)
|
|
17
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
|
|
@@ -174,7 +181,11 @@ def cmd_predict(args: argparse.Namespace) -> None:
|
|
|
174
181
|
rp = _resolve_results(args, "prediction results")
|
|
175
182
|
sp = _resolve_schedule(args, "prediction schedule")
|
|
176
183
|
|
|
177
|
-
|
|
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)
|
|
178
189
|
if not preds:
|
|
179
190
|
print(
|
|
180
191
|
"Error: Season is finalized (no pending games). "
|
|
@@ -186,16 +197,7 @@ def cmd_predict(args: argparse.Namespace) -> None:
|
|
|
186
197
|
_json_out(
|
|
187
198
|
{
|
|
188
199
|
**_liga_meta(league_info, args.ligaid),
|
|
189
|
-
"predictions": [
|
|
190
|
-
{
|
|
191
|
-
"home": g.home,
|
|
192
|
-
"away": g.away,
|
|
193
|
-
"winner": w,
|
|
194
|
-
"home_score": hs,
|
|
195
|
-
"away_score": aws,
|
|
196
|
-
}
|
|
197
|
-
for g, w, hs, aws in preds
|
|
198
|
-
],
|
|
200
|
+
"predictions": [format_prediction(pred) for pred in preds],
|
|
199
201
|
"standings": [s.to_dict() for s in standings],
|
|
200
202
|
}
|
|
201
203
|
)
|
|
@@ -204,6 +206,21 @@ def cmd_predict(args: argparse.Namespace) -> None:
|
|
|
204
206
|
print_predicted_standings(standings)
|
|
205
207
|
|
|
206
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
|
+
|
|
207
224
|
def _blocks(value: int, scale: int) -> str:
|
|
208
225
|
return "█" * max(0, value // scale)
|
|
209
226
|
|
|
@@ -404,6 +421,18 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
404
421
|
)
|
|
405
422
|
p_pr.set_defaults(func=cmd_predict)
|
|
406
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
|
+
|
|
407
436
|
p_top = subs.add_parser(
|
|
408
437
|
"top",
|
|
409
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}")
|