backcaster 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.
- {backcaster-0.3.0/src/backcaster.egg-info → backcaster-0.3.2}/PKG-INFO +67 -1
- {backcaster-0.3.0 → backcaster-0.3.2}/README.md +66 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/__init__.py +1 -1
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/cli.py +15 -0
- backcaster-0.3.2/src/backcaster/lahman.py +143 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/marcel.py +4 -1
- {backcaster-0.3.0 → backcaster-0.3.2/src/backcaster.egg-info}/PKG-INFO +67 -1
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/SOURCES.txt +2 -0
- backcaster-0.3.2/tests/test_lahman.py +239 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_marcel_math.py +2 -1
- {backcaster-0.3.0 → backcaster-0.3.2}/LICENSE +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/pyproject.toml +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/setup.cfg +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/data.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/normalize.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/outputs.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/schemas.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/dependency_links.txt +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/entry_points.txt +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/requires.txt +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/top_level.txt +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_cli.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_cli_player_lookup.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_data.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_normalize.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_outputs.py +0 -0
- {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_player_lookup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backcaster
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Marcel-style MLB projection CLI built on pybaseball
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
|
|
@@ -46,6 +46,27 @@ It supports:
|
|
|
46
46
|
- Python **3.13+**
|
|
47
47
|
- Internet access for first-time stat fetches (data is cached locally afterward)
|
|
48
48
|
|
|
49
|
+
### Update an installed package
|
|
50
|
+
|
|
51
|
+
Upgrade to the latest release available on PyPI:
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
python -m pip install --upgrade backcaster
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
To install a specific release, such as 0.3.2:
|
|
58
|
+
|
|
59
|
+
```powershell
|
|
60
|
+
python -m pip install --upgrade backcaster==0.3.2
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run the command using the same Python environment where you run Backcaster.
|
|
64
|
+
If you use a virtual environment, activate it first. Check the installed version with:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
python -m pip show backcaster
|
|
68
|
+
```
|
|
69
|
+
|
|
49
70
|
### Install from source (recommended for contributors)
|
|
50
71
|
|
|
51
72
|
```bash
|
|
@@ -93,6 +114,51 @@ backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <battin
|
|
|
93
114
|
backcaster player --name "Mookie Betts" --year 2026 --kind batting
|
|
94
115
|
```
|
|
95
116
|
|
|
117
|
+
### Offline projections with Lahman CSVs
|
|
118
|
+
|
|
119
|
+
Extract your Lahman CSV distribution into a local directory. Pass `--data-dir`
|
|
120
|
+
to use it without any network requests, player-register downloads, or FanGraphs
|
|
121
|
+
cache reads. The directory must contain `People.csv`, `Teams.csv`, and
|
|
122
|
+
`Batting.csv` or `Pitching.csv` for the selected kind. Filenames are matched
|
|
123
|
+
without regard to case. The database is not bundled with Backcaster.
|
|
124
|
+
|
|
125
|
+
See [Lahman database notes](docs/LAHMAN.md) for the supplied 1871–2025 edition's
|
|
126
|
+
coverage, attribution, license, and historical-data caveats, with its original README.
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
backcaster player --name "Mookie Betts" --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
|
|
130
|
+
backcaster team --team LAN --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
|
|
131
|
+
backcaster batch --year 2022 --kind pitching --data-dir "C:\Baseball\lahman" --format csv --out pitchers.csv
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Use Lahman's `teamID` values, such as `LAN` for the Dodgers (rather than the
|
|
135
|
+
FanGraphs abbreviation `LAD`). A projection year needs the preceding three seasons:
|
|
136
|
+
2022 requires 2019–2021. Missing seasons produce an error explaining which year
|
|
137
|
+
is unavailable. Without `--data-dir`, the existing pybaseball source is used;
|
|
138
|
+
FanGraphs may reject its automated requests with HTTP 403.
|
|
139
|
+
|
|
140
|
+
Offline qualification uses a consistent modern rule across historical seasons:
|
|
141
|
+
at least **3.1 PA per game** for hitters and **one inning per game** for pitchers.
|
|
142
|
+
The season's game count is the maximum `G` in that year's `Teams.csv` records,
|
|
143
|
+
so shortened seasons have lower thresholds. The PA threshold is rounded to the
|
|
144
|
+
nearest integer (502 PA for 162 games, 186 PA for 60 games). This is an analytical filter, not a
|
|
145
|
+
reconstruction of historical award eligibility or batting-title exceptions.
|
|
146
|
+
Relievers generally do not qualify under this pitching rule.
|
|
147
|
+
|
|
148
|
+
Player stints are summed before qualification. Plate appearances are calculated
|
|
149
|
+
as `AB + BB + HBP + SF + SH`; missing/blank HBP, SF, and SH values count as zero.
|
|
150
|
+
Blank numeric counting-stat cells are also treated as zero; use a dataset with
|
|
151
|
+
complete records for the seasons you intend to project.
|
|
152
|
+
Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
153
|
+
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
154
|
+
ambiguous name lookups report the matching IDs.
|
|
155
|
+
|
|
156
|
+
Only qualified player-seasons enter projections and league regression baselines.
|
|
157
|
+
Single-player projections require qualification in all three preceding seasons.
|
|
158
|
+
Team and batch projections use each player's available qualified seasons within
|
|
159
|
+
that window. For team selection, a traded player's full-season totals are assigned
|
|
160
|
+
to the team in their latest stint for that season; this is not a current-roster forecast.
|
|
161
|
+
|
|
96
162
|
### 2) Team projection
|
|
97
163
|
|
|
98
164
|
```bash
|
|
@@ -16,6 +16,27 @@ It supports:
|
|
|
16
16
|
- Python **3.13+**
|
|
17
17
|
- Internet access for first-time stat fetches (data is cached locally afterward)
|
|
18
18
|
|
|
19
|
+
### Update an installed package
|
|
20
|
+
|
|
21
|
+
Upgrade to the latest release available on PyPI:
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
python -m pip install --upgrade backcaster
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
To install a specific release, such as 0.3.2:
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
python -m pip install --upgrade backcaster==0.3.2
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Run the command using the same Python environment where you run Backcaster.
|
|
34
|
+
If you use a virtual environment, activate it first. Check the installed version with:
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
python -m pip show backcaster
|
|
38
|
+
```
|
|
39
|
+
|
|
19
40
|
### Install from source (recommended for contributors)
|
|
20
41
|
|
|
21
42
|
```bash
|
|
@@ -63,6 +84,51 @@ backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <battin
|
|
|
63
84
|
backcaster player --name "Mookie Betts" --year 2026 --kind batting
|
|
64
85
|
```
|
|
65
86
|
|
|
87
|
+
### Offline projections with Lahman CSVs
|
|
88
|
+
|
|
89
|
+
Extract your Lahman CSV distribution into a local directory. Pass `--data-dir`
|
|
90
|
+
to use it without any network requests, player-register downloads, or FanGraphs
|
|
91
|
+
cache reads. The directory must contain `People.csv`, `Teams.csv`, and
|
|
92
|
+
`Batting.csv` or `Pitching.csv` for the selected kind. Filenames are matched
|
|
93
|
+
without regard to case. The database is not bundled with Backcaster.
|
|
94
|
+
|
|
95
|
+
See [Lahman database notes](docs/LAHMAN.md) for the supplied 1871–2025 edition's
|
|
96
|
+
coverage, attribution, license, and historical-data caveats, with its original README.
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
backcaster player --name "Mookie Betts" --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
|
|
100
|
+
backcaster team --team LAN --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
|
|
101
|
+
backcaster batch --year 2022 --kind pitching --data-dir "C:\Baseball\lahman" --format csv --out pitchers.csv
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Use Lahman's `teamID` values, such as `LAN` for the Dodgers (rather than the
|
|
105
|
+
FanGraphs abbreviation `LAD`). A projection year needs the preceding three seasons:
|
|
106
|
+
2022 requires 2019–2021. Missing seasons produce an error explaining which year
|
|
107
|
+
is unavailable. Without `--data-dir`, the existing pybaseball source is used;
|
|
108
|
+
FanGraphs may reject its automated requests with HTTP 403.
|
|
109
|
+
|
|
110
|
+
Offline qualification uses a consistent modern rule across historical seasons:
|
|
111
|
+
at least **3.1 PA per game** for hitters and **one inning per game** for pitchers.
|
|
112
|
+
The season's game count is the maximum `G` in that year's `Teams.csv` records,
|
|
113
|
+
so shortened seasons have lower thresholds. The PA threshold is rounded to the
|
|
114
|
+
nearest integer (502 PA for 162 games, 186 PA for 60 games). This is an analytical filter, not a
|
|
115
|
+
reconstruction of historical award eligibility or batting-title exceptions.
|
|
116
|
+
Relievers generally do not qualify under this pitching rule.
|
|
117
|
+
|
|
118
|
+
Player stints are summed before qualification. Plate appearances are calculated
|
|
119
|
+
as `AB + BB + HBP + SF + SH`; missing/blank HBP, SF, and SH values count as zero.
|
|
120
|
+
Blank numeric counting-stat cells are also treated as zero; use a dataset with
|
|
121
|
+
complete records for the seasons you intend to project.
|
|
122
|
+
Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
123
|
+
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
124
|
+
ambiguous name lookups report the matching IDs.
|
|
125
|
+
|
|
126
|
+
Only qualified player-seasons enter projections and league regression baselines.
|
|
127
|
+
Single-player projections require qualification in all three preceding seasons.
|
|
128
|
+
Team and batch projections use each player's available qualified seasons within
|
|
129
|
+
that window. For team selection, a traded player's full-season totals are assigned
|
|
130
|
+
to the team in their latest stint for that season; this is not a current-roster forecast.
|
|
131
|
+
|
|
66
132
|
### 2) Team projection
|
|
67
133
|
|
|
68
134
|
```bash
|
|
@@ -12,6 +12,7 @@ from backcaster.data import (
|
|
|
12
12
|
lookup_player_ids,
|
|
13
13
|
resolve_player_lookup,
|
|
14
14
|
)
|
|
15
|
+
from backcaster.lahman import LahmanSource
|
|
15
16
|
from backcaster.marcel import ProjectionError, project_player, project_team
|
|
16
17
|
from backcaster.outputs import to_cli_table, to_csv, to_html
|
|
17
18
|
|
|
@@ -130,6 +131,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
130
131
|
sp.add_argument("--kind", choices=["batting", "pitching"], required=True)
|
|
131
132
|
sp.add_argument("--format", choices=_OUTPUT_FORMAT_CHOICES, default="cli")
|
|
132
133
|
sp.add_argument("--out")
|
|
134
|
+
sp.add_argument(
|
|
135
|
+
"--data-dir",
|
|
136
|
+
help="Directory containing extracted Lahman CSVs (offline, qualified players only)",
|
|
137
|
+
)
|
|
133
138
|
|
|
134
139
|
pp = sub.add_parser("player")
|
|
135
140
|
add_common(pp)
|
|
@@ -151,6 +156,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
151
156
|
parser = build_parser()
|
|
152
157
|
args = parser.parse_args(argv)
|
|
153
158
|
try:
|
|
159
|
+
if args.data_dir:
|
|
160
|
+
result = LahmanSource(args.data_dir).project(
|
|
161
|
+
args.command,
|
|
162
|
+
args.year,
|
|
163
|
+
args.kind,
|
|
164
|
+
name=getattr(args, "name", None),
|
|
165
|
+
team=getattr(args, "team", None),
|
|
166
|
+
)
|
|
167
|
+
_render(result, args.format, args.out)
|
|
168
|
+
return 0
|
|
154
169
|
return args.func(args)
|
|
155
170
|
except (DataFetchError, PlayerLookupError, ProjectionError, ValueError) as exc:
|
|
156
171
|
print(f"Error: {exc}", file=sys.stderr)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Offline projections from extracted Lahman CSV files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from backcaster.data import DataFetchError, PlayerLookupError
|
|
10
|
+
from backcaster.marcel import ProjectionError, project_player, project_team
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LahmanSource:
|
|
14
|
+
def __init__(self, directory: str) -> None:
|
|
15
|
+
self.directory = Path(directory)
|
|
16
|
+
self.tables: dict[str, pd.DataFrame] = {}
|
|
17
|
+
|
|
18
|
+
def read(self, name: str, required: set[str]) -> pd.DataFrame:
|
|
19
|
+
if name not in self.tables:
|
|
20
|
+
paths = [
|
|
21
|
+
p for p in self.directory.glob("*") if p.name.casefold() == f"{name}.csv".casefold()
|
|
22
|
+
]
|
|
23
|
+
if len(paths) != 1:
|
|
24
|
+
raise DataFetchError(f"Expected {name}.csv in Lahman directory '{self.directory}'.")
|
|
25
|
+
try:
|
|
26
|
+
self.tables[name] = pd.read_csv(paths[0], encoding="utf-8-sig", low_memory=False)
|
|
27
|
+
except (OSError, ValueError) as exc:
|
|
28
|
+
raise DataFetchError(f"Unable to read Lahman {name}.csv: {exc}") from exc
|
|
29
|
+
frame = self.tables[name]
|
|
30
|
+
missing = required - set(frame.columns)
|
|
31
|
+
if missing:
|
|
32
|
+
raise DataFetchError(
|
|
33
|
+
f"Lahman {name}.csv missing columns: {', '.join(sorted(missing))}."
|
|
34
|
+
)
|
|
35
|
+
return frame
|
|
36
|
+
|
|
37
|
+
def season(self, year: int, kind: str) -> pd.DataFrame:
|
|
38
|
+
if kind not in {"batting", "pitching"}:
|
|
39
|
+
raise DataFetchError(f"Invalid kind {kind!r}.")
|
|
40
|
+
name = "Batting" if kind == "batting" else "Pitching"
|
|
41
|
+
components = (
|
|
42
|
+
["AB", "H", "2B", "3B", "HR", "BB", "SO"]
|
|
43
|
+
if kind == "batting"
|
|
44
|
+
else ["IPouts", "ER", "H", "HR", "BB", "SO", "BFP"]
|
|
45
|
+
)
|
|
46
|
+
stats = self.read(name, {"playerID", "yearID", "stint", "teamID", *components})
|
|
47
|
+
stats = stats[stats["yearID"] == year].copy()
|
|
48
|
+
if stats.empty:
|
|
49
|
+
raise DataFetchError(
|
|
50
|
+
f"Lahman {name}.csv has no data for {year}; choose a covered year or update the dataset."
|
|
51
|
+
)
|
|
52
|
+
games = self._season_games(year)
|
|
53
|
+
if kind == "batting":
|
|
54
|
+
for column in ["HBP", "SF", "SH"]:
|
|
55
|
+
if column not in stats:
|
|
56
|
+
stats[column] = 0
|
|
57
|
+
components += ["HBP", "SF", "SH"]
|
|
58
|
+
try:
|
|
59
|
+
stats[components] = stats[components].apply(pd.to_numeric, errors="raise").fillna(0)
|
|
60
|
+
stats["stint"] = pd.to_numeric(stats["stint"], errors="raise")
|
|
61
|
+
except (ValueError, TypeError) as exc:
|
|
62
|
+
raise DataFetchError(
|
|
63
|
+
f"Invalid numeric data in Lahman {name}.csv for {year}: {exc}"
|
|
64
|
+
) from exc
|
|
65
|
+
totals = stats.groupby("playerID", sort=False)[components].sum()
|
|
66
|
+
latest = (
|
|
67
|
+
stats.sort_values("stint", kind="stable")
|
|
68
|
+
.drop_duplicates("playerID", keep="last")
|
|
69
|
+
.set_index("playerID")
|
|
70
|
+
)
|
|
71
|
+
totals["Team"] = latest["teamID"]
|
|
72
|
+
if kind == "batting":
|
|
73
|
+
totals["PA"] = totals[["AB", "BB", "HBP", "SF", "SH"]].sum(axis=1)
|
|
74
|
+
minimum_pa = int(3.1 * games + 0.5)
|
|
75
|
+
totals = totals[totals["PA"] >= minimum_pa]
|
|
76
|
+
else:
|
|
77
|
+
totals["IP"] = totals["IPouts"] / 3
|
|
78
|
+
totals["BF"] = totals["BFP"]
|
|
79
|
+
totals = totals[totals["IPouts"] >= 3 * games]
|
|
80
|
+
if totals.empty:
|
|
81
|
+
raise DataFetchError(f"No qualified {kind} players in Lahman for {year}.")
|
|
82
|
+
totals = self._attach_names(totals)
|
|
83
|
+
totals["PlayerID"] = totals["playerID"]
|
|
84
|
+
totals["Season"] = year
|
|
85
|
+
return totals
|
|
86
|
+
|
|
87
|
+
def _season_games(self, year: int) -> float:
|
|
88
|
+
teams = self.read("Teams", {"yearID", "teamID", "G"})
|
|
89
|
+
games = pd.to_numeric(teams.loc[teams["yearID"] == year, "G"], errors="coerce").max()
|
|
90
|
+
if pd.isna(games) or games <= 0:
|
|
91
|
+
raise DataFetchError(f"Lahman Teams.csv has no valid game counts for {year}.")
|
|
92
|
+
return float(games)
|
|
93
|
+
|
|
94
|
+
def _attach_names(self, totals: pd.DataFrame) -> pd.DataFrame:
|
|
95
|
+
people = self.read("People", {"playerID", "nameFirst", "nameLast"}).copy()
|
|
96
|
+
people["Name"] = (
|
|
97
|
+
people["nameFirst"].fillna("") + " " + people["nameLast"].fillna("")
|
|
98
|
+
).str.strip()
|
|
99
|
+
try:
|
|
100
|
+
totals = totals.reset_index().merge(
|
|
101
|
+
people[["playerID", "Name"]], on="playerID", how="left", validate="many_to_one"
|
|
102
|
+
)
|
|
103
|
+
except pd.errors.MergeError as exc:
|
|
104
|
+
raise DataFetchError("Duplicate player IDs in Lahman People.csv.") from exc
|
|
105
|
+
if totals["Name"].isna().any():
|
|
106
|
+
raise DataFetchError(
|
|
107
|
+
"Lahman People.csv is missing players present in season statistics."
|
|
108
|
+
)
|
|
109
|
+
return totals
|
|
110
|
+
|
|
111
|
+
def project(
|
|
112
|
+
self, command: str, year: int, kind: str, name: str | None = None, team: str | None = None
|
|
113
|
+
) -> pd.DataFrame:
|
|
114
|
+
seasons = pd.concat(
|
|
115
|
+
[self.season(y, kind) for y in (year - 1, year - 2, year - 3)], ignore_index=True
|
|
116
|
+
)
|
|
117
|
+
if command == "player":
|
|
118
|
+
normalized = " ".join((name or "").casefold().split())
|
|
119
|
+
matches = seasons[
|
|
120
|
+
seasons["Name"].map(lambda value: " ".join(value.casefold().split())) == normalized
|
|
121
|
+
]
|
|
122
|
+
ids = matches["PlayerID"].unique()
|
|
123
|
+
if len(ids) == 0:
|
|
124
|
+
raise PlayerLookupError(
|
|
125
|
+
f"No qualified Lahman player found for '{name}' in the prior three seasons."
|
|
126
|
+
)
|
|
127
|
+
if len(ids) > 1:
|
|
128
|
+
raise PlayerLookupError(
|
|
129
|
+
f"Ambiguous Lahman name '{name}'; matching player IDs: {', '.join(ids)}."
|
|
130
|
+
)
|
|
131
|
+
if matches["Season"].nunique() != 3:
|
|
132
|
+
raise ProjectionError(f"Player '{name}' must qualify in all three prior seasons.")
|
|
133
|
+
return project_player(
|
|
134
|
+
str(matches["Name"].iloc[0]), matches, kind, year, league_df=seasons
|
|
135
|
+
)
|
|
136
|
+
selected = seasons
|
|
137
|
+
if command == "team":
|
|
138
|
+
selected = seasons[seasons["Team"].str.casefold() == (team or "").casefold()]
|
|
139
|
+
if selected.empty:
|
|
140
|
+
raise ProjectionError(
|
|
141
|
+
f"No qualified players for Lahman team ID '{team}' in the prior three seasons."
|
|
142
|
+
)
|
|
143
|
+
return project_team(selected, kind, year, baseline_df=seasons)
|
|
@@ -179,7 +179,10 @@ def project_team(
|
|
|
179
179
|
comps = PITCHING_COMPONENTS
|
|
180
180
|
baseline_source = baseline_df if baseline_df is not None else team_df
|
|
181
181
|
_, baseline_rates = _compute_baseline_rates(baseline_source, comps)
|
|
182
|
-
|
|
182
|
+
identity = "PlayerID" if "PlayerID" in team_df.columns else "IDfg"
|
|
183
|
+
group_column = (
|
|
184
|
+
team_df[identity].fillna(team_df["Name"]) if identity in team_df.columns else "Name"
|
|
185
|
+
)
|
|
183
186
|
grouped = team_df.groupby(group_column, dropna=False, sort=False)
|
|
184
187
|
projections: list[pd.DataFrame] = []
|
|
185
188
|
for _, grp in grouped:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backcaster
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Marcel-style MLB projection CLI built on pybaseball
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
|
|
@@ -46,6 +46,27 @@ It supports:
|
|
|
46
46
|
- Python **3.13+**
|
|
47
47
|
- Internet access for first-time stat fetches (data is cached locally afterward)
|
|
48
48
|
|
|
49
|
+
### Update an installed package
|
|
50
|
+
|
|
51
|
+
Upgrade to the latest release available on PyPI:
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
python -m pip install --upgrade backcaster
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
To install a specific release, such as 0.3.2:
|
|
58
|
+
|
|
59
|
+
```powershell
|
|
60
|
+
python -m pip install --upgrade backcaster==0.3.2
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run the command using the same Python environment where you run Backcaster.
|
|
64
|
+
If you use a virtual environment, activate it first. Check the installed version with:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
python -m pip show backcaster
|
|
68
|
+
```
|
|
69
|
+
|
|
49
70
|
### Install from source (recommended for contributors)
|
|
50
71
|
|
|
51
72
|
```bash
|
|
@@ -93,6 +114,51 @@ backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <battin
|
|
|
93
114
|
backcaster player --name "Mookie Betts" --year 2026 --kind batting
|
|
94
115
|
```
|
|
95
116
|
|
|
117
|
+
### Offline projections with Lahman CSVs
|
|
118
|
+
|
|
119
|
+
Extract your Lahman CSV distribution into a local directory. Pass `--data-dir`
|
|
120
|
+
to use it without any network requests, player-register downloads, or FanGraphs
|
|
121
|
+
cache reads. The directory must contain `People.csv`, `Teams.csv`, and
|
|
122
|
+
`Batting.csv` or `Pitching.csv` for the selected kind. Filenames are matched
|
|
123
|
+
without regard to case. The database is not bundled with Backcaster.
|
|
124
|
+
|
|
125
|
+
See [Lahman database notes](docs/LAHMAN.md) for the supplied 1871–2025 edition's
|
|
126
|
+
coverage, attribution, license, and historical-data caveats, with its original README.
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
backcaster player --name "Mookie Betts" --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
|
|
130
|
+
backcaster team --team LAN --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
|
|
131
|
+
backcaster batch --year 2022 --kind pitching --data-dir "C:\Baseball\lahman" --format csv --out pitchers.csv
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Use Lahman's `teamID` values, such as `LAN` for the Dodgers (rather than the
|
|
135
|
+
FanGraphs abbreviation `LAD`). A projection year needs the preceding three seasons:
|
|
136
|
+
2022 requires 2019–2021. Missing seasons produce an error explaining which year
|
|
137
|
+
is unavailable. Without `--data-dir`, the existing pybaseball source is used;
|
|
138
|
+
FanGraphs may reject its automated requests with HTTP 403.
|
|
139
|
+
|
|
140
|
+
Offline qualification uses a consistent modern rule across historical seasons:
|
|
141
|
+
at least **3.1 PA per game** for hitters and **one inning per game** for pitchers.
|
|
142
|
+
The season's game count is the maximum `G` in that year's `Teams.csv` records,
|
|
143
|
+
so shortened seasons have lower thresholds. The PA threshold is rounded to the
|
|
144
|
+
nearest integer (502 PA for 162 games, 186 PA for 60 games). This is an analytical filter, not a
|
|
145
|
+
reconstruction of historical award eligibility or batting-title exceptions.
|
|
146
|
+
Relievers generally do not qualify under this pitching rule.
|
|
147
|
+
|
|
148
|
+
Player stints are summed before qualification. Plate appearances are calculated
|
|
149
|
+
as `AB + BB + HBP + SF + SH`; missing/blank HBP, SF, and SH values count as zero.
|
|
150
|
+
Blank numeric counting-stat cells are also treated as zero; use a dataset with
|
|
151
|
+
complete records for the seasons you intend to project.
|
|
152
|
+
Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
|
|
153
|
+
batters faced. Lahman player IDs keep different players with the same name separate;
|
|
154
|
+
ambiguous name lookups report the matching IDs.
|
|
155
|
+
|
|
156
|
+
Only qualified player-seasons enter projections and league regression baselines.
|
|
157
|
+
Single-player projections require qualification in all three preceding seasons.
|
|
158
|
+
Team and batch projections use each player's available qualified seasons within
|
|
159
|
+
that window. For team selection, a traded player's full-season totals are assigned
|
|
160
|
+
to the team in their latest stint for that season; this is not a current-roster forecast.
|
|
161
|
+
|
|
96
162
|
### 2) Team projection
|
|
97
163
|
|
|
98
164
|
```bash
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/backcaster/__init__.py
|
|
5
5
|
src/backcaster/cli.py
|
|
6
6
|
src/backcaster/data.py
|
|
7
|
+
src/backcaster/lahman.py
|
|
7
8
|
src/backcaster/marcel.py
|
|
8
9
|
src/backcaster/normalize.py
|
|
9
10
|
src/backcaster/outputs.py
|
|
@@ -17,6 +18,7 @@ src/backcaster.egg-info/top_level.txt
|
|
|
17
18
|
tests/test_cli.py
|
|
18
19
|
tests/test_cli_player_lookup.py
|
|
19
20
|
tests/test_data.py
|
|
21
|
+
tests/test_lahman.py
|
|
20
22
|
tests/test_marcel_math.py
|
|
21
23
|
tests/test_normalize.py
|
|
22
24
|
tests/test_outputs.py
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from backcaster.cli import main
|
|
7
|
+
from backcaster.data import DataFetchError, PlayerLookupError
|
|
8
|
+
from backcaster.lahman import LahmanSource
|
|
9
|
+
from backcaster.marcel import ProjectionError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def lahman(tmp_path: Path) -> Path:
|
|
14
|
+
pd.DataFrame(
|
|
15
|
+
[
|
|
16
|
+
{"playerID": "star", "nameFirst": "Test", "nameLast": "Star"},
|
|
17
|
+
{"playerID": "other", "nameFirst": "Other", "nameLast": "Player"},
|
|
18
|
+
{"playerID": "minor", "nameFirst": "Brief", "nameLast": "Career"},
|
|
19
|
+
]
|
|
20
|
+
).to_csv(tmp_path / "People.csv", index=False)
|
|
21
|
+
batting, pitching, teams = [], [], []
|
|
22
|
+
for year, games in [(2019, 162), (2020, 60), (2021, 162)]:
|
|
23
|
+
teams.append({"yearID": year, "teamID": "BOS", "G": games})
|
|
24
|
+
for player, ab, bb in [("star", 500, 60), ("other", 450, 70), ("minor", 20, 0)]:
|
|
25
|
+
batting.append(
|
|
26
|
+
dict(
|
|
27
|
+
playerID=player,
|
|
28
|
+
yearID=year,
|
|
29
|
+
stint=1,
|
|
30
|
+
teamID="BOS",
|
|
31
|
+
AB=ab,
|
|
32
|
+
H=150,
|
|
33
|
+
**{"2B": 30, "3B": 2},
|
|
34
|
+
HR=20,
|
|
35
|
+
BB=bb,
|
|
36
|
+
SO=100,
|
|
37
|
+
HBP=5,
|
|
38
|
+
SF=5,
|
|
39
|
+
SH=1,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
pitching.append(
|
|
43
|
+
dict(
|
|
44
|
+
playerID=player,
|
|
45
|
+
yearID=year,
|
|
46
|
+
stint=1,
|
|
47
|
+
teamID="BOS",
|
|
48
|
+
IPouts=487 if player != "minor" else 10,
|
|
49
|
+
ER=60,
|
|
50
|
+
H=150,
|
|
51
|
+
HR=20,
|
|
52
|
+
BB=50,
|
|
53
|
+
SO=180,
|
|
54
|
+
BFP=700,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
pd.DataFrame(batting).to_csv(tmp_path / "Batting.csv", index=False)
|
|
58
|
+
pd.DataFrame(pitching).to_csv(tmp_path / "Pitching.csv", index=False)
|
|
59
|
+
pd.DataFrame(teams).to_csv(tmp_path / "Teams.csv", index=False)
|
|
60
|
+
return tmp_path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_season_qualification_and_components(lahman: Path) -> None:
|
|
64
|
+
source = LahmanSource(str(lahman))
|
|
65
|
+
batting = source.season(2021, "batting").set_index("PlayerID")
|
|
66
|
+
assert set(batting.index) == {"star", "other"}
|
|
67
|
+
assert batting.loc["star", "PA"] == 571
|
|
68
|
+
pitching = source.season(2021, "pitching").set_index("PlayerID")
|
|
69
|
+
assert pitching.loc["star", "IP"] == pytest.approx(162 + 1 / 3)
|
|
70
|
+
assert pitching.loc["star", "BF"] == 700
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_traded_player_combined_before_qualification(lahman: Path) -> None:
|
|
74
|
+
path = lahman / "Batting.csv"
|
|
75
|
+
frame = pd.read_csv(path)
|
|
76
|
+
row = frame[(frame.playerID == "star") & (frame.yearID == 2021)].copy()
|
|
77
|
+
frame = frame.drop(row.index)
|
|
78
|
+
for column in ["AB", "H", "2B", "3B", "HR", "BB", "SO", "HBP", "SF", "SH"]:
|
|
79
|
+
row[column] = row[column] / 2
|
|
80
|
+
second = row.assign(stint=2, teamID="LAN")
|
|
81
|
+
pd.concat([frame, row, second]).to_csv(path, index=False)
|
|
82
|
+
season = LahmanSource(str(lahman)).season(2021, "batting")
|
|
83
|
+
star = season[season.PlayerID == "star"]
|
|
84
|
+
assert len(star) == 1
|
|
85
|
+
assert star.iloc[0]["PA"] == 571
|
|
86
|
+
assert star.iloc[0]["Team"] == "LAN"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_short_season_threshold(lahman: Path) -> None:
|
|
90
|
+
path = lahman / "Batting.csv"
|
|
91
|
+
frame = pd.read_csv(path)
|
|
92
|
+
frame.loc[frame.playerID == "star", ["AB", "BB", "HBP", "SF", "SH"]] = [190, 0, 0, 0, 0]
|
|
93
|
+
frame.to_csv(path, index=False)
|
|
94
|
+
source = LahmanSource(str(lahman))
|
|
95
|
+
assert "star" in set(source.season(2020, "batting").PlayerID)
|
|
96
|
+
assert "star" not in set(source.season(2021, "batting").PlayerID)
|
|
97
|
+
with pytest.raises(ProjectionError, match="all three"):
|
|
98
|
+
source.project("player", 2022, "batting", name="Test Star")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize("command", ["player", "team", "batch"])
|
|
102
|
+
@pytest.mark.parametrize("kind", ["batting", "pitching"])
|
|
103
|
+
def test_offline_cli(
|
|
104
|
+
lahman: Path, monkeypatch: pytest.MonkeyPatch, command: str, kind: str
|
|
105
|
+
) -> None:
|
|
106
|
+
def forbidden(*args, **kwargs):
|
|
107
|
+
raise AssertionError("offline mode attempted network access")
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr("backcaster.cli.lookup_player_ids", forbidden)
|
|
110
|
+
monkeypatch.setattr("backcaster.cli.fetch_season_stats", forbidden)
|
|
111
|
+
out = lahman / "projection.csv"
|
|
112
|
+
args = [
|
|
113
|
+
command,
|
|
114
|
+
"--year",
|
|
115
|
+
"2022",
|
|
116
|
+
"--kind",
|
|
117
|
+
kind,
|
|
118
|
+
"--data-dir",
|
|
119
|
+
str(lahman),
|
|
120
|
+
"--format",
|
|
121
|
+
"csv",
|
|
122
|
+
"--out",
|
|
123
|
+
str(out),
|
|
124
|
+
]
|
|
125
|
+
if command == "player":
|
|
126
|
+
args += ["--name", "test star"]
|
|
127
|
+
if command == "team":
|
|
128
|
+
args += ["--team", "BOS"]
|
|
129
|
+
assert main(args) == 0
|
|
130
|
+
result = pd.read_csv(out)
|
|
131
|
+
assert len(result) == (1 if command == "player" else 2)
|
|
132
|
+
assert set(result.Year) == {2022}
|
|
133
|
+
assert result.Reliability.gt(0).all()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_missing_season_and_files(lahman: Path) -> None:
|
|
137
|
+
source = LahmanSource(str(lahman))
|
|
138
|
+
with pytest.raises(DataFetchError, match="no data for 2025"):
|
|
139
|
+
source.season(2025, "batting")
|
|
140
|
+
missing = LahmanSource(str(lahman / "missing"))
|
|
141
|
+
with pytest.raises(DataFetchError, match="Expected Batting.csv"):
|
|
142
|
+
missing.season(2021, "batting")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_no_match_and_team(lahman: Path) -> None:
|
|
146
|
+
source = LahmanSource(str(lahman))
|
|
147
|
+
with pytest.raises(PlayerLookupError, match="No qualified"):
|
|
148
|
+
source.project("player", 2022, "batting", name="Nobody Here")
|
|
149
|
+
with pytest.raises(ProjectionError, match="team ID"):
|
|
150
|
+
source.project("team", 2022, "batting", team="INVALID")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_duplicate_names_are_not_merged(lahman: Path) -> None:
|
|
154
|
+
path = lahman / "People.csv"
|
|
155
|
+
people = pd.read_csv(path)
|
|
156
|
+
people.loc[people.playerID == "other", ["nameFirst", "nameLast"]] = ["Test", "Star"]
|
|
157
|
+
people.to_csv(path, index=False)
|
|
158
|
+
source = LahmanSource(str(lahman))
|
|
159
|
+
assert len(source.project("batch", 2022, "batting")) == 2
|
|
160
|
+
with pytest.raises(PlayerLookupError, match="Ambiguous"):
|
|
161
|
+
source.project("player", 2022, "batting", name="Test Star")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.parametrize(
|
|
165
|
+
"case, message",
|
|
166
|
+
[
|
|
167
|
+
("columns", "missing columns"),
|
|
168
|
+
("numeric", "Invalid numeric"),
|
|
169
|
+
("games", "no valid game counts"),
|
|
170
|
+
("empty", "No qualified"),
|
|
171
|
+
("duplicate", "Duplicate player IDs"),
|
|
172
|
+
("people", "missing players"),
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
def test_invalid_data_reports_actionable_errors(lahman: Path, case: str, message: str) -> None:
|
|
176
|
+
if case in {"columns", "numeric", "empty"}:
|
|
177
|
+
path = lahman / "Batting.csv"
|
|
178
|
+
frame = pd.read_csv(path)
|
|
179
|
+
if case == "columns":
|
|
180
|
+
frame = frame.drop(columns="AB")
|
|
181
|
+
elif case == "numeric":
|
|
182
|
+
frame["AB"] = "invalid"
|
|
183
|
+
else:
|
|
184
|
+
frame[["AB", "BB", "HBP", "SF", "SH"]] = 0
|
|
185
|
+
elif case == "games":
|
|
186
|
+
path = lahman / "Teams.csv"
|
|
187
|
+
frame = pd.read_csv(path)
|
|
188
|
+
frame["G"] = 0
|
|
189
|
+
else:
|
|
190
|
+
path = lahman / "People.csv"
|
|
191
|
+
frame = pd.read_csv(path)
|
|
192
|
+
frame = pd.concat([frame, frame.iloc[[0]]]) if case == "duplicate" else frame.iloc[0:0]
|
|
193
|
+
frame.to_csv(path, index=False)
|
|
194
|
+
source = LahmanSource(str(lahman))
|
|
195
|
+
with pytest.raises(DataFetchError, match=message):
|
|
196
|
+
source.season(2021, "batting")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_unreadable_csv(lahman: Path) -> None:
|
|
200
|
+
(lahman / "Batting.csv").write_text("", encoding="utf-8")
|
|
201
|
+
source = LahmanSource(str(lahman))
|
|
202
|
+
with pytest.raises(DataFetchError, match="Unable to read"):
|
|
203
|
+
source.season(2021, "batting")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_optional_columns_and_case_insensitive_filenames(lahman: Path) -> None:
|
|
207
|
+
path = lahman / "Batting.csv"
|
|
208
|
+
pd.read_csv(path).drop(columns=["HBP", "SF", "SH"]).to_csv(lahman / "batting.csv", index=False)
|
|
209
|
+
path.unlink()
|
|
210
|
+
season = LahmanSource(str(lahman)).season(2021, "batting")
|
|
211
|
+
assert (season[["HBP", "SF", "SH"]] == 0).all().all()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_invalid_kind(lahman: Path) -> None:
|
|
215
|
+
source = LahmanSource(str(lahman))
|
|
216
|
+
with pytest.raises(DataFetchError, match="Invalid kind"):
|
|
217
|
+
source.season(2021, "fielding")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_cli_missing_directory_returns_error(
|
|
221
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
222
|
+
) -> None:
|
|
223
|
+
assert main(["batch", "--year", "2022", "--kind", "batting", "--data-dir", str(tmp_path)]) == 2
|
|
224
|
+
assert "Expected Batting.csv" in capsys.readouterr().err
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_qualification_boundary(lahman: Path) -> None:
|
|
228
|
+
path = lahman / "Batting.csv"
|
|
229
|
+
frame = pd.read_csv(path)
|
|
230
|
+
frame.loc[frame.playerID == "star", ["AB", "BB", "HBP", "SF", "SH"]] = [502, 0, 0, 0, 0]
|
|
231
|
+
frame.loc[frame.playerID == "other", ["AB", "BB", "HBP", "SF", "SH"]] = [501, 0, 0, 0, 0]
|
|
232
|
+
frame.to_csv(path, index=False)
|
|
233
|
+
assert set(LahmanSource(str(lahman)).season(2021, "batting").PlayerID) == {"star"}
|
|
234
|
+
path = lahman / "Pitching.csv"
|
|
235
|
+
frame = pd.read_csv(path)
|
|
236
|
+
frame.loc[frame.playerID == "star", "IPouts"] = 486
|
|
237
|
+
frame.loc[frame.playerID == "other", "IPouts"] = 485
|
|
238
|
+
frame.to_csv(path, index=False)
|
|
239
|
+
assert set(LahmanSource(str(lahman)).season(2021, "pitching").PlayerID) == {"star"}
|
|
@@ -186,7 +186,8 @@ def test_project_player_batting_with_missing_optional_columns() -> None:
|
|
|
186
186
|
prior = _batting_prior().drop(columns=["HBP", "SF"])
|
|
187
187
|
result = project_player("Test Hitter", prior, "batting", 2026)
|
|
188
188
|
assert float(result.loc[0, "OBP"]) > 0
|
|
189
|
-
assert "HBP" in result.columns
|
|
189
|
+
assert "HBP" in result.columns
|
|
190
|
+
assert "SF" in result.columns
|
|
190
191
|
|
|
191
192
|
|
|
192
193
|
def test_project_player_pitching() -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|