backcaster 0.3.4__tar.gz → 0.3.6__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.4/src/backcaster.egg-info → backcaster-0.3.6}/PKG-INFO +52 -16
  2. {backcaster-0.3.4 → backcaster-0.3.6}/README.md +51 -15
  3. {backcaster-0.3.4 → backcaster-0.3.6}/THIRD_PARTY_NOTICES.md +7 -5
  4. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/__init__.py +1 -1
  5. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/cli.py +9 -2
  6. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/lahman.py +13 -10
  7. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/marcel.py +46 -15
  8. {backcaster-0.3.4 → backcaster-0.3.6/src/backcaster.egg-info}/PKG-INFO +52 -16
  9. {backcaster-0.3.4 → backcaster-0.3.6}/tests/test_lahman.py +83 -2
  10. {backcaster-0.3.4 → backcaster-0.3.6}/tests/test_marcel_math.py +42 -0
  11. {backcaster-0.3.4 → backcaster-0.3.6}/LICENSE +0 -0
  12. {backcaster-0.3.4 → backcaster-0.3.6}/pyproject.toml +0 -0
  13. {backcaster-0.3.4 → backcaster-0.3.6}/setup.cfg +0 -0
  14. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/batters.py +0 -0
  15. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/data.py +0 -0
  16. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/normalize.py +0 -0
  17. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/outputs.py +0 -0
  18. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster/schemas.py +0 -0
  19. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster.egg-info/SOURCES.txt +0 -0
  20. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster.egg-info/dependency_links.txt +0 -0
  21. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster.egg-info/entry_points.txt +0 -0
  22. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster.egg-info/requires.txt +0 -0
  23. {backcaster-0.3.4 → backcaster-0.3.6}/src/backcaster.egg-info/top_level.txt +0 -0
  24. {backcaster-0.3.4 → backcaster-0.3.6}/tests/test_batters.py +0 -0
  25. {backcaster-0.3.4 → backcaster-0.3.6}/tests/test_cli.py +0 -0
  26. {backcaster-0.3.4 → backcaster-0.3.6}/tests/test_normalize.py +0 -0
  27. {backcaster-0.3.4 → backcaster-0.3.6}/tests/test_outputs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backcaster
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Marcel-style MLB projection CLI using local Lahman CSV data
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
@@ -36,7 +36,7 @@ Backcaster is a Python CLI that produces **Marcel-style MLB projections** from t
36
36
  It supports:
37
37
  - **Single player projections** (`player`)
38
38
  - **Single team projections** (`team`)
39
- - **League-wide batch projections** (`batch`)
39
+ - **League-wide projections** (`hitters`, `pitchers`, or `batch`)
40
40
  - Output as terminal table, CSV, or HTML
41
41
 
42
42
  ---
@@ -56,10 +56,10 @@ Upgrade to the latest release available on PyPI:
56
56
  python -m pip install --upgrade backcaster
57
57
  ```
58
58
 
59
- To install a specific release, such as 0.3.4:
59
+ To install a specific release, such as 0.3.6:
60
60
 
61
61
  ```powershell
62
- python -m pip install --upgrade backcaster==0.3.4
62
+ python -m pip install --upgrade backcaster==0.3.6
63
63
  ```
64
64
 
65
65
  Run the command using the same Python environment where you run Backcaster.
@@ -97,19 +97,52 @@ pip install dist/backcaster-*.whl
97
97
 
98
98
  ## Quickstart CLI usage
99
99
 
100
- All commands require:
101
- - `--year` target projection year
102
- - `--kind` one of `batting` or `pitching`
100
+ All commands require `--year`, the target projection year.
101
+ The `player`, `team`, and `batch` commands also require `--kind batting|pitching`.
102
+ The `hitters` and `pitchers` commands choose their kind automatically.
103
103
 
104
104
  Subcommand-specific required options:
105
105
  - `player` requires `--name`
106
106
  - `team` requires `--team`
107
- - `batch` requires no additional required option
107
+ - `batch`, `hitters`, and `pitchers` require no player or team option
108
108
 
109
109
  ```bash
110
110
  backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <batting|pitching> [--format cli|csv|html] [--out PATH]
111
+ backcaster <hitters|pitchers> --year <YEAR> [--data-dir PATH] [--format cli|csv|html] [--out PATH]
111
112
  ```
112
113
 
114
+ ### Pre-offseason league projections
115
+
116
+ Once the latest regular season is present in your local Lahman files, generate
117
+ both populations without roster updates or team filters. For terminal output:
118
+
119
+ ```powershell
120
+ backcaster hitters --year 2026
121
+ backcaster pitchers --year 2026
122
+ ```
123
+
124
+ To save files for comparison with Baseball Reference:
125
+
126
+ ```powershell
127
+ backcaster hitters --year 2026 --format csv --out hitters_2026.csv
128
+ backcaster pitchers --year 2026 --format csv --out pitchers_2026.csv
129
+ ```
130
+
131
+ `--year 2026` projects 2026 from 2023–2025; it never reads 2026 statistics.
132
+ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
133
+ They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
134
+ For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
135
+ For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
136
+ File formats require `--out`; the CLI format prints to the terminal.
137
+
138
+ Players qualify for selection by meeting the existing PA/IP threshold in at least
139
+ one of those three seasons. This is a historical eligibility list, not an active
140
+ roster; pitchers who never reach the qualifying innings threshold (including most
141
+ relievers) remain excluded. Qualification selects players without removing their
142
+ other seasons from the calculation. Trades are combined into one player-season
143
+ before qualification, and all teams' statistics contribute once. The output is
144
+ one row per selected player, ready for comparison with reference projections.
145
+
113
146
  ### 1) Player projection
114
147
 
115
148
  ```bash
@@ -123,8 +156,8 @@ All projections use local Lahman CSVs. On Windows, the default directory is
123
156
  Set the `LAHMAN_DATA_DIR` environment variable to change the default, or pass
124
157
  `--data-dir` to override it for one command. No network requests or online
125
158
  fallbacks are made. The directory must contain `People.csv`, `Teams.csv`, and
126
- `Batting.csv` or `Pitching.csv` for the selected kind. Batter projections also
127
- require `Appearances.csv` and a valid `birthYear` in `People.csv`. Filenames are matched
159
+ `Batting.csv` or `Pitching.csv` for the selected kind. Both kinds require valid
160
+ `birthYear` values in `People.csv`; batters additionally require `Appearances.csv`. Filenames are matched
128
161
  without regard to case. The database is not bundled with Backcaster.
129
162
 
130
163
  For the default Windows directory, no additional flag is needed:
@@ -166,11 +199,12 @@ ambiguous name lookups report the matching IDs.
166
199
 
167
200
  Batter league regression and rebaselining use all non-pitcher player-seasons;
168
201
  qualification selects the displayed players. Pitching retains its qualified-only
169
- baseline. See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
202
+ baseline, with real-age performance adjustments documented in
203
+ [the pitcher model](docs/PITCHER_MODEL.md). See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
170
204
  real-age adjustment, PA formula, and explicit rebaselining choices.
171
- Single-player projections require qualification in all three preceding seasons.
172
- Team and batch projections use each player's available qualified seasons within
173
- that window. For team selection, a traded player's full-season totals are assigned
205
+ Eligibility means qualifying in at least one of the three preceding seasons.
206
+ For every selected player, projections use all available seasons in that window,
207
+ including nonqualified seasons and seasons spent with other teams. For team selection, a traded player's full-season totals are assigned
174
208
  to the team in their latest stint for that season; this is not a current-roster forecast.
175
209
 
176
210
  ### 2) Team projection
@@ -179,7 +213,7 @@ to the team in their latest stint for that season; this is not a current-roster
179
213
  backcaster team --team LAN --year 2026 --kind pitching
180
214
  ```
181
215
 
182
- ### 3) Batch projection (all players returned by source data)
216
+ ### 3) Batch projection (all eligible players)
183
217
 
184
218
  ```bash
185
219
  backcaster batch --year 2026 --kind batting
@@ -208,7 +242,8 @@ Example shape (columns vary by batting vs pitching):
208
242
  ```
209
243
 
210
244
  ### CSV output (`--format csv`)
211
- A single-row CSV for `player`; multi-row CSV for `team` and `batch`.
245
+ A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
246
+ Both populations include `PlayerID` for matching records across files.
212
247
 
213
248
  ### HTML output (`--format html`)
214
249
  A basic HTML table (no custom CSS) suitable for quick sharing or embedding.
@@ -227,6 +262,7 @@ Lahman distribution from the same edition. Existing online caches are ignored.
227
262
 
228
263
  Runtime dependencies are intentionally minimal:
229
264
  - `pandas` for tabular data processing and projection output
265
+ - `numpy` for numerical operations and rebaselining convergence checks
230
266
 
231
267
  Development dependencies (`--extra dev`) include:
232
268
  - `pytest`, `pytest-cov`
@@ -5,7 +5,7 @@ Backcaster is a Python CLI that produces **Marcel-style MLB projections** from t
5
5
  It supports:
6
6
  - **Single player projections** (`player`)
7
7
  - **Single team projections** (`team`)
8
- - **League-wide batch projections** (`batch`)
8
+ - **League-wide projections** (`hitters`, `pitchers`, or `batch`)
9
9
  - Output as terminal table, CSV, or HTML
10
10
 
11
11
  ---
@@ -25,10 +25,10 @@ Upgrade to the latest release available on PyPI:
25
25
  python -m pip install --upgrade backcaster
26
26
  ```
27
27
 
28
- To install a specific release, such as 0.3.4:
28
+ To install a specific release, such as 0.3.6:
29
29
 
30
30
  ```powershell
31
- python -m pip install --upgrade backcaster==0.3.4
31
+ python -m pip install --upgrade backcaster==0.3.6
32
32
  ```
33
33
 
34
34
  Run the command using the same Python environment where you run Backcaster.
@@ -66,19 +66,52 @@ pip install dist/backcaster-*.whl
66
66
 
67
67
  ## Quickstart CLI usage
68
68
 
69
- All commands require:
70
- - `--year` target projection year
71
- - `--kind` one of `batting` or `pitching`
69
+ All commands require `--year`, the target projection year.
70
+ The `player`, `team`, and `batch` commands also require `--kind batting|pitching`.
71
+ The `hitters` and `pitchers` commands choose their kind automatically.
72
72
 
73
73
  Subcommand-specific required options:
74
74
  - `player` requires `--name`
75
75
  - `team` requires `--team`
76
- - `batch` requires no additional required option
76
+ - `batch`, `hitters`, and `pitchers` require no player or team option
77
77
 
78
78
  ```bash
79
79
  backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <batting|pitching> [--format cli|csv|html] [--out PATH]
80
+ backcaster <hitters|pitchers> --year <YEAR> [--data-dir PATH] [--format cli|csv|html] [--out PATH]
80
81
  ```
81
82
 
83
+ ### Pre-offseason league projections
84
+
85
+ Once the latest regular season is present in your local Lahman files, generate
86
+ both populations without roster updates or team filters. For terminal output:
87
+
88
+ ```powershell
89
+ backcaster hitters --year 2026
90
+ backcaster pitchers --year 2026
91
+ ```
92
+
93
+ To save files for comparison with Baseball Reference:
94
+
95
+ ```powershell
96
+ backcaster hitters --year 2026 --format csv --out hitters_2026.csv
97
+ backcaster pitchers --year 2026 --format csv --out pitchers_2026.csv
98
+ ```
99
+
100
+ `--year 2026` projects 2026 from 2023–2025; it never reads 2026 statistics.
101
+ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
102
+ They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
103
+ For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
104
+ For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
105
+ File formats require `--out`; the CLI format prints to the terminal.
106
+
107
+ Players qualify for selection by meeting the existing PA/IP threshold in at least
108
+ one of those three seasons. This is a historical eligibility list, not an active
109
+ roster; pitchers who never reach the qualifying innings threshold (including most
110
+ relievers) remain excluded. Qualification selects players without removing their
111
+ other seasons from the calculation. Trades are combined into one player-season
112
+ before qualification, and all teams' statistics contribute once. The output is
113
+ one row per selected player, ready for comparison with reference projections.
114
+
82
115
  ### 1) Player projection
83
116
 
84
117
  ```bash
@@ -92,8 +125,8 @@ All projections use local Lahman CSVs. On Windows, the default directory is
92
125
  Set the `LAHMAN_DATA_DIR` environment variable to change the default, or pass
93
126
  `--data-dir` to override it for one command. No network requests or online
94
127
  fallbacks are made. The directory must contain `People.csv`, `Teams.csv`, and
95
- `Batting.csv` or `Pitching.csv` for the selected kind. Batter projections also
96
- require `Appearances.csv` and a valid `birthYear` in `People.csv`. Filenames are matched
128
+ `Batting.csv` or `Pitching.csv` for the selected kind. Both kinds require valid
129
+ `birthYear` values in `People.csv`; batters additionally require `Appearances.csv`. Filenames are matched
97
130
  without regard to case. The database is not bundled with Backcaster.
98
131
 
99
132
  For the default Windows directory, no additional flag is needed:
@@ -135,11 +168,12 @@ ambiguous name lookups report the matching IDs.
135
168
 
136
169
  Batter league regression and rebaselining use all non-pitcher player-seasons;
137
170
  qualification selects the displayed players. Pitching retains its qualified-only
138
- baseline. See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
171
+ baseline, with real-age performance adjustments documented in
172
+ [the pitcher model](docs/PITCHER_MODEL.md). See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
139
173
  real-age adjustment, PA formula, and explicit rebaselining choices.
140
- Single-player projections require qualification in all three preceding seasons.
141
- Team and batch projections use each player's available qualified seasons within
142
- that window. For team selection, a traded player's full-season totals are assigned
174
+ Eligibility means qualifying in at least one of the three preceding seasons.
175
+ For every selected player, projections use all available seasons in that window,
176
+ including nonqualified seasons and seasons spent with other teams. For team selection, a traded player's full-season totals are assigned
143
177
  to the team in their latest stint for that season; this is not a current-roster forecast.
144
178
 
145
179
  ### 2) Team projection
@@ -148,7 +182,7 @@ to the team in their latest stint for that season; this is not a current-roster
148
182
  backcaster team --team LAN --year 2026 --kind pitching
149
183
  ```
150
184
 
151
- ### 3) Batch projection (all players returned by source data)
185
+ ### 3) Batch projection (all eligible players)
152
186
 
153
187
  ```bash
154
188
  backcaster batch --year 2026 --kind batting
@@ -177,7 +211,8 @@ Example shape (columns vary by batting vs pitching):
177
211
  ```
178
212
 
179
213
  ### CSV output (`--format csv`)
180
- A single-row CSV for `player`; multi-row CSV for `team` and `batch`.
214
+ A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
215
+ Both populations include `PlayerID` for matching records across files.
181
216
 
182
217
  ### HTML output (`--format html`)
183
218
  A basic HTML table (no custom CSS) suitable for quick sharing or embedding.
@@ -196,6 +231,7 @@ Lahman distribution from the same edition. Existing online caches are ignored.
196
231
 
197
232
  Runtime dependencies are intentionally minimal:
198
233
  - `pandas` for tabular data processing and projection output
234
+ - `numpy` for numerical operations and rebaselining convergence checks
199
235
 
200
236
  Development dependencies (`--extra dev`) include:
201
237
  - `pytest`, `pytest-cov`
@@ -12,11 +12,13 @@ Reference revision: `13a1c7943f5a7317c9c812a28e299c2184060046` (March 1, 2020).
12
12
  See [reference and adaptation notes](docs/MARCELR_REFERENCE.md) for the files
13
13
  reviewed, formulas examined, current differences, and attribution practices.
14
14
 
15
- At the time this notice was added, marcelR source had been inspected, but no R
16
- source had been copied or translated into Backcaster, and its datasets had not
17
- been bundled. Backcaster does not depend on R or marcelR. The notice is retained
18
- for acknowledgment and to accompany future attributed adaptations. Neither the
19
- review nor the acknowledgment implies endorsement or verified numerical parity.
15
+ Backcaster's pitcher-aging equations in `src/backcaster/marcel.py` are adapted
16
+ from the age-adjustment formulas in marcelR's `R/utilities.R` and their stat-specific
17
+ application in `inst/scripts/marcel_data_exporter.R` at the revision above.
18
+ The implementation is Python, with local birth-year validation and unchanged
19
+ pitching opportunities; it is not a complete port of marcelR. No marcelR datasets
20
+ are bundled. Backcaster does not depend on R or marcelR. Attribution does not
21
+ imply endorsement or verified numerical parity.
20
22
 
21
23
  The following is marcelR's MIT license notice, reproduced without changes:
22
24
 
@@ -1,4 +1,4 @@
1
1
  """backcaster package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.3.4"
4
+ __version__ = "0.3.6"
@@ -40,9 +40,12 @@ def build_parser() -> argparse.ArgumentParser:
40
40
  p = argparse.ArgumentParser(prog="backcaster")
41
41
  sub = p.add_subparsers(dest="command", required=True)
42
42
 
43
- def add_common(sp: argparse.ArgumentParser) -> None:
43
+ def add_common(sp: argparse.ArgumentParser, kind: str | None = None) -> None:
44
44
  sp.add_argument("--year", type=int, required=True)
45
- sp.add_argument("--kind", choices=["batting", "pitching"], required=True)
45
+ if kind is None:
46
+ sp.add_argument("--kind", choices=["batting", "pitching"], required=True)
47
+ else:
48
+ sp.set_defaults(kind=kind)
46
49
  sp.add_argument("--format", choices=_OUTPUT_FORMAT_CHOICES, default="cli")
47
50
  sp.add_argument("--out")
48
51
  sp.add_argument(
@@ -61,6 +64,10 @@ def build_parser() -> argparse.ArgumentParser:
61
64
 
62
65
  bp = sub.add_parser("batch")
63
66
  add_common(bp)
67
+ for command, kind in [("hitters", "batting"), ("pitchers", "pitching")]:
68
+ league = sub.add_parser(command, help=f"Project all eligible {command} across all teams")
69
+ add_common(league, kind)
70
+ league.set_defaults(command="batch")
64
71
  return p
65
72
 
66
73
 
@@ -124,28 +124,33 @@ class LahmanSource:
124
124
  if kind == "batting":
125
125
  seasons, batting = self._batting_population(year)
126
126
  else:
127
+ self.read("People", {"playerID", "nameFirst", "nameLast", "birthYear"})
127
128
  seasons = pd.concat(
128
- [self.season(y, kind) for y in (year - 1, year - 2, year - 3)], ignore_index=True
129
+ [self.season(y, kind, qualified=False) for y in (year - 1, year - 2, year - 3)],
130
+ ignore_index=True,
129
131
  )
132
+ eligible = seasons[seasons["Qualified"]]
130
133
  if command == "player":
131
- matches = self._player_seasons(seasons, name)
134
+ candidates = self._player_seasons(eligible, name)
135
+ matches = seasons[seasons["PlayerID"] == candidates["PlayerID"].iloc[0]]
132
136
  if kind == "batting":
133
137
  return batting[batting["PlayerID"] == matches["PlayerID"].iloc[0]].reset_index(
134
138
  drop=True
135
139
  )
136
140
  return project_player(
137
- str(matches["Name"].iloc[0]), matches, kind, year, league_df=seasons
138
- )
139
- selected = seasons
141
+ str(matches["Name"].iloc[0]), matches, kind, year, league_df=eligible
142
+ ).assign(PlayerID=matches["PlayerID"].iloc[0])
143
+ selected = eligible
140
144
  if command == "team":
141
- selected = seasons[seasons["Team"].str.casefold() == (team or "").casefold()]
145
+ selected = eligible[eligible["Team"].str.casefold() == (team or "").casefold()]
142
146
  if selected.empty:
143
147
  raise ProjectionError(
144
148
  f"No qualified players for Lahman team ID '{team}' in the prior three seasons."
145
149
  )
146
150
  if kind == "batting":
147
151
  return batting[batting["PlayerID"].isin(selected["PlayerID"])].reset_index(drop=True)
148
- return project_team(selected, kind, year, baseline_df=seasons)
152
+ history = seasons[seasons["PlayerID"].isin(selected["PlayerID"])]
153
+ return project_team(history, kind, year, baseline_df=eligible)
149
154
 
150
155
  def _batting_population(self, year: int) -> tuple[pd.DataFrame, pd.DataFrame]:
151
156
  self.read("People", {"playerID", "nameFirst", "nameLast", "birthYear"})
@@ -178,7 +183,7 @@ class LahmanSource:
178
183
  qualified = seasons[seasons["Qualified"]]
179
184
  if qualified.empty:
180
185
  raise ProjectionError("No qualified non-pitcher batters in the prior three seasons.")
181
- return qualified, projected
186
+ return seasons, projected
182
187
 
183
188
  def _player_seasons(self, seasons: pd.DataFrame, name: str | None) -> pd.DataFrame:
184
189
  normalized = " ".join((name or "").casefold().split())
@@ -194,6 +199,4 @@ class LahmanSource:
194
199
  raise PlayerLookupError(
195
200
  f"Ambiguous Lahman name '{name}'; matching player IDs: {', '.join(ids)}."
196
201
  )
197
- if matches["Season"].nunique() != 3:
198
- raise ProjectionError(f"Player '{name}' must qualify in all three prior seasons.")
199
202
  return matches
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
4
+
3
5
  import pandas as pd
4
6
 
5
7
  from backcaster.normalize import safe_divide
@@ -14,9 +16,27 @@ BATTING_COMPONENTS = ["PA", "AB", "H", "2B", "3B", "HR", "BB", "SO", "HBP", "SF"
14
16
  PITCHING_COMPONENTS = ["IP", "ER", "H", "HR", "BB", "SO", "BF"]
15
17
 
16
18
 
17
- def _weighted_row(
18
- rows: list[pd.Series], weights: tuple[float, float, float], cols: list[str]
19
- ) -> pd.Series:
19
+ def _pitcher_age(prior: pd.DataFrame, year: int, fallback: float) -> float:
20
+ age = fallback
21
+ if "birthYear" in prior:
22
+ age = year - float(pd.to_numeric(prior["birthYear"].iloc[0], errors="coerce"))
23
+ if not math.isfinite(age) or not 15 <= age <= 70:
24
+ raise ProjectionError("A valid birthYear or explicit age is required for pitcher aging.")
25
+ return age
26
+
27
+
28
+ def _age_pitching(counts: pd.Series, age: float) -> pd.Series:
29
+ # Equations adapted from Ben Dilday's marcelR, utilities.R and
30
+ # inst/scripts/marcel_data_exporter.R, revision 13a1c7943f5a7317c9c812a28e299c2184060046.
31
+ # Copyright (c) 2017 Ben Dilday, MIT; see THIRD_PARTY_NOTICES.md.
32
+ benefit = 1 / (1 + 0.003 * (age - 29)) if age > 29 else 1 + 0.006 * (29 - age)
33
+ result = counts.copy()
34
+ result[["ER", "H", "HR", "BB"]] /= benefit
35
+ result["SO"] *= benefit
36
+ return result
37
+
38
+
39
+ def _weighted_row(rows: list[pd.Series], weights: tuple[float, ...], cols: list[str]) -> pd.Series:
20
40
  total_w = sum(weights[: len(rows)])
21
41
  out = {}
22
42
  for col in cols:
@@ -124,14 +144,20 @@ def project_player(
124
144
  prior_df = _prepare_prior_df(prior_three, comps, optional_comps=optional_comps)
125
145
 
126
146
  rows = [prior_df.iloc[i] for i in range(min(3, prior_df.shape[0]))]
127
- weighted = _weighted_row(rows, config.season_weights, comps)
147
+ weights: tuple[float, ...] = config.season_weights[: len(rows)]
148
+ if kind == "pitching":
149
+ calendar_weights: dict[float, float] = dict(
150
+ zip((year - 1, year - 2, year - 3), config.season_weights)
151
+ )
152
+ weights = tuple(calendar_weights.get(float(row["Season"]), 0.0) for row in rows)
153
+ weighted = _weighted_row(rows, weights, comps)
128
154
 
129
155
  pt_col = comps[0]
130
156
  weighted_pt = float(weighted[pt_col])
131
157
  # Regression uses weighted evidence, not the per-season average used for
132
158
  # playing time. Averaging first would multiply regression strength by the
133
159
  # sum of the season weights (12 for three seasons with weights 5/4/3).
134
- evidence = weighted * sum(config.season_weights[: len(rows)])
160
+ evidence = weighted * sum(weights)
135
161
  evidence_pt = float(evidence[pt_col])
136
162
  reliability = safe_divide(evidence_pt, evidence_pt + reg_pt)
137
163
 
@@ -148,7 +174,7 @@ def project_player(
148
174
  regressed[c] = regressed_rate * weighted_pt
149
175
 
150
176
  growth = config.default_pa_growth if kind == "batting" else config.default_ip_growth
151
- age_adj = config.age_adjustment_fn(age)
177
+ age_adj = config.age_adjustment_fn(age) if kind == "batting" else 1.0
152
178
  projected_pt = weighted_pt * growth * age_adj
153
179
  pt_scale = safe_divide(projected_pt, weighted_pt)
154
180
  regressed *= pt_scale
@@ -156,12 +182,16 @@ def project_player(
156
182
  if kind == "batting":
157
183
  rates = _derive_batting_rates(regressed)
158
184
  else:
185
+ age = _pitcher_age(prior_df, year, age)
186
+ regressed = _age_pitching(regressed, age)
159
187
  rates = _derive_pitching_rates(regressed)
160
188
 
161
189
  output = pd.concat([regressed, rates])
162
190
  output["Reliability"] = reliability
163
191
  output["Name"] = player_name
164
192
  output["Year"] = year
193
+ if kind == "pitching":
194
+ output["Age"] = age
165
195
  return output.to_frame().T
166
196
 
167
197
 
@@ -195,16 +225,17 @@ def project_team(
195
225
  display_name = sorted_grp["Name"].iloc[0]
196
226
  if not isinstance(display_name, str):
197
227
  raise ProjectionError("Encountered non-string player name in team input.")
198
- projections.append(
199
- project_player(
200
- display_name,
201
- sorted_grp.head(3),
202
- kind,
203
- year,
204
- config,
205
- league_rates=baseline_rates,
206
- )
228
+ projection = project_player(
229
+ display_name,
230
+ sorted_grp.head(3),
231
+ kind,
232
+ year,
233
+ config,
234
+ league_rates=baseline_rates,
207
235
  )
236
+ if "PlayerID" in sorted_grp:
237
+ projection["PlayerID"] = sorted_grp["PlayerID"].iloc[0]
238
+ projections.append(projection)
208
239
  if not projections:
209
240
  raise ProjectionError("No players available to project for this team.")
210
241
  return pd.concat(projections, ignore_index=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backcaster
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Marcel-style MLB projection CLI using local Lahman CSV data
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
@@ -36,7 +36,7 @@ Backcaster is a Python CLI that produces **Marcel-style MLB projections** from t
36
36
  It supports:
37
37
  - **Single player projections** (`player`)
38
38
  - **Single team projections** (`team`)
39
- - **League-wide batch projections** (`batch`)
39
+ - **League-wide projections** (`hitters`, `pitchers`, or `batch`)
40
40
  - Output as terminal table, CSV, or HTML
41
41
 
42
42
  ---
@@ -56,10 +56,10 @@ Upgrade to the latest release available on PyPI:
56
56
  python -m pip install --upgrade backcaster
57
57
  ```
58
58
 
59
- To install a specific release, such as 0.3.4:
59
+ To install a specific release, such as 0.3.6:
60
60
 
61
61
  ```powershell
62
- python -m pip install --upgrade backcaster==0.3.4
62
+ python -m pip install --upgrade backcaster==0.3.6
63
63
  ```
64
64
 
65
65
  Run the command using the same Python environment where you run Backcaster.
@@ -97,19 +97,52 @@ pip install dist/backcaster-*.whl
97
97
 
98
98
  ## Quickstart CLI usage
99
99
 
100
- All commands require:
101
- - `--year` target projection year
102
- - `--kind` one of `batting` or `pitching`
100
+ All commands require `--year`, the target projection year.
101
+ The `player`, `team`, and `batch` commands also require `--kind batting|pitching`.
102
+ The `hitters` and `pitchers` commands choose their kind automatically.
103
103
 
104
104
  Subcommand-specific required options:
105
105
  - `player` requires `--name`
106
106
  - `team` requires `--team`
107
- - `batch` requires no additional required option
107
+ - `batch`, `hitters`, and `pitchers` require no player or team option
108
108
 
109
109
  ```bash
110
110
  backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <batting|pitching> [--format cli|csv|html] [--out PATH]
111
+ backcaster <hitters|pitchers> --year <YEAR> [--data-dir PATH] [--format cli|csv|html] [--out PATH]
111
112
  ```
112
113
 
114
+ ### Pre-offseason league projections
115
+
116
+ Once the latest regular season is present in your local Lahman files, generate
117
+ both populations without roster updates or team filters. For terminal output:
118
+
119
+ ```powershell
120
+ backcaster hitters --year 2026
121
+ backcaster pitchers --year 2026
122
+ ```
123
+
124
+ To save files for comparison with Baseball Reference:
125
+
126
+ ```powershell
127
+ backcaster hitters --year 2026 --format csv --out hitters_2026.csv
128
+ backcaster pitchers --year 2026 --format csv --out pitchers_2026.csv
129
+ ```
130
+
131
+ `--year 2026` projects 2026 from 2023–2025; it never reads 2026 statistics.
132
+ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
133
+ They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
134
+ For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
135
+ For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
136
+ File formats require `--out`; the CLI format prints to the terminal.
137
+
138
+ Players qualify for selection by meeting the existing PA/IP threshold in at least
139
+ one of those three seasons. This is a historical eligibility list, not an active
140
+ roster; pitchers who never reach the qualifying innings threshold (including most
141
+ relievers) remain excluded. Qualification selects players without removing their
142
+ other seasons from the calculation. Trades are combined into one player-season
143
+ before qualification, and all teams' statistics contribute once. The output is
144
+ one row per selected player, ready for comparison with reference projections.
145
+
113
146
  ### 1) Player projection
114
147
 
115
148
  ```bash
@@ -123,8 +156,8 @@ All projections use local Lahman CSVs. On Windows, the default directory is
123
156
  Set the `LAHMAN_DATA_DIR` environment variable to change the default, or pass
124
157
  `--data-dir` to override it for one command. No network requests or online
125
158
  fallbacks are made. The directory must contain `People.csv`, `Teams.csv`, and
126
- `Batting.csv` or `Pitching.csv` for the selected kind. Batter projections also
127
- require `Appearances.csv` and a valid `birthYear` in `People.csv`. Filenames are matched
159
+ `Batting.csv` or `Pitching.csv` for the selected kind. Both kinds require valid
160
+ `birthYear` values in `People.csv`; batters additionally require `Appearances.csv`. Filenames are matched
128
161
  without regard to case. The database is not bundled with Backcaster.
129
162
 
130
163
  For the default Windows directory, no additional flag is needed:
@@ -166,11 +199,12 @@ ambiguous name lookups report the matching IDs.
166
199
 
167
200
  Batter league regression and rebaselining use all non-pitcher player-seasons;
168
201
  qualification selects the displayed players. Pitching retains its qualified-only
169
- baseline. See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
202
+ baseline, with real-age performance adjustments documented in
203
+ [the pitcher model](docs/PITCHER_MODEL.md). See [the batter model](docs/BATTER_MODEL.md) for the six-stage calculation,
170
204
  real-age adjustment, PA formula, and explicit rebaselining choices.
171
- Single-player projections require qualification in all three preceding seasons.
172
- Team and batch projections use each player's available qualified seasons within
173
- that window. For team selection, a traded player's full-season totals are assigned
205
+ Eligibility means qualifying in at least one of the three preceding seasons.
206
+ For every selected player, projections use all available seasons in that window,
207
+ including nonqualified seasons and seasons spent with other teams. For team selection, a traded player's full-season totals are assigned
174
208
  to the team in their latest stint for that season; this is not a current-roster forecast.
175
209
 
176
210
  ### 2) Team projection
@@ -179,7 +213,7 @@ to the team in their latest stint for that season; this is not a current-roster
179
213
  backcaster team --team LAN --year 2026 --kind pitching
180
214
  ```
181
215
 
182
- ### 3) Batch projection (all players returned by source data)
216
+ ### 3) Batch projection (all eligible players)
183
217
 
184
218
  ```bash
185
219
  backcaster batch --year 2026 --kind batting
@@ -208,7 +242,8 @@ Example shape (columns vary by batting vs pitching):
208
242
  ```
209
243
 
210
244
  ### CSV output (`--format csv`)
211
- A single-row CSV for `player`; multi-row CSV for `team` and `batch`.
245
+ A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
246
+ Both populations include `PlayerID` for matching records across files.
212
247
 
213
248
  ### HTML output (`--format html`)
214
249
  A basic HTML table (no custom CSS) suitable for quick sharing or embedding.
@@ -227,6 +262,7 @@ Lahman distribution from the same edition. Existing online caches are ignored.
227
262
 
228
263
  Runtime dependencies are intentionally minimal:
229
264
  - `pandas` for tabular data processing and projection output
265
+ - `numpy` for numerical operations and rebaselining convergence checks
230
266
 
231
267
  Development dependencies (`--extra dev`) include:
232
268
  - `pytest`, `pytest-cov`
@@ -102,8 +102,8 @@ def test_short_season_threshold(lahman: Path) -> None:
102
102
  source = LahmanSource(str(lahman))
103
103
  assert "star" in set(source.season(2020, "batting").PlayerID)
104
104
  assert "star" not in set(source.season(2021, "batting").PlayerID)
105
- with pytest.raises(ProjectionError, match="all three"):
106
- source.project("player", 2022, "batting", name="Test Star")
105
+ result = source.project("player", 2022, "batting", name="Test Star")
106
+ assert result.loc[0, "PA"] == pytest.approx(0.5 * 190 + 0.1 * 190 + 200)
107
107
 
108
108
 
109
109
  @pytest.mark.parametrize("command", ["player", "team", "batch"])
@@ -337,3 +337,84 @@ def test_missing_birth_year_column(lahman: Path) -> None:
337
337
  source = LahmanSource(str(lahman))
338
338
  with pytest.raises(DataFetchError, match="birthYear"):
339
339
  source.project("batch", 2022, "batting")
340
+
341
+
342
+ def test_pitcher_aging_consistent_across_commands(lahman: Path) -> None:
343
+ source = LahmanSource(str(lahman))
344
+ player = source.project("player", 2022, "pitching", name="Test Star").iloc[0]
345
+ team = source.project("team", 2022, "pitching", team="BOS")
346
+ batch = source.project("batch", 2022, "pitching")
347
+ assert player["Age"] == 32
348
+ assert player["IP"] == pytest.approx(487 / 3)
349
+ for result in [team, batch]:
350
+ row = result[result.Name == "Test Star"].iloc[0]
351
+ for stat in ["IP", "SO", "ERA", "WHIP", "Age"]:
352
+ assert row[stat] == pytest.approx(player[stat])
353
+
354
+
355
+ def test_pitcher_requires_birth_year(lahman: Path) -> None:
356
+ path = lahman / "People.csv"
357
+ pd.read_csv(path).drop(columns="birthYear").to_csv(path, index=False)
358
+ source = LahmanSource(str(lahman))
359
+ with pytest.raises(DataFetchError, match="birthYear"):
360
+ source.project("batch", 2022, "pitching")
361
+
362
+
363
+ @pytest.mark.parametrize("command, kind", [("hitters", "batting"), ("pitchers", "pitching")])
364
+ def test_league_commands_match_batch(lahman: Path, command: str, kind: str) -> None:
365
+ out = lahman / f"{command}.csv"
366
+ batch = lahman / "batch.csv"
367
+ common = ["--year", "2022", "--data-dir", str(lahman), "--format", "csv"]
368
+ assert main([command, *common, "--out", str(out)]) == 0
369
+ assert main(["batch", *common, "--kind", kind, "--out", str(batch)]) == 0
370
+ pd.testing.assert_frame_equal(pd.read_csv(out), pd.read_csv(batch))
371
+ assert len(pd.read_csv(out)) == 2
372
+
373
+
374
+ def test_pitcher_full_history_survives_team_and_qualification_selection(lahman: Path) -> None:
375
+ path = lahman / "Pitching.csv"
376
+ frame = pd.read_csv(path)
377
+ frame.loc[(frame.playerID == "star") & (frame.yearID == 2019), "teamID"] = "LAN"
378
+ middle = (frame.playerID == "star") & (frame.yearID == 2020)
379
+ frame.loc[middle, ["teamID", "IPouts", "ER"]] = ["LAA", 90, 20]
380
+ frame.to_csv(path, index=False)
381
+ source = LahmanSource(str(lahman))
382
+ player = source.project("player", 2022, "pitching", name="Test Star").iloc[0]
383
+ team = source.project("team", 2022, "pitching", team="LAN").iloc[0]
384
+ batch = source.project("batch", 2022, "pitching")
385
+ row = batch[batch.Name == "Test Star"].iloc[0]
386
+ assert player["IP"] == pytest.approx((5 * 487 / 3 + 4 * 30 + 3 * 487 / 3) / 12)
387
+ for candidate in [team, row]:
388
+ for metric in ["IP", "ER", "ERA", "WHIP", "SO", "Reliability"]:
389
+ assert candidate[metric] == pytest.approx(player[metric])
390
+
391
+
392
+ def test_pitcher_trade_stints_count_once_and_qualify_together(lahman: Path) -> None:
393
+ original = LahmanSource(str(lahman)).project("batch", 2022, "pitching")
394
+ path = lahman / "Pitching.csv"
395
+ frame = pd.read_csv(path)
396
+ mask = (frame.playerID == "star") & (frame.yearID == 2021)
397
+ row = frame[mask].copy()
398
+ frame = frame[~mask]
399
+ for column in ["IPouts", "ER", "H", "HR", "BB", "SO", "BFP"]:
400
+ row[column] = row[column] / 2
401
+ second = row.assign(stint=2, teamID="LAN")
402
+ pd.concat([frame, row, second]).to_csv(path, index=False)
403
+ source = LahmanSource(str(lahman))
404
+ season = source.season(2021, "pitching")
405
+ assert len(season[season.PlayerID == "star"]) == 1
406
+ actual = source.project("batch", 2022, "pitching")
407
+ pd.testing.assert_frame_equal(
408
+ actual.sort_values("PlayerID").reset_index(drop=True),
409
+ original.sort_values("PlayerID").reset_index(drop=True),
410
+ )
411
+
412
+
413
+ def test_pitcher_without_three_qualifying_seasons_can_be_selected(lahman: Path) -> None:
414
+ path = lahman / "Pitching.csv"
415
+ frame = pd.read_csv(path)
416
+ frame.loc[(frame.playerID == "star") & (frame.yearID != 2021), "IPouts"] = 90
417
+ frame.to_csv(path, index=False)
418
+ result = LahmanSource(str(lahman)).project("player", 2022, "pitching", name="Test Star")
419
+ assert len(result) == 1
420
+ assert result.loc[0, "IP"] == pytest.approx((5 * 487 / 3 + 4 * 30 + 3 * 30) / 12)
@@ -408,3 +408,45 @@ def test_one_season_regression_uses_only_available_weights() -> None:
408
408
  result = project_player("One Season", prior, "batting", 2025, league_rates=rates)
409
409
  assert float(result.loc[0, "HR"]) / float(result.loc[0, "PA"]) == pytest.approx(324 / 4200)
410
410
  assert float(result.loc[0, "Reliability"]) == pytest.approx(3000 / 4200)
411
+
412
+
413
+ @pytest.mark.parametrize("age, factor", [(25, 1.024), (29, 1.0), (34, 1 / 1.015)])
414
+ def test_pitcher_age_curve_changes_rates_not_opportunities(age: int, factor: float) -> None:
415
+ neutral = project_player("Pitcher", _pitching_prior(), "pitching", 2026, age=29).iloc[0]
416
+ result = project_player("Pitcher", _pitching_prior(), "pitching", 2026, age=age).iloc[0]
417
+ assert result["IP"] == pytest.approx(neutral["IP"])
418
+ assert result["BF"] == pytest.approx(neutral["BF"])
419
+ assert result["Reliability"] == pytest.approx(neutral["Reliability"])
420
+ for stat in ["ER", "H", "HR", "BB", "ERA", "WHIP"]:
421
+ assert result[stat] == pytest.approx(neutral[stat] / factor)
422
+ assert result["SO"] == pytest.approx(neutral["SO"] * factor)
423
+ assert result["Age"] == age
424
+
425
+
426
+ def test_pitcher_birth_year_overrides_default_age() -> None:
427
+ prior = _pitching_prior().assign(birthYear=1992)
428
+ actual = project_player("Pitcher", prior, "pitching", 2026)
429
+ expected = project_player("Pitcher", _pitching_prior(), "pitching", 2026, age=34)
430
+ pd.testing.assert_frame_equal(actual, expected)
431
+
432
+
433
+ @pytest.mark.parametrize("birth", [float("nan"), float("inf"), "unknown", 1800, 2025])
434
+ def test_pitcher_invalid_birth_year(birth: object) -> None:
435
+ prior = _pitching_prior().assign(birthYear=birth)
436
+ with pytest.raises(ProjectionError, match="valid birthYear"):
437
+ project_player("Pitcher", prior, "pitching", 2026)
438
+
439
+
440
+ def test_pitcher_missing_year_keeps_calendar_weights() -> None:
441
+ prior = _pitching_prior()
442
+ prior = prior[prior.Season != 2024]
443
+ result = project_player("Pitcher", prior, "pitching", 2026, age=29).iloc[0]
444
+ assert result["IP"] == pytest.approx((5 * 200 + 3 * 210) / 8)
445
+ assert result["Reliability"] == pytest.approx(1630 / 1830)
446
+
447
+
448
+ def test_pitcher_future_season_has_no_evidence() -> None:
449
+ prior = _pitching_prior().iloc[[0]].assign(Season=2026)
450
+ result = project_player("Pitcher", prior, "pitching", 2026, age=29).iloc[0]
451
+ assert result["IP"] == 0
452
+ assert result["Reliability"] == 0
File without changes
File without changes
File without changes
File without changes