backcaster 0.4.0__tar.gz → 0.4.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.
- {backcaster-0.4.0/src/backcaster.egg-info → backcaster-0.4.2}/PKG-INFO +70 -18
- {backcaster-0.4.0 → backcaster-0.4.2}/README.md +65 -16
- {backcaster-0.4.0 → backcaster-0.4.2}/pyproject.toml +4 -1
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/__init__.py +1 -1
- backcaster-0.4.2/src/backcaster/batters.py +175 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/cli.py +19 -6
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/data.py +2 -2
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/html_report.py +26 -27
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/lahman.py +80 -36
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/marcel.py +109 -74
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/outputs.py +37 -4
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/schemas.py +4 -5
- backcaster-0.4.2/src/backcaster/stat_columns.py +55 -0
- {backcaster-0.4.0 → backcaster-0.4.2/src/backcaster.egg-info}/PKG-INFO +70 -18
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/SOURCES.txt +1 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/requires.txt +4 -1
- backcaster-0.4.2/tests/test_batters.py +241 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_cli.py +58 -2
- {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_html_report.py +5 -5
- {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_lahman.py +205 -13
- {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_marcel_math.py +180 -152
- {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_outputs.py +101 -5
- backcaster-0.4.0/src/backcaster/batters.py +0 -118
- backcaster-0.4.0/tests/test_batters.py +0 -120
- {backcaster-0.4.0 → backcaster-0.4.2}/LICENSE +0 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/THIRD_PARTY_NOTICES.md +0 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/setup.cfg +0 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/normalize.py +0 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/dependency_links.txt +0 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/entry_points.txt +0 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/top_level.txt +0 -0
- {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_normalize.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backcaster
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Marcel-style MLB projection CLI using local Lahman CSV data
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
|
|
@@ -18,7 +18,7 @@ Requires-Python: >=3.13
|
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
20
|
License-File: THIRD_PARTY_NOTICES.md
|
|
21
|
-
Requires-Dist: pandas>=2.
|
|
21
|
+
Requires-Dist: pandas>=2.2.2
|
|
22
22
|
Requires-Dist: numpy>=2.0
|
|
23
23
|
Provides-Extra: dev
|
|
24
24
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -27,6 +27,9 @@ Requires-Dist: ruff>=0.11.0; extra == "dev"
|
|
|
27
27
|
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
28
28
|
Requires-Dist: pandas-stubs>=2.2; extra == "dev"
|
|
29
29
|
Requires-Dist: build>=1.2; extra == "dev"
|
|
30
|
+
Requires-Dist: setuptools>=77; extra == "dev"
|
|
31
|
+
Requires-Dist: wheel; extra == "dev"
|
|
32
|
+
Requires-Dist: twine; extra == "dev"
|
|
30
33
|
Dynamic: license-file
|
|
31
34
|
|
|
32
35
|
# Backcaster
|
|
@@ -56,10 +59,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
56
59
|
python -m pip install --upgrade backcaster
|
|
57
60
|
```
|
|
58
61
|
|
|
59
|
-
To install a specific release, such as 0.4.
|
|
62
|
+
To install a specific release, such as 0.4.2:
|
|
60
63
|
|
|
61
64
|
```powershell
|
|
62
|
-
python -m pip install --upgrade backcaster==0.4.
|
|
65
|
+
python -m pip install --upgrade backcaster==0.4.2
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -73,7 +76,7 @@ python -m pip show backcaster
|
|
|
73
76
|
|
|
74
77
|
```bash
|
|
75
78
|
# from repo root
|
|
76
|
-
uv sync
|
|
79
|
+
uv sync --locked
|
|
77
80
|
```
|
|
78
81
|
|
|
79
82
|
This installs runtime dependencies and the `backcaster` CLI entrypoint defined in `pyproject.toml`.
|
|
@@ -81,10 +84,10 @@ This installs runtime dependencies and the `backcaster` CLI entrypoint defined i
|
|
|
81
84
|
### Install in editable/dev mode
|
|
82
85
|
|
|
83
86
|
```bash
|
|
84
|
-
uv sync --extra dev
|
|
87
|
+
uv sync --locked --extra dev
|
|
85
88
|
```
|
|
86
89
|
|
|
87
|
-
This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`).
|
|
90
|
+
This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`, `setuptools`, `wheel`, `twine`).
|
|
88
91
|
|
|
89
92
|
### Build/install as a package
|
|
90
93
|
|
|
@@ -102,7 +105,7 @@ The `player`, `team`, and `batch` commands also require `--kind batting|pitching
|
|
|
102
105
|
The `hitters` and `pitchers` commands choose their kind automatically.
|
|
103
106
|
|
|
104
107
|
Subcommand-specific required options:
|
|
105
|
-
- `player` requires `--name`
|
|
108
|
+
- `player` requires exactly one of `--name` or `--player-id`
|
|
106
109
|
- `team` requires `--team`
|
|
107
110
|
- `batch`, `hitters`, and `pitchers` require no player or team option
|
|
108
111
|
|
|
@@ -133,7 +136,9 @@ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
|
|
|
133
136
|
They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
|
|
134
137
|
For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
|
|
135
138
|
For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
|
|
136
|
-
File formats require `--out`; the CLI format prints to the terminal
|
|
139
|
+
File formats require `--out`; the CLI format prints to the terminal and rejects
|
|
140
|
+
`--out`. Invalid output options fail before data is loaded. Unwritable output
|
|
141
|
+
paths report an error on stderr and exit with status 2.
|
|
137
142
|
|
|
138
143
|
Players qualify for selection by meeting the existing PA/IP threshold in at least
|
|
139
144
|
one of those three seasons. This is a historical eligibility list, not an active
|
|
@@ -195,7 +200,15 @@ Blank numeric counting-stat cells are also treated as zero; use a dataset with
|
|
|
195
200
|
complete records for the seasons you intend to project.
|
|
196
201
|
Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
197
202
|
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
198
|
-
ambiguous name lookups report the matching IDs.
|
|
203
|
+
ambiguous name lookups report the matching IDs. Select one with `--player-id`
|
|
204
|
+
instead of `--name`, for example:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
backcaster player --player-id bettsmo01 --year 2026 --kind batting
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
IDs match Lahman `People.csv.playerID` exactly and retain the same qualification
|
|
211
|
+
rules as name lookups.
|
|
199
212
|
|
|
200
213
|
Batter league regression and rebaselining use all non-pitcher player-seasons;
|
|
201
214
|
qualification selects the displayed players. Pitching retains its qualified-only
|
|
@@ -226,7 +239,7 @@ backcaster player --name "Mookie Betts" --year 2026 --kind batting --format csv
|
|
|
226
239
|
backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out/lan_pitching_2026.html
|
|
227
240
|
```
|
|
228
241
|
|
|
229
|
-
> `--out` is required for `csv` and `html` formats
|
|
242
|
+
> `--out` is required for `csv` and `html` formats and cannot be used with `cli`.
|
|
230
243
|
|
|
231
244
|
---
|
|
232
245
|
|
|
@@ -238,8 +251,8 @@ Selected columns from a 2025 Mookie Betts projection (actual output includes
|
|
|
238
251
|
additional counting statistics, reliability, and age):
|
|
239
252
|
|
|
240
253
|
```text
|
|
241
|
-
PlayerID PA AB H HR
|
|
242
|
-
bettsmo01 527 461 128 24 0.278 0.362 0.507 0.869
|
|
254
|
+
Name Year PlayerID Age PA AB H HR BA OBP SLG OPS
|
|
255
|
+
Mookie Betts 2025 bettsmo01 33 527 461 128 24 0.278 0.362 0.507 0.869
|
|
243
256
|
```
|
|
244
257
|
|
|
245
258
|
### Number formatting and baseball innings
|
|
@@ -248,8 +261,13 @@ Terminal, CSV, and HTML outputs use the same display rules:
|
|
|
248
261
|
|
|
249
262
|
- Counting statistics are rounded to the nearest whole number, with halves rounded
|
|
250
263
|
up. Age and year are displayed as whole numbers too.
|
|
251
|
-
-
|
|
252
|
-
|
|
264
|
+
- BA, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
|
|
265
|
+
BA is the canonical batter output label (previously AVG).
|
|
266
|
+
Rates with zero denominators remain undefined (NaN) and display blank;
|
|
267
|
+
OPS is undefined when OBP or SLG is undefined.
|
|
268
|
+
- ERA shows two decimals; WHIP shows three. W-L% shows a three-decimal
|
|
269
|
+
fraction, such as `0.524`. H9, HR9, BB9, and SO9 show one decimal; SO/W shows two.
|
|
270
|
+
W-L% without any decisions and SO/W without walks are undefined and display blank.
|
|
253
271
|
- IP is rounded to the nearest out and displayed in baseball notation:
|
|
254
272
|
`161.0` is 161 innings, `161.1` is 161 innings plus one out, and `161.2` is
|
|
255
273
|
161 innings plus two outs. Three outs carry into the next whole inning.
|
|
@@ -257,13 +275,34 @@ Terminal, CSV, and HTML outputs use the same display rules:
|
|
|
257
275
|
Calculations and Python projection DataFrames retain full precision. Rates are
|
|
258
276
|
computed from unrounded totals, then formatted; they are not recalculated from
|
|
259
277
|
rounded displayed counts. Independently rounded totals may therefore not add up
|
|
260
|
-
exactly. Reliability
|
|
278
|
+
exactly. Reliability shows six decimal places in CLI, CSV, and HTML output.
|
|
261
279
|
|
|
262
280
|
**CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
|
|
263
281
|
arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
|
|
264
282
|
Backcaster uses decimal innings (`IPouts / 3`), so ERA and WHIP calculations use
|
|
265
283
|
true innings rather than the displayed notation.
|
|
266
284
|
|
|
285
|
+
### Tracked statistics and order
|
|
286
|
+
|
|
287
|
+
All outputs put available Name, Year, Player ID, and Age metadata first, followed
|
|
288
|
+
by these statistics in order and then Reliability:
|
|
289
|
+
|
|
290
|
+
```text
|
|
291
|
+
Batters: PA,AB,R,H,2B,3B,HR,RBI,SB,CS,BB,SO,BA,OBP,SLG,OPS,TB,GDP,HBP,SH,SF,IBB
|
|
292
|
+
Pitchers: W,L,W-L%,ERA,SV,IP,H,R,ER,HR,BB,IBB,SO,HBP,BK,WP,BF,WHIP,H9,HR9,BB9,SO9,SO/W
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Lahman's batting `GIDP` is exported as `GDP`. The expanded counting-stat columns
|
|
296
|
+
must be present in the appropriate local CSVs; missing columns produce an error.
|
|
297
|
+
TB is derived as `H + 2B + 2×3B + 3×HR`; stolen bases do not add total bases.
|
|
298
|
+
BA is H/AB, SLG is TB/AB, OBP is `(H+BB+HBP)/(AB+BB+HBP+SF)`, and OPS is OBP+SLG.
|
|
299
|
+
IBB is already part of BB and is never added again to OBP or PA. Runs, RBI, SB,
|
|
300
|
+
CS, and GDP are contextual counts rather than additional PA outcomes.
|
|
301
|
+
|
|
302
|
+
Pitcher per-nine rates use unrounded counts and decimal IP: `9 × count / IP`.
|
|
303
|
+
SO/W is SO/BB; W-L% is W/(W+L). Runs allowed (`R`) and earned runs (`ER`) are
|
|
304
|
+
separate projections; ERA uses ER, while WHIP uses H+BB without adding IBB again.
|
|
305
|
+
|
|
267
306
|
### CSV output (`--format csv`)
|
|
268
307
|
A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
|
|
269
308
|
Both populations include `PlayerID` for matching records across files.
|
|
@@ -302,6 +341,13 @@ Lahman distribution from the same edition. Existing online caches are ignored.
|
|
|
302
341
|
|
|
303
342
|
---
|
|
304
343
|
|
|
344
|
+
### Python data-loading errors
|
|
345
|
+
|
|
346
|
+
Local data-loading and validation failures raise
|
|
347
|
+
`backcaster.data.DataLoadError` (renamed from `DataFetchError`). Player lookup
|
|
348
|
+
failures continue to raise `PlayerLookupError`. The CLI reports these failures
|
|
349
|
+
and filesystem errors on stderr with exit status 2.
|
|
350
|
+
|
|
305
351
|
## Dependency notes
|
|
306
352
|
|
|
307
353
|
Runtime dependencies are intentionally minimal:
|
|
@@ -315,7 +361,12 @@ Development dependencies (`--extra dev`) include:
|
|
|
315
361
|
|
|
316
362
|
### Lockfile policy (`uv.lock`)
|
|
317
363
|
|
|
318
|
-
`uv.lock`
|
|
364
|
+
`uv.lock` is committed and pins runtime, development, and build dependencies. CI uses
|
|
365
|
+
`uv sync --locked --extra dev` and fails if the lockfile is out of date. CI first
|
|
366
|
+
installs the locked tools with `--no-install-project`, then installs Backcaster
|
|
367
|
+
with `--no-build-isolation` to keep build dependencies pinned. CI builds
|
|
368
|
+
without build isolation using the locked build tools; wheel smoke tests install
|
|
369
|
+
hash-checked runtime dependencies exported from the same lockfile.
|
|
319
370
|
|
|
320
371
|
Release/source ZIP archives may not include `uv.lock`. If it is missing in your environment, regenerate it locally:
|
|
321
372
|
|
|
@@ -323,7 +374,8 @@ Release/source ZIP archives may not include `uv.lock`. If it is missing in your
|
|
|
323
374
|
uv lock
|
|
324
375
|
```
|
|
325
376
|
|
|
326
|
-
Do not hand-edit `uv.lock`;
|
|
377
|
+
Do not hand-edit `uv.lock`; run `uv lock` and commit the updated lockfile when
|
|
378
|
+
dependencies change. To deliberately refresh all pins, run `uv lock --upgrade`.
|
|
327
379
|
|
|
328
380
|
---
|
|
329
381
|
|
|
@@ -25,10 +25,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
25
25
|
python -m pip install --upgrade backcaster
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
To install a specific release, such as 0.4.
|
|
28
|
+
To install a specific release, such as 0.4.2:
|
|
29
29
|
|
|
30
30
|
```powershell
|
|
31
|
-
python -m pip install --upgrade backcaster==0.4.
|
|
31
|
+
python -m pip install --upgrade backcaster==0.4.2
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -42,7 +42,7 @@ python -m pip show backcaster
|
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
# from repo root
|
|
45
|
-
uv sync
|
|
45
|
+
uv sync --locked
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
This installs runtime dependencies and the `backcaster` CLI entrypoint defined in `pyproject.toml`.
|
|
@@ -50,10 +50,10 @@ This installs runtime dependencies and the `backcaster` CLI entrypoint defined i
|
|
|
50
50
|
### Install in editable/dev mode
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
uv sync --extra dev
|
|
53
|
+
uv sync --locked --extra dev
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`).
|
|
56
|
+
This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`, `setuptools`, `wheel`, `twine`).
|
|
57
57
|
|
|
58
58
|
### Build/install as a package
|
|
59
59
|
|
|
@@ -71,7 +71,7 @@ The `player`, `team`, and `batch` commands also require `--kind batting|pitching
|
|
|
71
71
|
The `hitters` and `pitchers` commands choose their kind automatically.
|
|
72
72
|
|
|
73
73
|
Subcommand-specific required options:
|
|
74
|
-
- `player` requires `--name`
|
|
74
|
+
- `player` requires exactly one of `--name` or `--player-id`
|
|
75
75
|
- `team` requires `--team`
|
|
76
76
|
- `batch`, `hitters`, and `pitchers` require no player or team option
|
|
77
77
|
|
|
@@ -102,7 +102,9 @@ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
|
|
|
102
102
|
They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
|
|
103
103
|
For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
|
|
104
104
|
For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
|
|
105
|
-
File formats require `--out`; the CLI format prints to the terminal
|
|
105
|
+
File formats require `--out`; the CLI format prints to the terminal and rejects
|
|
106
|
+
`--out`. Invalid output options fail before data is loaded. Unwritable output
|
|
107
|
+
paths report an error on stderr and exit with status 2.
|
|
106
108
|
|
|
107
109
|
Players qualify for selection by meeting the existing PA/IP threshold in at least
|
|
108
110
|
one of those three seasons. This is a historical eligibility list, not an active
|
|
@@ -164,7 +166,15 @@ Blank numeric counting-stat cells are also treated as zero; use a dataset with
|
|
|
164
166
|
complete records for the seasons you intend to project.
|
|
165
167
|
Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
166
168
|
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
167
|
-
ambiguous name lookups report the matching IDs.
|
|
169
|
+
ambiguous name lookups report the matching IDs. Select one with `--player-id`
|
|
170
|
+
instead of `--name`, for example:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
backcaster player --player-id bettsmo01 --year 2026 --kind batting
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
IDs match Lahman `People.csv.playerID` exactly and retain the same qualification
|
|
177
|
+
rules as name lookups.
|
|
168
178
|
|
|
169
179
|
Batter league regression and rebaselining use all non-pitcher player-seasons;
|
|
170
180
|
qualification selects the displayed players. Pitching retains its qualified-only
|
|
@@ -195,7 +205,7 @@ backcaster player --name "Mookie Betts" --year 2026 --kind batting --format csv
|
|
|
195
205
|
backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out/lan_pitching_2026.html
|
|
196
206
|
```
|
|
197
207
|
|
|
198
|
-
> `--out` is required for `csv` and `html` formats
|
|
208
|
+
> `--out` is required for `csv` and `html` formats and cannot be used with `cli`.
|
|
199
209
|
|
|
200
210
|
---
|
|
201
211
|
|
|
@@ -207,8 +217,8 @@ Selected columns from a 2025 Mookie Betts projection (actual output includes
|
|
|
207
217
|
additional counting statistics, reliability, and age):
|
|
208
218
|
|
|
209
219
|
```text
|
|
210
|
-
PlayerID PA AB H HR
|
|
211
|
-
bettsmo01 527 461 128 24 0.278 0.362 0.507 0.869
|
|
220
|
+
Name Year PlayerID Age PA AB H HR BA OBP SLG OPS
|
|
221
|
+
Mookie Betts 2025 bettsmo01 33 527 461 128 24 0.278 0.362 0.507 0.869
|
|
212
222
|
```
|
|
213
223
|
|
|
214
224
|
### Number formatting and baseball innings
|
|
@@ -217,8 +227,13 @@ Terminal, CSV, and HTML outputs use the same display rules:
|
|
|
217
227
|
|
|
218
228
|
- Counting statistics are rounded to the nearest whole number, with halves rounded
|
|
219
229
|
up. Age and year are displayed as whole numbers too.
|
|
220
|
-
-
|
|
221
|
-
|
|
230
|
+
- BA, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
|
|
231
|
+
BA is the canonical batter output label (previously AVG).
|
|
232
|
+
Rates with zero denominators remain undefined (NaN) and display blank;
|
|
233
|
+
OPS is undefined when OBP or SLG is undefined.
|
|
234
|
+
- ERA shows two decimals; WHIP shows three. W-L% shows a three-decimal
|
|
235
|
+
fraction, such as `0.524`. H9, HR9, BB9, and SO9 show one decimal; SO/W shows two.
|
|
236
|
+
W-L% without any decisions and SO/W without walks are undefined and display blank.
|
|
222
237
|
- IP is rounded to the nearest out and displayed in baseball notation:
|
|
223
238
|
`161.0` is 161 innings, `161.1` is 161 innings plus one out, and `161.2` is
|
|
224
239
|
161 innings plus two outs. Three outs carry into the next whole inning.
|
|
@@ -226,13 +241,34 @@ Terminal, CSV, and HTML outputs use the same display rules:
|
|
|
226
241
|
Calculations and Python projection DataFrames retain full precision. Rates are
|
|
227
242
|
computed from unrounded totals, then formatted; they are not recalculated from
|
|
228
243
|
rounded displayed counts. Independently rounded totals may therefore not add up
|
|
229
|
-
exactly. Reliability
|
|
244
|
+
exactly. Reliability shows six decimal places in CLI, CSV, and HTML output.
|
|
230
245
|
|
|
231
246
|
**CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
|
|
232
247
|
arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
|
|
233
248
|
Backcaster uses decimal innings (`IPouts / 3`), so ERA and WHIP calculations use
|
|
234
249
|
true innings rather than the displayed notation.
|
|
235
250
|
|
|
251
|
+
### Tracked statistics and order
|
|
252
|
+
|
|
253
|
+
All outputs put available Name, Year, Player ID, and Age metadata first, followed
|
|
254
|
+
by these statistics in order and then Reliability:
|
|
255
|
+
|
|
256
|
+
```text
|
|
257
|
+
Batters: PA,AB,R,H,2B,3B,HR,RBI,SB,CS,BB,SO,BA,OBP,SLG,OPS,TB,GDP,HBP,SH,SF,IBB
|
|
258
|
+
Pitchers: W,L,W-L%,ERA,SV,IP,H,R,ER,HR,BB,IBB,SO,HBP,BK,WP,BF,WHIP,H9,HR9,BB9,SO9,SO/W
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Lahman's batting `GIDP` is exported as `GDP`. The expanded counting-stat columns
|
|
262
|
+
must be present in the appropriate local CSVs; missing columns produce an error.
|
|
263
|
+
TB is derived as `H + 2B + 2×3B + 3×HR`; stolen bases do not add total bases.
|
|
264
|
+
BA is H/AB, SLG is TB/AB, OBP is `(H+BB+HBP)/(AB+BB+HBP+SF)`, and OPS is OBP+SLG.
|
|
265
|
+
IBB is already part of BB and is never added again to OBP or PA. Runs, RBI, SB,
|
|
266
|
+
CS, and GDP are contextual counts rather than additional PA outcomes.
|
|
267
|
+
|
|
268
|
+
Pitcher per-nine rates use unrounded counts and decimal IP: `9 × count / IP`.
|
|
269
|
+
SO/W is SO/BB; W-L% is W/(W+L). Runs allowed (`R`) and earned runs (`ER`) are
|
|
270
|
+
separate projections; ERA uses ER, while WHIP uses H+BB without adding IBB again.
|
|
271
|
+
|
|
236
272
|
### CSV output (`--format csv`)
|
|
237
273
|
A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
|
|
238
274
|
Both populations include `PlayerID` for matching records across files.
|
|
@@ -271,6 +307,13 @@ Lahman distribution from the same edition. Existing online caches are ignored.
|
|
|
271
307
|
|
|
272
308
|
---
|
|
273
309
|
|
|
310
|
+
### Python data-loading errors
|
|
311
|
+
|
|
312
|
+
Local data-loading and validation failures raise
|
|
313
|
+
`backcaster.data.DataLoadError` (renamed from `DataFetchError`). Player lookup
|
|
314
|
+
failures continue to raise `PlayerLookupError`. The CLI reports these failures
|
|
315
|
+
and filesystem errors on stderr with exit status 2.
|
|
316
|
+
|
|
274
317
|
## Dependency notes
|
|
275
318
|
|
|
276
319
|
Runtime dependencies are intentionally minimal:
|
|
@@ -284,7 +327,12 @@ Development dependencies (`--extra dev`) include:
|
|
|
284
327
|
|
|
285
328
|
### Lockfile policy (`uv.lock`)
|
|
286
329
|
|
|
287
|
-
`uv.lock`
|
|
330
|
+
`uv.lock` is committed and pins runtime, development, and build dependencies. CI uses
|
|
331
|
+
`uv sync --locked --extra dev` and fails if the lockfile is out of date. CI first
|
|
332
|
+
installs the locked tools with `--no-install-project`, then installs Backcaster
|
|
333
|
+
with `--no-build-isolation` to keep build dependencies pinned. CI builds
|
|
334
|
+
without build isolation using the locked build tools; wheel smoke tests install
|
|
335
|
+
hash-checked runtime dependencies exported from the same lockfile.
|
|
288
336
|
|
|
289
337
|
Release/source ZIP archives may not include `uv.lock`. If it is missing in your environment, regenerate it locally:
|
|
290
338
|
|
|
@@ -292,7 +340,8 @@ Release/source ZIP archives may not include `uv.lock`. If it is missing in your
|
|
|
292
340
|
uv lock
|
|
293
341
|
```
|
|
294
342
|
|
|
295
|
-
Do not hand-edit `uv.lock`;
|
|
343
|
+
Do not hand-edit `uv.lock`; run `uv lock` and commit the updated lockfile when
|
|
344
|
+
dependencies change. To deliberately refresh all pins, run `uv lock --upgrade`.
|
|
296
345
|
|
|
297
346
|
---
|
|
298
347
|
|
|
@@ -21,7 +21,7 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
requires-python = ">=3.13"
|
|
23
23
|
dependencies = [
|
|
24
|
-
"pandas>=2.
|
|
24
|
+
"pandas>=2.2.2",
|
|
25
25
|
"numpy>=2.0",
|
|
26
26
|
]
|
|
27
27
|
|
|
@@ -38,6 +38,9 @@ dev = [
|
|
|
38
38
|
"mypy>=1.10",
|
|
39
39
|
"pandas-stubs>=2.2",
|
|
40
40
|
"build>=1.2",
|
|
41
|
+
"setuptools>=77",
|
|
42
|
+
"wheel",
|
|
43
|
+
"twine",
|
|
41
44
|
]
|
|
42
45
|
|
|
43
46
|
[project.scripts]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Population-level batter projections; no projected-year statistics are read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from backcaster.marcel import ProjectionError
|
|
9
|
+
from backcaster.normalize import safe_divide
|
|
10
|
+
from backcaster.stat_columns import BATTING_STAT_COLUMNS, CONTEXT_STATS, IDENTITY_COLUMNS
|
|
11
|
+
|
|
12
|
+
EVENTS = ["1B", "2B", "3B", "HR", "BB", "HBP", "SO", "SH", "SF", "OUT"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _events(frame: pd.DataFrame) -> pd.DataFrame:
|
|
16
|
+
events = frame.reindex(columns=EVENTS, fill_value=0).astype(float)
|
|
17
|
+
events["1B"] = frame["H"] - frame["2B"] - frame["3B"] - frame["HR"]
|
|
18
|
+
events["OUT"] = frame["AB"] - frame["H"] - frame["SO"]
|
|
19
|
+
if not np.isfinite(events.to_numpy()).all() or (events < 0).any().any():
|
|
20
|
+
raise ProjectionError(
|
|
21
|
+
"Batter counts must be finite and consistent (H <= AB, SO <= AB - H)."
|
|
22
|
+
)
|
|
23
|
+
return events
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _rebaseline(events: pd.DataFrame, pa: pd.Series, target: pd.Series) -> pd.DataFrame:
|
|
27
|
+
"""Rake mutually exclusive events to fixed player PA and league event totals."""
|
|
28
|
+
desired = target * float(pa.sum())
|
|
29
|
+
# Positive league outcomes must be reachable, including a historically absent
|
|
30
|
+
# event in the projected population. Seed only those cells, then rake.
|
|
31
|
+
values = events.clip(lower=1e-12).mul(desired.gt(0), axis=1)
|
|
32
|
+
for _ in range(200):
|
|
33
|
+
values = values.mul(desired.div(values.sum()).fillna(0), axis=1)
|
|
34
|
+
values = values.mul(pa.div(values.sum(axis=1)).fillna(0), axis=0)
|
|
35
|
+
if np.allclose(values.sum(), desired, rtol=1e-10, atol=1e-8):
|
|
36
|
+
return values
|
|
37
|
+
raise ProjectionError("Batter league rebaselining did not converge.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _context_counts(history: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame:
|
|
41
|
+
counts = history[list(CONTEXT_STATS)].astype(float)
|
|
42
|
+
if not np.isfinite(counts.to_numpy()).all() or (counts < 0).any().any():
|
|
43
|
+
raise ProjectionError("Contextual batter counts must be finite and nonnegative.")
|
|
44
|
+
if (counts["IBB"] > history["BB"]).any() or (counts["GDP"] > events["OUT"]).any():
|
|
45
|
+
raise ProjectionError(
|
|
46
|
+
"IBB must not exceed BB and GDP must not exceed non-strikeout AB outs."
|
|
47
|
+
)
|
|
48
|
+
return counts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _regress(
|
|
52
|
+
history: pd.DataFrame, counts: pd.DataFrame, year: int
|
|
53
|
+
) -> tuple[pd.DataFrame, pd.Series, pd.DataFrame]:
|
|
54
|
+
weights = history["Season"].map({year - 1: 5.0, year - 2: 4.0, year - 3: 3.0})
|
|
55
|
+
exposure = history["PA"] * weights
|
|
56
|
+
league_pa = history.groupby("Season")["PA"].sum()
|
|
57
|
+
if year - 1 not in league_pa:
|
|
58
|
+
raise ProjectionError("Latest completed season has no non-pitcher batting PA.")
|
|
59
|
+
league_rates = counts.groupby(history["Season"]).sum().div(league_pa, axis=0)
|
|
60
|
+
expected = league_rates.reindex(history["Season"]).set_axis(history.index).mul(exposure, axis=0)
|
|
61
|
+
evidence = counts.mul(weights, axis=0).groupby(history["PlayerID"]).sum()
|
|
62
|
+
evidence_pa = exposure.groupby(history["PlayerID"]).sum()
|
|
63
|
+
baseline = expected.groupby(history["PlayerID"]).sum().div(evidence_pa, axis=0)
|
|
64
|
+
return (evidence + 1200 * baseline).div(evidence_pa + 1200, axis=0), evidence_pa, league_rates
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _playing_time(history: pd.DataFrame, year: int) -> pd.Series:
|
|
68
|
+
prior = (
|
|
69
|
+
history.pivot_table(index="PlayerID", columns="Season", values="PA", aggfunc="sum")
|
|
70
|
+
.reindex(columns=[year - 1, year - 2], fill_value=0)
|
|
71
|
+
.fillna(0)
|
|
72
|
+
)
|
|
73
|
+
return 0.5 * prior[year - 1] + 0.1 * prior[year - 2] + 200
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _age_rates(
|
|
77
|
+
rates: pd.DataFrame, people: pd.DataFrame, year: int
|
|
78
|
+
) -> tuple[pd.DataFrame, pd.Series]:
|
|
79
|
+
birth = pd.to_numeric(people["birthYear"], errors="coerce")
|
|
80
|
+
age = year - birth
|
|
81
|
+
if birth.isna().any() or not np.isfinite(age).all() or ((age < 15) | (age > 70)).any():
|
|
82
|
+
raise ProjectionError(
|
|
83
|
+
"Valid birthYear is required for every batter in the projection population."
|
|
84
|
+
)
|
|
85
|
+
adjustment = (age - 29) * np.where(age > 29, 0.003, 0.006)
|
|
86
|
+
aged = rates.copy()
|
|
87
|
+
positive = ["1B", "2B", "3B", "HR", "BB", "HBP", "R", "RBI", "SB", "IBB"]
|
|
88
|
+
negative = ["SO", "OUT", "CS", "GDP"]
|
|
89
|
+
aged[positive] = aged[positive].mul(1 - adjustment, axis=0)
|
|
90
|
+
aged[negative] = aged[negative].mul(1 + adjustment, axis=0)
|
|
91
|
+
return aged, age
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _bounded_rebaseline(values: pd.Series, target: float, cap: pd.Series) -> pd.Series:
|
|
95
|
+
"""Preserve a contextual league total while respecting per-player capacity."""
|
|
96
|
+
capacity = float(cap.sum())
|
|
97
|
+
tolerance = max(1e-8, capacity * 1e-9)
|
|
98
|
+
if target < 0 or target > capacity + tolerance:
|
|
99
|
+
raise ProjectionError("Contextual league target exceeds its available event capacity.")
|
|
100
|
+
if target == 0:
|
|
101
|
+
return values * 0
|
|
102
|
+
if target >= capacity:
|
|
103
|
+
return cap.copy()
|
|
104
|
+
# Normalize weights so target/smallest weight is a finite upper bound.
|
|
105
|
+
weights = np.maximum(values.to_numpy(dtype=float), 1e-12)
|
|
106
|
+
weights = weights / float(weights.max())
|
|
107
|
+
limits = cap.to_numpy(dtype=float)
|
|
108
|
+
low, high = 0.0, target / float(weights.min())
|
|
109
|
+
for _ in range(150):
|
|
110
|
+
scale = (low + high) / 2
|
|
111
|
+
result = np.minimum(weights * scale, limits)
|
|
112
|
+
if float(result.sum()) < target:
|
|
113
|
+
low = scale
|
|
114
|
+
else:
|
|
115
|
+
high = scale
|
|
116
|
+
return pd.Series(
|
|
117
|
+
np.minimum(weights * ((low + high) / 2), limits), index=values.index, name=values.name
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _project_context(
|
|
122
|
+
rates: pd.DataFrame, pa: pd.Series, target: pd.Series, events: pd.DataFrame
|
|
123
|
+
) -> pd.DataFrame:
|
|
124
|
+
projected = rates[list(CONTEXT_STATS)].mul(pa, axis=0)
|
|
125
|
+
desired = target[list(CONTEXT_STATS)] * float(pa.sum())
|
|
126
|
+
# Runs/RBI/baserunning are overlapping events, not extra PA outcomes.
|
|
127
|
+
for stat in ["R", "RBI", "SB", "CS"]:
|
|
128
|
+
projected[stat] *= safe_divide(float(desired[stat]), float(projected[stat].sum()))
|
|
129
|
+
projected["IBB"] = _bounded_rebaseline(projected["IBB"], float(desired["IBB"]), events["BB"])
|
|
130
|
+
projected["GDP"] = _bounded_rebaseline(projected["GDP"], float(desired["GDP"]), events["OUT"])
|
|
131
|
+
return projected
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _summarize(projected: pd.DataFrame, pa: pd.Series) -> pd.DataFrame:
|
|
135
|
+
out = projected.copy()
|
|
136
|
+
out["H"] = out[["1B", "2B", "3B", "HR"]].sum(axis=1)
|
|
137
|
+
out["AB"] = out["H"] + out["SO"] + out["OUT"]
|
|
138
|
+
out["PA"] = pa
|
|
139
|
+
out["TB"] = out["H"] + out["2B"] + 2 * out["3B"] + 3 * out["HR"]
|
|
140
|
+
out["BA"] = out["H"] / out["AB"].replace(0, np.nan)
|
|
141
|
+
# BB includes IBB already. SB/CS/R/RBI/GDP do not add PA or total bases.
|
|
142
|
+
out["OBP"] = (out["H"] + out["BB"] + out["HBP"]) / (
|
|
143
|
+
out["AB"] + out["BB"] + out["HBP"] + out["SF"]
|
|
144
|
+
).replace(0, np.nan)
|
|
145
|
+
out["SLG"] = out["TB"] / out["AB"].replace(0, np.nan)
|
|
146
|
+
out["OPS"] = out["OBP"] + out["SLG"]
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def project_batters(seasons: pd.DataFrame, year: int) -> pd.DataFrame:
|
|
151
|
+
"""Use all non-pitcher seasons for regression and population rebaselining."""
|
|
152
|
+
history = seasons[seasons["Season"].isin([year - 1, year - 2, year - 3])].copy()
|
|
153
|
+
if history.empty:
|
|
154
|
+
raise ProjectionError("No non-pitcher batting history in the three prior seasons.")
|
|
155
|
+
history = history[history["PA"] > 0].copy()
|
|
156
|
+
events = _events(history)
|
|
157
|
+
counts = events.join(_context_counts(history, events))
|
|
158
|
+
rates, evidence_pa, league_rates = _regress(history, counts, year)
|
|
159
|
+
pa = _playing_time(history, year)
|
|
160
|
+
people = (
|
|
161
|
+
history.sort_values("Season")
|
|
162
|
+
.drop_duplicates("PlayerID", keep="last")
|
|
163
|
+
.set_index("PlayerID")
|
|
164
|
+
.reindex(rates.index)
|
|
165
|
+
)
|
|
166
|
+
rates, age = _age_rates(rates, people, year)
|
|
167
|
+
event_rates = rates[EVENTS].div(rates[EVENTS].sum(axis=1), axis=0)
|
|
168
|
+
target = league_rates.loc[[year - 1]].iloc[0]
|
|
169
|
+
projected = _rebaseline(event_rates.mul(pa, axis=0), pa, target[EVENTS])
|
|
170
|
+
context = _project_context(rates, pa, target, projected)
|
|
171
|
+
out = _summarize(projected.join(context), pa)
|
|
172
|
+
out["Reliability"] = evidence_pa / (evidence_pa + 1200)
|
|
173
|
+
out["Age"], out["Name"], out["Year"] = age, people["Name"], year
|
|
174
|
+
out = out.reset_index()
|
|
175
|
+
return out[[*IDENTITY_COLUMNS, *BATTING_STAT_COLUMNS, "Reliability"]]
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
import pandas as pd
|
|
9
9
|
|
|
10
|
-
from backcaster.data import
|
|
10
|
+
from backcaster.data import DataLoadError, PlayerLookupError
|
|
11
11
|
from backcaster.lahman import LahmanSource
|
|
12
12
|
from backcaster.marcel import ProjectionError
|
|
13
13
|
from backcaster.outputs import to_cli_table, to_csv, to_html
|
|
@@ -25,12 +25,19 @@ def _default_data_dir() -> str:
|
|
|
25
25
|
return os.environ.get("LAHMAN_DATA_DIR", default)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _validate_output_options(output_format: str, out: str | None) -> None:
|
|
29
|
+
if output_format == "cli" and out is not None:
|
|
30
|
+
raise ValueError("--out cannot be used with --format cli; choose --format csv or html")
|
|
31
|
+
if output_format in _FILE_RENDERERS and not out:
|
|
32
|
+
raise ValueError(f"--out is required for {output_format} output")
|
|
33
|
+
|
|
34
|
+
|
|
28
35
|
def _render(df: pd.DataFrame, output_format: str, out: str | None) -> None:
|
|
36
|
+
_validate_output_options(output_format, out)
|
|
29
37
|
if output_format == "cli":
|
|
30
38
|
print(to_cli_table(df))
|
|
31
39
|
elif output_format in _FILE_RENDERERS:
|
|
32
|
-
|
|
33
|
-
raise ValueError(f"--out is required for {output_format} output")
|
|
40
|
+
assert out is not None
|
|
34
41
|
_FILE_RENDERERS[output_format](df, out)
|
|
35
42
|
else:
|
|
36
43
|
raise ValueError(f"Unsupported format: {output_format}")
|
|
@@ -47,7 +54,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
47
54
|
else:
|
|
48
55
|
sp.set_defaults(kind=kind)
|
|
49
56
|
sp.add_argument("--format", choices=_OUTPUT_FORMAT_CHOICES, default="cli")
|
|
50
|
-
sp.add_argument("--out")
|
|
57
|
+
sp.add_argument("--out", help="Output path (required for csv/html; invalid with cli)")
|
|
51
58
|
sp.add_argument(
|
|
52
59
|
"--data-dir",
|
|
53
60
|
default=_default_data_dir(),
|
|
@@ -56,7 +63,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
56
63
|
|
|
57
64
|
pp = sub.add_parser("player")
|
|
58
65
|
add_common(pp)
|
|
59
|
-
pp.
|
|
66
|
+
player_selector = pp.add_mutually_exclusive_group(required=True)
|
|
67
|
+
player_selector.add_argument("--name", help="Player name (must identify one qualified player)")
|
|
68
|
+
player_selector.add_argument(
|
|
69
|
+
"--player-id", help="Exact Lahman player ID (for example, bettsmo01)"
|
|
70
|
+
)
|
|
60
71
|
|
|
61
72
|
tp = sub.add_parser("team")
|
|
62
73
|
add_common(tp)
|
|
@@ -75,16 +86,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
75
86
|
parser = build_parser()
|
|
76
87
|
args = parser.parse_args(argv)
|
|
77
88
|
try:
|
|
89
|
+
_validate_output_options(args.format, args.out)
|
|
78
90
|
result = LahmanSource(args.data_dir).project(
|
|
79
91
|
args.command,
|
|
80
92
|
args.year,
|
|
81
93
|
args.kind,
|
|
82
94
|
name=getattr(args, "name", None),
|
|
83
95
|
team=getattr(args, "team", None),
|
|
96
|
+
player_id=getattr(args, "player_id", None),
|
|
84
97
|
)
|
|
85
98
|
_render(result, args.format, args.out)
|
|
86
99
|
return 0
|
|
87
|
-
except (
|
|
100
|
+
except (DataLoadError, PlayerLookupError, ProjectionError, ValueError, OSError) as exc:
|
|
88
101
|
print(f"Error: {exc}", file=sys.stderr)
|
|
89
102
|
return 2
|
|
90
103
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Errors reported when local source data cannot be loaded or resolved."""
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
class
|
|
5
|
-
|
|
4
|
+
class DataLoadError(RuntimeError):
|
|
5
|
+
"""Local source files are missing, unreadable, or invalid."""
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class PlayerLookupError(RuntimeError):
|