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.
Files changed (27) hide show
  1. {backcaster-0.3.0/src/backcaster.egg-info → backcaster-0.3.2}/PKG-INFO +67 -1
  2. {backcaster-0.3.0 → backcaster-0.3.2}/README.md +66 -0
  3. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/__init__.py +1 -1
  4. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/cli.py +15 -0
  5. backcaster-0.3.2/src/backcaster/lahman.py +143 -0
  6. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/marcel.py +4 -1
  7. {backcaster-0.3.0 → backcaster-0.3.2/src/backcaster.egg-info}/PKG-INFO +67 -1
  8. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/SOURCES.txt +2 -0
  9. backcaster-0.3.2/tests/test_lahman.py +239 -0
  10. {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_marcel_math.py +2 -1
  11. {backcaster-0.3.0 → backcaster-0.3.2}/LICENSE +0 -0
  12. {backcaster-0.3.0 → backcaster-0.3.2}/pyproject.toml +0 -0
  13. {backcaster-0.3.0 → backcaster-0.3.2}/setup.cfg +0 -0
  14. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/data.py +0 -0
  15. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/normalize.py +0 -0
  16. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/outputs.py +0 -0
  17. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster/schemas.py +0 -0
  18. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/dependency_links.txt +0 -0
  19. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/entry_points.txt +0 -0
  20. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/requires.txt +0 -0
  21. {backcaster-0.3.0 → backcaster-0.3.2}/src/backcaster.egg-info/top_level.txt +0 -0
  22. {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_cli.py +0 -0
  23. {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_cli_player_lookup.py +0 -0
  24. {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_data.py +0 -0
  25. {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_normalize.py +0 -0
  26. {backcaster-0.3.0 → backcaster-0.3.2}/tests/test_outputs.py +0 -0
  27. {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.0
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
@@ -1,4 +1,4 @@
1
1
  """backcaster package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.3.0"
4
+ __version__ = "0.3.2"
@@ -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
- group_column = team_df["IDfg"].fillna(team_df["Name"]) if "IDfg" in team_df.columns else "Name"
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.0
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 and "SF" 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