backcaster 0.3.0__tar.gz → 0.3.1__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.1}/PKG-INFO +43 -1
  2. {backcaster-0.3.0 → backcaster-0.3.1}/README.md +42 -0
  3. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster/__init__.py +1 -1
  4. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster/cli.py +15 -0
  5. backcaster-0.3.1/src/backcaster/lahman.py +143 -0
  6. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster/marcel.py +4 -1
  7. {backcaster-0.3.0 → backcaster-0.3.1/src/backcaster.egg-info}/PKG-INFO +43 -1
  8. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster.egg-info/SOURCES.txt +2 -0
  9. backcaster-0.3.1/tests/test_lahman.py +239 -0
  10. {backcaster-0.3.0 → backcaster-0.3.1}/tests/test_marcel_math.py +2 -1
  11. {backcaster-0.3.0 → backcaster-0.3.1}/LICENSE +0 -0
  12. {backcaster-0.3.0 → backcaster-0.3.1}/pyproject.toml +0 -0
  13. {backcaster-0.3.0 → backcaster-0.3.1}/setup.cfg +0 -0
  14. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster/data.py +0 -0
  15. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster/normalize.py +0 -0
  16. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster/outputs.py +0 -0
  17. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster/schemas.py +0 -0
  18. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster.egg-info/dependency_links.txt +0 -0
  19. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster.egg-info/entry_points.txt +0 -0
  20. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster.egg-info/requires.txt +0 -0
  21. {backcaster-0.3.0 → backcaster-0.3.1}/src/backcaster.egg-info/top_level.txt +0 -0
  22. {backcaster-0.3.0 → backcaster-0.3.1}/tests/test_cli.py +0 -0
  23. {backcaster-0.3.0 → backcaster-0.3.1}/tests/test_cli_player_lookup.py +0 -0
  24. {backcaster-0.3.0 → backcaster-0.3.1}/tests/test_data.py +0 -0
  25. {backcaster-0.3.0 → backcaster-0.3.1}/tests/test_normalize.py +0 -0
  26. {backcaster-0.3.0 → backcaster-0.3.1}/tests/test_outputs.py +0 -0
  27. {backcaster-0.3.0 → backcaster-0.3.1}/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.1
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
@@ -93,6 +93,48 @@ backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <battin
93
93
  backcaster player --name "Mookie Betts" --year 2026 --kind batting
94
94
  ```
95
95
 
96
+ ### Offline projections with Lahman CSVs
97
+
98
+ Extract your Lahman CSV distribution into a local directory. Pass `--data-dir`
99
+ to use it without any network requests, player-register downloads, or FanGraphs
100
+ cache reads. The directory must contain `People.csv`, `Teams.csv`, and
101
+ `Batting.csv` or `Pitching.csv` for the selected kind. Filenames are matched
102
+ without regard to case. The database is not bundled with Backcaster.
103
+
104
+ ```powershell
105
+ backcaster player --name "Mookie Betts" --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
106
+ backcaster team --team LAN --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
107
+ backcaster batch --year 2022 --kind pitching --data-dir "C:\Baseball\lahman" --format csv --out pitchers.csv
108
+ ```
109
+
110
+ Use Lahman's `teamID` values, such as `LAN` for the Dodgers (rather than the
111
+ FanGraphs abbreviation `LAD`). A projection year needs the preceding three seasons:
112
+ 2022 requires 2019–2021. Missing seasons produce an error explaining which year
113
+ is unavailable. Without `--data-dir`, the existing pybaseball source is used;
114
+ FanGraphs may reject its automated requests with HTTP 403.
115
+
116
+ Offline qualification uses a consistent modern rule across historical seasons:
117
+ at least **3.1 PA per game** for hitters and **one inning per game** for pitchers.
118
+ The season's game count is the maximum `G` in that year's `Teams.csv` records,
119
+ so shortened seasons have lower thresholds. The PA threshold is rounded to the
120
+ nearest integer (502 PA for 162 games, 186 PA for 60 games). This is an analytical filter, not a
121
+ reconstruction of historical award eligibility or batting-title exceptions.
122
+ Relievers generally do not qualify under this pitching rule.
123
+
124
+ Player stints are summed before qualification. Plate appearances are calculated
125
+ as `AB + BB + HBP + SF + SH`; missing/blank HBP, SF, and SH values count as zero.
126
+ Blank numeric counting-stat cells are also treated as zero; use a dataset with
127
+ complete records for the seasons you intend to project.
128
+ Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
129
+ batters faced. Lahman player IDs keep different players with the same name separate;
130
+ ambiguous name lookups report the matching IDs.
131
+
132
+ Only qualified player-seasons enter projections and league regression baselines.
133
+ Single-player projections require qualification in all three preceding seasons.
134
+ Team and batch projections use each player's available qualified seasons within
135
+ that window. For team selection, a traded player's full-season totals are assigned
136
+ to the team in their latest stint for that season; this is not a current-roster forecast.
137
+
96
138
  ### 2) Team projection
97
139
 
98
140
  ```bash
@@ -63,6 +63,48 @@ backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <battin
63
63
  backcaster player --name "Mookie Betts" --year 2026 --kind batting
64
64
  ```
65
65
 
66
+ ### Offline projections with Lahman CSVs
67
+
68
+ Extract your Lahman CSV distribution into a local directory. Pass `--data-dir`
69
+ to use it without any network requests, player-register downloads, or FanGraphs
70
+ cache reads. The directory must contain `People.csv`, `Teams.csv`, and
71
+ `Batting.csv` or `Pitching.csv` for the selected kind. Filenames are matched
72
+ without regard to case. The database is not bundled with Backcaster.
73
+
74
+ ```powershell
75
+ backcaster player --name "Mookie Betts" --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
76
+ backcaster team --team LAN --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
77
+ backcaster batch --year 2022 --kind pitching --data-dir "C:\Baseball\lahman" --format csv --out pitchers.csv
78
+ ```
79
+
80
+ Use Lahman's `teamID` values, such as `LAN` for the Dodgers (rather than the
81
+ FanGraphs abbreviation `LAD`). A projection year needs the preceding three seasons:
82
+ 2022 requires 2019–2021. Missing seasons produce an error explaining which year
83
+ is unavailable. Without `--data-dir`, the existing pybaseball source is used;
84
+ FanGraphs may reject its automated requests with HTTP 403.
85
+
86
+ Offline qualification uses a consistent modern rule across historical seasons:
87
+ at least **3.1 PA per game** for hitters and **one inning per game** for pitchers.
88
+ The season's game count is the maximum `G` in that year's `Teams.csv` records,
89
+ so shortened seasons have lower thresholds. The PA threshold is rounded to the
90
+ nearest integer (502 PA for 162 games, 186 PA for 60 games). This is an analytical filter, not a
91
+ reconstruction of historical award eligibility or batting-title exceptions.
92
+ Relievers generally do not qualify under this pitching rule.
93
+
94
+ Player stints are summed before qualification. Plate appearances are calculated
95
+ as `AB + BB + HBP + SF + SH`; missing/blank HBP, SF, and SH values count as zero.
96
+ Blank numeric counting-stat cells are also treated as zero; use a dataset with
97
+ complete records for the seasons you intend to project.
98
+ Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
99
+ batters faced. Lahman player IDs keep different players with the same name separate;
100
+ ambiguous name lookups report the matching IDs.
101
+
102
+ Only qualified player-seasons enter projections and league regression baselines.
103
+ Single-player projections require qualification in all three preceding seasons.
104
+ Team and batch projections use each player's available qualified seasons within
105
+ that window. For team selection, a traded player's full-season totals are assigned
106
+ to the team in their latest stint for that season; this is not a current-roster forecast.
107
+
66
108
  ### 2) Team projection
67
109
 
68
110
  ```bash
@@ -1,4 +1,4 @@
1
1
  """backcaster package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.3.0"
4
+ __version__ = "0.3.1"
@@ -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.1
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
@@ -93,6 +93,48 @@ backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <battin
93
93
  backcaster player --name "Mookie Betts" --year 2026 --kind batting
94
94
  ```
95
95
 
96
+ ### Offline projections with Lahman CSVs
97
+
98
+ Extract your Lahman CSV distribution into a local directory. Pass `--data-dir`
99
+ to use it without any network requests, player-register downloads, or FanGraphs
100
+ cache reads. The directory must contain `People.csv`, `Teams.csv`, and
101
+ `Batting.csv` or `Pitching.csv` for the selected kind. Filenames are matched
102
+ without regard to case. The database is not bundled with Backcaster.
103
+
104
+ ```powershell
105
+ backcaster player --name "Mookie Betts" --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
106
+ backcaster team --team LAN --year 2022 --kind batting --data-dir "C:\Baseball\lahman"
107
+ backcaster batch --year 2022 --kind pitching --data-dir "C:\Baseball\lahman" --format csv --out pitchers.csv
108
+ ```
109
+
110
+ Use Lahman's `teamID` values, such as `LAN` for the Dodgers (rather than the
111
+ FanGraphs abbreviation `LAD`). A projection year needs the preceding three seasons:
112
+ 2022 requires 2019–2021. Missing seasons produce an error explaining which year
113
+ is unavailable. Without `--data-dir`, the existing pybaseball source is used;
114
+ FanGraphs may reject its automated requests with HTTP 403.
115
+
116
+ Offline qualification uses a consistent modern rule across historical seasons:
117
+ at least **3.1 PA per game** for hitters and **one inning per game** for pitchers.
118
+ The season's game count is the maximum `G` in that year's `Teams.csv` records,
119
+ so shortened seasons have lower thresholds. The PA threshold is rounded to the
120
+ nearest integer (502 PA for 162 games, 186 PA for 60 games). This is an analytical filter, not a
121
+ reconstruction of historical award eligibility or batting-title exceptions.
122
+ Relievers generally do not qualify under this pitching rule.
123
+
124
+ Player stints are summed before qualification. Plate appearances are calculated
125
+ as `AB + BB + HBP + SF + SH`; missing/blank HBP, SF, and SH values count as zero.
126
+ Blank numeric counting-stat cells are also treated as zero; use a dataset with
127
+ complete records for the seasons you intend to project.
128
+ Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
129
+ batters faced. Lahman player IDs keep different players with the same name separate;
130
+ ambiguous name lookups report the matching IDs.
131
+
132
+ Only qualified player-seasons enter projections and league regression baselines.
133
+ Single-player projections require qualification in all three preceding seasons.
134
+ Team and batch projections use each player's available qualified seasons within
135
+ that window. For team selection, a traded player's full-season totals are assigned
136
+ to the team in their latest stint for that season; this is not a current-roster forecast.
137
+
96
138
  ### 2) Team projection
97
139
 
98
140
  ```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