backcaster 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
backcaster/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """backcaster package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.3.0"
backcaster/cli.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ import pandas as pd
7
+
8
+ from backcaster.data import (
9
+ DataFetchError,
10
+ PlayerLookupError,
11
+ fetch_season_stats,
12
+ lookup_player_ids,
13
+ resolve_player_lookup,
14
+ )
15
+ from backcaster.marcel import ProjectionError, project_player, project_team
16
+ from backcaster.outputs import to_cli_table, to_csv, to_html
17
+
18
+ _FILE_RENDERERS = {"csv": to_csv, "html": to_html}
19
+ _OUTPUT_FORMAT_CHOICES = ("cli", *_FILE_RENDERERS.keys())
20
+
21
+
22
+ def _prior_years(year: int) -> tuple[int, int, int]:
23
+ return year - 1, year - 2, year - 3
24
+
25
+
26
+ def _render(df: pd.DataFrame, output_format: str, out: str | None) -> None:
27
+ if output_format == "cli":
28
+ print(to_cli_table(df))
29
+ elif output_format in _FILE_RENDERERS:
30
+ if not out:
31
+ raise ValueError(f"--out is required for {output_format} output")
32
+ _FILE_RENDERERS[output_format](df, out)
33
+ else:
34
+ raise ValueError(f"Unsupported format: {output_format}")
35
+
36
+
37
+ def _require_columns(
38
+ df: pd.DataFrame, required: set[str], *, context: str, error_cls: type[Exception]
39
+ ) -> None:
40
+ missing = sorted(required - set(df.columns))
41
+ if missing:
42
+ cols = ", ".join(missing)
43
+ raise error_cls(f"{context} missing required column(s): {cols}.")
44
+
45
+
46
+ def run_player(args: argparse.Namespace) -> int:
47
+ years = _prior_years(args.year)
48
+ pid_df = lookup_player_ids(args.name)
49
+ resolved = resolve_player_lookup(args.name, pid_df, years)
50
+ fg_key = resolved.get("key_fangraphs")
51
+ fg_key_numeric = pd.to_numeric(fg_key, errors="coerce") if pd.notna(fg_key) else None
52
+
53
+ frames = []
54
+ league_frames = []
55
+ for y in years:
56
+ df = fetch_season_stats(y, args.kind)
57
+ league = df.copy()
58
+ league["Season"] = y
59
+ league_frames.append(league)
60
+
61
+ row = pd.DataFrame()
62
+ if pd.notna(fg_key_numeric) and "IDfg" in league.columns:
63
+ idfg_numeric = pd.to_numeric(league["IDfg"], errors="coerce")
64
+ row = league[idfg_numeric == fg_key_numeric].copy()
65
+
66
+ if row.empty and pd.isna(fg_key_numeric):
67
+ _require_columns(
68
+ league,
69
+ {"Name"},
70
+ context=f"Season {y} {args.kind} stats for name fallback",
71
+ error_cls=ProjectionError,
72
+ )
73
+ row = league[league["Name"].astype(str).str.casefold() == args.name.casefold()].copy()
74
+ if row.empty:
75
+ raise ProjectionError(f"Missing season {y} for player '{args.name}'.")
76
+ frames.append(row)
77
+
78
+ prior = pd.concat(frames, ignore_index=True).sort_values("Season", ascending=False)
79
+ league_prior = pd.concat(league_frames, ignore_index=True)
80
+ proj = project_player(args.name, prior, args.kind, args.year, league_df=league_prior)
81
+ _render(proj, args.format, args.out)
82
+ return 0
83
+
84
+
85
+ def run_team(args: argparse.Namespace) -> int:
86
+ years = _prior_years(args.year)
87
+ team_frames = []
88
+ league_frames = []
89
+ for y in years:
90
+ df = fetch_season_stats(y, args.kind)
91
+ _require_columns(
92
+ df, {"Team"}, context=f"Season {y} {args.kind} stats", error_cls=DataFetchError
93
+ )
94
+ league = df.copy()
95
+ league["Season"] = y
96
+ league_frames.append(league)
97
+
98
+ row = df[df["Team"] == args.team].copy()
99
+ if row.empty:
100
+ raise ProjectionError(f"Missing season {y} for team '{args.team}'.")
101
+ row["Season"] = y
102
+ team_frames.append(row)
103
+ all_team = pd.concat(team_frames, ignore_index=True)
104
+ all_league = pd.concat(league_frames, ignore_index=True)
105
+ proj = project_team(all_team, args.kind, args.year, baseline_df=all_league)
106
+ _render(proj, args.format, args.out)
107
+ return 0
108
+
109
+
110
+ def run_batch(args: argparse.Namespace) -> int:
111
+ years = _prior_years(args.year)
112
+ frames = []
113
+ for y in years:
114
+ df = fetch_season_stats(y, args.kind)
115
+ df = df.copy()
116
+ df["Season"] = y
117
+ frames.append(df)
118
+ all_players = pd.concat(frames, ignore_index=True)
119
+ proj = project_team(all_players, args.kind, args.year)
120
+ _render(proj, args.format, args.out)
121
+ return 0
122
+
123
+
124
+ def build_parser() -> argparse.ArgumentParser:
125
+ p = argparse.ArgumentParser(prog="backcaster")
126
+ sub = p.add_subparsers(dest="command", required=True)
127
+
128
+ def add_common(sp: argparse.ArgumentParser) -> None:
129
+ sp.add_argument("--year", type=int, required=True)
130
+ sp.add_argument("--kind", choices=["batting", "pitching"], required=True)
131
+ sp.add_argument("--format", choices=_OUTPUT_FORMAT_CHOICES, default="cli")
132
+ sp.add_argument("--out")
133
+
134
+ pp = sub.add_parser("player")
135
+ add_common(pp)
136
+ pp.add_argument("--name", required=True)
137
+ pp.set_defaults(func=run_player)
138
+
139
+ tp = sub.add_parser("team")
140
+ add_common(tp)
141
+ tp.add_argument("--team", required=True)
142
+ tp.set_defaults(func=run_team)
143
+
144
+ bp = sub.add_parser("batch")
145
+ add_common(bp)
146
+ bp.set_defaults(func=run_batch)
147
+ return p
148
+
149
+
150
+ def main(argv: list[str] | None = None) -> int:
151
+ parser = build_parser()
152
+ args = parser.parse_args(argv)
153
+ try:
154
+ return args.func(args)
155
+ except (DataFetchError, PlayerLookupError, ProjectionError, ValueError) as exc:
156
+ print(f"Error: {exc}", file=sys.stderr)
157
+ return 2
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())
backcaster/data.py ADDED
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from collections.abc import Callable, Iterable
6
+ from numbers import Number
7
+ from pathlib import Path
8
+ from typing import SupportsInt, cast
9
+
10
+ import pandas as pd
11
+
12
+ from backcaster.schemas import Kind
13
+
14
+ CACHE_ROOT = Path.home() / ".cache" / "backcaster"
15
+ VALID_KINDS: tuple[Kind, ...] = ("batting", "pitching")
16
+
17
+ LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ class DataFetchError(RuntimeError):
21
+ pass
22
+
23
+
24
+ class PlayerLookupError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def _normalize_name(value: str) -> str:
29
+ return " ".join(value.strip().lower().split())
30
+
31
+
32
+ def _clean_name_value(row: pd.Series, key: str) -> str:
33
+ value = row.get(key)
34
+ return str(value).strip() if pd.notna(value) else ""
35
+
36
+
37
+ def _candidate_full_names(row: pd.Series) -> set[str]:
38
+ names: set[str] = set()
39
+ first = _clean_name_value(row, "name_first")
40
+ last = _clean_name_value(row, "name_last")
41
+ given = _clean_name_value(row, "name_given")
42
+ if first and last:
43
+ names.add(_normalize_name(f"{first} {last}"))
44
+ if given:
45
+ names.add(_normalize_name(given))
46
+ return names
47
+
48
+
49
+ def _to_int_or_none(value: object) -> int | None:
50
+ if value is None or value == "":
51
+ return None
52
+ if isinstance(value, bool):
53
+ return int(value)
54
+ if isinstance(value, (Number, str, bytes, bytearray)):
55
+ try:
56
+ return int(cast(SupportsInt | str | bytes | bytearray, value))
57
+ except (TypeError, ValueError):
58
+ return None
59
+ return None
60
+
61
+
62
+ def _format_numeric_or_unknown(value: object) -> str:
63
+ as_int = _to_int_or_none(value)
64
+ return str(as_int) if as_int is not None else "?"
65
+
66
+
67
+ def _filter_candidates_by_name(requested_name: str, lookup_df: pd.DataFrame) -> pd.DataFrame:
68
+ def _normalized_name_column(column_name: str) -> pd.Series:
69
+ if column_name not in lookup_df.columns:
70
+ return pd.Series("", index=lookup_df.index, dtype="object")
71
+ return lookup_df[column_name].astype("string").fillna("").str.strip()
72
+
73
+ first_names = _normalized_name_column("name_first")
74
+ last_names = _normalized_name_column("name_last")
75
+ given_names = _normalized_name_column("name_given")
76
+
77
+ full_names = (first_names + " " + last_names).str.strip().map(_normalize_name)
78
+ given_names = given_names.map(_normalize_name)
79
+
80
+ has_full_name = first_names.ne("") & last_names.ne("")
81
+ name_mask = (has_full_name & full_names.eq(requested_name)) | given_names.eq(requested_name)
82
+ return lookup_df[name_mask] if name_mask.any() else lookup_df
83
+
84
+
85
+ def _overlaps_year_window(row: pd.Series, start_year: int, end_year: int) -> bool:
86
+ first = _to_int_or_none(row.get("mlb_played_first"))
87
+ last = _to_int_or_none(row.get("mlb_played_last"))
88
+ if first is None and last is None:
89
+ return True
90
+ if first is None:
91
+ # `last` cannot be None here because the both-None case returns above.
92
+ assert last is not None
93
+ return start_year <= last
94
+ if last is None:
95
+ return end_year >= first
96
+ return not (last < start_year or first > end_year)
97
+
98
+
99
+ def _filter_candidates_by_years(candidates: pd.DataFrame, years: list[int]) -> pd.DataFrame:
100
+ has_year_cols = {"mlb_played_first", "mlb_played_last"}.issubset(candidates.columns)
101
+ if not years or not has_year_cols:
102
+ return candidates
103
+ start_year, end_year = min(years), max(years)
104
+ window_mask = candidates.apply(
105
+ lambda row: _overlaps_year_window(row, start_year, end_year), axis=1
106
+ )
107
+ return candidates[window_mask]
108
+
109
+
110
+ def _format_candidate_detail(row: pd.Series) -> str:
111
+ given = _clean_name_value(row, "name_given")
112
+ first_name = _clean_name_value(row, "name_first")
113
+ last_name = _clean_name_value(row, "name_last")
114
+ label = given or f"{first_name} {last_name}".strip() or "Unknown Player"
115
+ fg = _format_numeric_or_unknown(row.get("key_fangraphs"))
116
+ start = _format_numeric_or_unknown(row.get("mlb_played_first"))
117
+ end = _format_numeric_or_unknown(row.get("mlb_played_last"))
118
+ return f"{label} (key_fangraphs={fg}, MLB={start}-{end})"
119
+
120
+
121
+ def resolve_player_lookup(
122
+ name: str, lookup_df: pd.DataFrame, target_years: Iterable[int]
123
+ ) -> pd.Series:
124
+ if lookup_df.empty:
125
+ raise PlayerLookupError(f"No player found for '{name}'.")
126
+
127
+ requested = _normalize_name(name)
128
+ candidates = _filter_candidates_by_name(requested, lookup_df)
129
+
130
+ years = sorted({int(y) for y in target_years})
131
+ candidates = _filter_candidates_by_years(candidates, years)
132
+
133
+ if len(candidates) == 1:
134
+ return candidates.iloc[0]
135
+
136
+ if len(candidates) == 0:
137
+ raise PlayerLookupError(f"No player found for '{name}' in seasons {years}.")
138
+
139
+ details = [_format_candidate_detail(row) for _, row in candidates.iterrows()]
140
+
141
+ raise PlayerLookupError(
142
+ f"Duplicate player match for '{name}'. Candidates: " + "; ".join(details)
143
+ )
144
+
145
+
146
+ def _cache_path(kind: Kind, year: int, ext: str = "csv") -> Path:
147
+ if kind not in VALID_KINDS:
148
+ expected = ", ".join(repr(value) for value in VALID_KINDS)
149
+ raise ValueError(f"Invalid kind {kind!r}. Expected one of: {expected}.")
150
+ CACHE_ROOT.mkdir(parents=True, exist_ok=True)
151
+ return CACHE_ROOT / f"{kind}_{year}.{ext}"
152
+
153
+
154
+ def _read_cache(kind: Kind, year: int) -> pd.DataFrame | None:
155
+ parquet_path = _cache_path(kind, year, "parquet")
156
+ csv_path = _cache_path(kind, year, "csv")
157
+
158
+ if parquet_path.exists():
159
+ try:
160
+ return pd.read_parquet(parquet_path)
161
+ except Exception as exc:
162
+ LOGGER.warning(
163
+ "Parquet cache read failed for %s %s at %s; falling back to CSV if available: %s",
164
+ kind,
165
+ year,
166
+ parquet_path,
167
+ exc,
168
+ )
169
+
170
+ if csv_path.exists():
171
+ return pd.read_csv(csv_path)
172
+ return None
173
+
174
+
175
+ def _atomic_write_frame(
176
+ df: pd.DataFrame,
177
+ target_path: Path,
178
+ writer: Callable[[pd.DataFrame, Path], None],
179
+ ) -> None:
180
+ tmp_path = target_path.with_suffix(f"{target_path.suffix}.{uuid.uuid4().hex}.tmp")
181
+ try:
182
+ writer(df, tmp_path)
183
+ tmp_path.replace(target_path)
184
+ finally:
185
+ if tmp_path.exists():
186
+ tmp_path.unlink()
187
+
188
+
189
+ def _write_cache(kind: Kind, year: int, df: pd.DataFrame) -> None:
190
+ parquet_path = _cache_path(kind, year, "parquet")
191
+ csv_path = _cache_path(kind, year, "csv")
192
+
193
+ try:
194
+ _atomic_write_frame(
195
+ df, parquet_path, lambda frame, path: frame.to_parquet(path, index=False)
196
+ )
197
+ except Exception as exc:
198
+ LOGGER.warning(
199
+ "Parquet cache write failed for %s %s at %s; falling back to CSV: %s",
200
+ kind,
201
+ year,
202
+ parquet_path,
203
+ exc,
204
+ )
205
+ _atomic_write_frame(df, csv_path, lambda frame, path: frame.to_csv(path, index=False))
206
+
207
+
208
+ def fetch_season_stats(year: int, kind: Kind, use_cache: bool = True) -> pd.DataFrame:
209
+ if not isinstance(kind, str) or kind not in ("batting", "pitching"):
210
+ raise DataFetchError(f"Invalid kind {kind!r}. Expected one of: 'batting', 'pitching'.")
211
+
212
+ cached = _read_cache(kind, year) if use_cache else None
213
+ if cached is not None:
214
+ return cached
215
+
216
+ try:
217
+ import pybaseball as pyb
218
+
219
+ if kind == "batting":
220
+ df = pyb.batting_stats(year)
221
+ else:
222
+ df = pyb.pitching_stats(year)
223
+ except Exception as exc:
224
+ raise DataFetchError(f"Unable to fetch {kind} stats for {year}: {exc}") from exc
225
+
226
+ if df is None or df.empty:
227
+ raise DataFetchError(f"No {kind} stats returned for {year}.")
228
+
229
+ _write_cache(kind, year, df)
230
+ return df
231
+
232
+
233
+ def lookup_player_ids(name: str) -> pd.DataFrame:
234
+ parts = name.strip().split()
235
+ if len(parts) < 2:
236
+ raise PlayerLookupError("Provide both first and last name.")
237
+ first, last = parts[0], parts[-1]
238
+ try:
239
+ import pybaseball as pyb
240
+
241
+ df = pyb.playerid_lookup(last, first)
242
+ except Exception as exc:
243
+ raise PlayerLookupError(f"Failed player lookup for '{name}': {exc}") from exc
244
+ if df.empty:
245
+ raise PlayerLookupError(f"No player found for '{name}'.")
246
+ return df
backcaster/marcel.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+ from backcaster.normalize import safe_divide
6
+ from backcaster.schemas import MarcelConfig
7
+
8
+
9
+ class ProjectionError(RuntimeError):
10
+ pass
11
+
12
+
13
+ BATTING_COMPONENTS = ["PA", "AB", "H", "2B", "3B", "HR", "BB", "SO", "HBP", "SF"]
14
+ PITCHING_COMPONENTS = ["IP", "ER", "H", "HR", "BB", "SO", "BF"]
15
+
16
+
17
+ def _weighted_row(
18
+ rows: list[pd.Series], weights: tuple[float, float, float], cols: list[str]
19
+ ) -> pd.Series:
20
+ total_w = sum(weights[: len(rows)])
21
+ out = {}
22
+ for col in cols:
23
+ out[col] = (
24
+ sum(float(r.get(col, 0.0)) * w for r, w in zip(rows, weights)) / total_w
25
+ if total_w
26
+ else 0.0
27
+ )
28
+ return pd.Series(out)
29
+
30
+
31
+ def _derive_batting_rates(s: pd.Series) -> pd.Series:
32
+ singles = s["H"] - s["2B"] - s["3B"] - s["HR"]
33
+ tb = singles + 2 * s["2B"] + 3 * s["3B"] + 4 * s["HR"]
34
+ avg = safe_divide(s["H"], s["AB"])
35
+ obp = safe_divide(
36
+ s["H"] + s["BB"] + s.get("HBP", 0), s["AB"] + s["BB"] + s.get("HBP", 0) + s.get("SF", 0)
37
+ )
38
+ slg = safe_divide(tb, s["AB"])
39
+ return pd.Series({"AVG": avg, "OBP": obp, "SLG": slg, "OPS": obp + slg})
40
+
41
+
42
+ def _derive_pitching_rates(s: pd.Series) -> pd.Series:
43
+ era = safe_divide(s["ER"] * 9, s["IP"])
44
+ whip = safe_divide(s["H"] + s["BB"], s["IP"])
45
+ return pd.Series({"ERA": era, "WHIP": whip})
46
+
47
+
48
+ def _compute_baseline_rates(df: pd.DataFrame, comps: list[str]) -> tuple[float, dict[str, float]]:
49
+ pt_col = comps[0]
50
+ present_cols = [c for c in comps if c in df.columns]
51
+ sums = df[present_cols].sum() if present_cols else pd.Series(dtype="float64")
52
+ league_pt = float(sums.get(pt_col, 0.0))
53
+ league_rates = {c: safe_divide(float(sums.get(c, 0.0)), league_pt) for c in comps[1:]}
54
+ return league_pt, league_rates
55
+
56
+
57
+ def _validate_projection_kind(kind: str) -> str:
58
+ if kind not in {"batting", "pitching"}:
59
+ raise ProjectionError(f"Invalid kind {kind!r}. Expected one of: 'batting', 'pitching'.")
60
+ return kind
61
+
62
+
63
+ def _projection_components(kind: str, config: MarcelConfig) -> tuple[list[str], float]:
64
+ if kind == "batting":
65
+ return BATTING_COMPONENTS, config.regression_pa
66
+ return PITCHING_COMPONENTS, config.regression_ip
67
+
68
+
69
+ def _prepare_prior_df(
70
+ prior_three: pd.DataFrame, comps: list[str], optional_comps: set[str] | None = None
71
+ ) -> pd.DataFrame:
72
+ if "Season" not in prior_three.columns:
73
+ raise ProjectionError("Missing required Season column in prior seasons input.")
74
+
75
+ season_values = prior_three["Season"]
76
+ if season_values.isna().any():
77
+ raise ProjectionError("Season values must be non-missing in prior seasons input.")
78
+
79
+ try:
80
+ season_numeric = pd.to_numeric(season_values, errors="raise")
81
+ except (TypeError, ValueError) as exc:
82
+ raise ProjectionError("Season values must be numeric and consistently typed.") from exc
83
+
84
+ prior_df = (
85
+ prior_three.assign(Season=season_numeric)
86
+ .sort_values("Season", ascending=False, kind="mergesort")
87
+ .reset_index(drop=True)
88
+ .copy()
89
+ )
90
+
91
+ optional_comps = optional_comps or set()
92
+ missing_required = [c for c in comps if c not in prior_df.columns and c not in optional_comps]
93
+ if missing_required:
94
+ missing_required_str = ", ".join(sorted(missing_required))
95
+ raise ProjectionError(
96
+ f"Missing required component columns in prior seasons input: {missing_required_str}."
97
+ )
98
+
99
+ for c in optional_comps:
100
+ if c in comps and c not in prior_df.columns:
101
+ prior_df[c] = 0.0
102
+ return prior_df
103
+
104
+
105
+ def project_player(
106
+ player_name: str,
107
+ prior_three: pd.DataFrame,
108
+ kind: str,
109
+ year: int,
110
+ config: MarcelConfig | None = None,
111
+ age: float = 29,
112
+ league_df: pd.DataFrame | None = None,
113
+ league_rates: dict[str, float] | None = None,
114
+ ) -> pd.DataFrame:
115
+ config = config or MarcelConfig()
116
+ if prior_three.empty:
117
+ raise ProjectionError("Expected at least one prior season for projection.")
118
+ if prior_three.shape[0] > 3:
119
+ raise ProjectionError("Expected at most three prior seasons for projection.")
120
+
121
+ kind = _validate_projection_kind(kind)
122
+ comps, reg_pt = _projection_components(kind, config)
123
+ optional_comps = {"HBP", "SF"} if kind == "batting" else set()
124
+ prior_df = _prepare_prior_df(prior_three, comps, optional_comps=optional_comps)
125
+
126
+ rows = [prior_df.iloc[i] for i in range(min(3, prior_df.shape[0]))]
127
+ weighted = _weighted_row(rows, config.season_weights, comps)
128
+
129
+ pt_col = comps[0]
130
+ weighted_pt = float(weighted[pt_col])
131
+ reliability = min(1.0, safe_divide(weighted_pt, config.reliability_scale))
132
+
133
+ if league_rates is None:
134
+ baseline_df = league_df if league_df is not None else prior_df
135
+ _, league_rates = _compute_baseline_rates(baseline_df, comps)
136
+
137
+ regressed = weighted.copy()
138
+ regressed[pt_col] = weighted_pt
139
+ for c in comps[1:]:
140
+ regressed_rate = safe_divide(
141
+ float(weighted[c]) + league_rates[c] * reg_pt, weighted_pt + reg_pt
142
+ )
143
+ regressed[c] = regressed_rate * weighted_pt
144
+
145
+ growth = config.default_pa_growth if kind == "batting" else config.default_ip_growth
146
+ age_adj = config.age_adjustment_fn(age)
147
+ projected_pt = weighted_pt * growth * age_adj
148
+ pt_scale = safe_divide(projected_pt, weighted_pt)
149
+ regressed *= pt_scale
150
+
151
+ if kind == "batting":
152
+ rates = _derive_batting_rates(regressed)
153
+ else:
154
+ rates = _derive_pitching_rates(regressed)
155
+
156
+ output = pd.concat([regressed, rates])
157
+ output["Reliability"] = reliability
158
+ output["Name"] = player_name
159
+ output["Year"] = year
160
+ return output.to_frame().T
161
+
162
+
163
+ def project_team(
164
+ team_df: pd.DataFrame,
165
+ kind: str,
166
+ year: int,
167
+ config: MarcelConfig | None = None,
168
+ baseline_df: pd.DataFrame | None = None,
169
+ ) -> pd.DataFrame:
170
+ config = config or MarcelConfig()
171
+ if "Name" not in team_df.columns:
172
+ raise ProjectionError("Missing required Name column in team input.")
173
+ if "Season" not in team_df.columns:
174
+ raise ProjectionError("Missing required Season column in team input.")
175
+ kind = _validate_projection_kind(kind)
176
+ if kind == "batting":
177
+ comps = BATTING_COMPONENTS
178
+ else:
179
+ comps = PITCHING_COMPONENTS
180
+ baseline_source = baseline_df if baseline_df is not None else team_df
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"
183
+ grouped = team_df.groupby(group_column, dropna=False, sort=False)
184
+ projections: list[pd.DataFrame] = []
185
+ for _, grp in grouped:
186
+ sorted_grp = grp.sort_values("Season", ascending=False, kind="mergesort")
187
+ display_name = sorted_grp["Name"].iloc[0]
188
+ if not isinstance(display_name, str):
189
+ raise ProjectionError("Encountered non-string player name in team input.")
190
+ projections.append(
191
+ project_player(
192
+ display_name,
193
+ sorted_grp.head(3),
194
+ kind,
195
+ year,
196
+ config,
197
+ league_rates=baseline_rates,
198
+ )
199
+ )
200
+ if not projections:
201
+ raise ProjectionError("No players available to project for this team.")
202
+ return pd.concat(projections, ignore_index=True)
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+
6
+ def numeric_columns(df: pd.DataFrame, exclude: set[str] | None = None) -> list[str]:
7
+ return [
8
+ c for c in df.columns if c not in (exclude or ()) and pd.api.types.is_numeric_dtype(df[c])
9
+ ]
10
+
11
+
12
+ def safe_divide(numerator: float, denominator: float) -> float:
13
+ return 0.0 if denominator == 0 else float(numerator) / float(denominator)
backcaster/outputs.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def to_cli_table(df: pd.DataFrame) -> str:
9
+ return df.to_string(index=False)
10
+
11
+
12
+ def to_csv(df: pd.DataFrame, out_path: str | Path) -> Path:
13
+ out = Path(out_path)
14
+ out.parent.mkdir(parents=True, exist_ok=True)
15
+ df.to_csv(out, index=False)
16
+ return out
17
+
18
+
19
+ def to_html(df: pd.DataFrame, out_path: str | Path) -> Path:
20
+ out = Path(out_path)
21
+ out.parent.mkdir(parents=True, exist_ok=True)
22
+ html = df.to_html(index=False, border=0)
23
+ out.write_text(html, encoding="utf-8")
24
+ return out
backcaster/schemas.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal
6
+
7
+ import pandas as pd
8
+
9
+ Kind = Literal["batting", "pitching"]
10
+ OutputFormat = Literal["cli", "csv", "html"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class MarcelConfig:
15
+ """Configuration knobs for Marcel-style projection."""
16
+
17
+ season_weights: tuple[float, float, float] = (5.0, 4.0, 3.0)
18
+ regression_pa: float = 1200.0
19
+ regression_ip: float = 200.0
20
+ age_adjustment_fn: Callable[[float], float] = lambda age: 1.0 + (0.006 if age <= 29 else -0.003)
21
+ reliability_scale: float = 1200.0
22
+ default_pa_growth: float = 1.0
23
+ default_ip_growth: float = 1.0
24
+
25
+
26
+ @dataclass
27
+ class ProjectionResult:
28
+ player_name: str
29
+ kind: Kind
30
+ year: int
31
+ projected: pd.DataFrame
32
+ source_years: tuple[int, int, int]
33
+ reliability: float
34
+ meta: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.4
2
+ Name: backcaster
3
+ Version: 0.3.0
4
+ Summary: Marcel-style MLB projection CLI built on pybaseball
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
7
+ Project-URL: Repository, https://github.com/jeffreywevans/backcaster
8
+ Project-URL: Issues, https://github.com/jeffreywevans/backcaster/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Console
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
17
+ Requires-Python: >=3.13
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pandas>=2.0
21
+ Requires-Dist: pybaseball>=2.2
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.11.0; extra == "dev"
26
+ Requires-Dist: mypy>=1.10; extra == "dev"
27
+ Requires-Dist: pandas-stubs>=2.2; extra == "dev"
28
+ Requires-Dist: build>=1.2; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # Backcaster
32
+
33
+ Backcaster is a Python CLI that produces **Marcel-style MLB projections** from the prior three seasons of data using [`pybaseball`](https://github.com/jldbc/pybaseball) and `pandas`.
34
+
35
+ It supports:
36
+ - **Single player projections** (`player`)
37
+ - **Single team projections** (`team`)
38
+ - **League-wide batch projections** (`batch`)
39
+ - Output as terminal table, CSV, or HTML
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ ### Requirements
46
+ - Python **3.13+**
47
+ - Internet access for first-time stat fetches (data is cached locally afterward)
48
+
49
+ ### Install from source (recommended for contributors)
50
+
51
+ ```bash
52
+ # from repo root
53
+ uv sync
54
+ ```
55
+
56
+ This installs runtime dependencies and the `backcaster` CLI entrypoint defined in `pyproject.toml`.
57
+
58
+ ### Install in editable/dev mode
59
+
60
+ ```bash
61
+ uv sync --extra dev
62
+ ```
63
+
64
+ This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`).
65
+
66
+ ### Build/install as a package
67
+
68
+ ```bash
69
+ python -m build
70
+ pip install dist/backcaster-*.whl
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Quickstart CLI usage
76
+
77
+ All commands require:
78
+ - `--year` target projection year
79
+ - `--kind` one of `batting` or `pitching`
80
+
81
+ Subcommand-specific required options:
82
+ - `player` requires `--name`
83
+ - `team` requires `--team`
84
+ - `batch` requires no additional required option
85
+
86
+ ```bash
87
+ backcaster <player|team|batch> [subcommand-options] --year <YEAR> --kind <batting|pitching> [--format cli|csv|html] [--out PATH]
88
+ ```
89
+
90
+ ### 1) Player projection
91
+
92
+ ```bash
93
+ backcaster player --name "Mookie Betts" --year 2026 --kind batting
94
+ ```
95
+
96
+ ### 2) Team projection
97
+
98
+ ```bash
99
+ backcaster team --team LAD --year 2026 --kind pitching
100
+ ```
101
+
102
+ ### 3) Batch projection (all players returned by source data)
103
+
104
+ ```bash
105
+ backcaster batch --year 2026 --kind batting
106
+ ```
107
+
108
+ ### File outputs
109
+
110
+ ```bash
111
+ backcaster player --name "Mookie Betts" --year 2026 --kind batting --format csv --out ./out/mookie_2026.csv
112
+ backcaster team --team LAD --year 2026 --kind pitching --format html --out ./out/lad_pitching_2026.html
113
+ ```
114
+
115
+ > `--out` is required for `csv` and `html` formats.
116
+
117
+ ---
118
+
119
+ ## Output examples
120
+
121
+ ### CLI table output (`--format cli`, default)
122
+
123
+ Example shape (columns vary by batting vs pitching):
124
+
125
+ ```text
126
+ PA AB H 2B 3B HR BB SO HBP SF AVG OBP SLG OPS Reliability Name Year
127
+ 610.2 545.1 162.4 34.1 2.8 32.6 67.9 119.3 5.6 4.7 0.298 0.384 0.531 0.915 0.89 Mookie Betts 2026
128
+ ```
129
+
130
+ ### CSV output (`--format csv`)
131
+ A single-row CSV for `player`; multi-row CSV for `team` and `batch`.
132
+
133
+ ### HTML output (`--format html`)
134
+ A basic HTML table (no custom CSS) suitable for quick sharing or embedding.
135
+
136
+ ---
137
+
138
+ ## Cache behavior
139
+
140
+ Backcaster caches season stat pulls under:
141
+
142
+ ```text
143
+ ~/.cache/backcaster/
144
+ ```
145
+
146
+ Cache details:
147
+ - Key pattern: `<kind>_<year>.parquet` (preferred) or `<kind>_<year>.csv` (fallback)
148
+ - Read order: Parquet first, then CSV
149
+ - Write behavior: atomic temp-file write + rename to reduce corruption risk
150
+ - Parquet write failures automatically fall back to CSV
151
+
152
+ Practical implications:
153
+ - First run for a season may be slower (network fetch).
154
+ - Re-runs are typically faster (local cache hit).
155
+ - If upstream data changes and you need a refresh, delete the relevant cache file(s) and rerun.
156
+
157
+ ---
158
+
159
+ ## Dependency notes
160
+
161
+ Runtime dependencies are intentionally minimal:
162
+ - `pandas` for tabular data processing and projection output
163
+ - `pybaseball` for MLB player/team seasonal stat retrieval and player ID lookup
164
+
165
+ Development dependencies (`--extra dev`) include:
166
+ - `pytest`, `pytest-cov`
167
+ - `ruff`
168
+ - `mypy`, `pandas-stubs`
169
+
170
+ ### Lockfile policy (`uv.lock`)
171
+
172
+ `uv.lock` pins transitive versions for reproducible environments when working from a Git checkout.
173
+
174
+ Release/source ZIP archives may not include `uv.lock`. If it is missing in your environment, regenerate it locally:
175
+
176
+ ```bash
177
+ uv lock
178
+ ```
179
+
180
+ Do not hand-edit `uv.lock`; regenerate it when dependencies change.
181
+
182
+ ---
183
+
184
+ ## What this projection does and does not claim
185
+
186
+ ### What it does
187
+ - Implements a **Marcel-style** weighted/regressed projection over prior seasons.
188
+ - Uses configurable reliability/regression/playing-time assumptions in code defaults.
189
+ - Produces transparent, table-based outputs for downstream analysis.
190
+
191
+ ### What it does **not** claim
192
+ - It is **not** an official MLB/club forecasting system.
193
+ - It does **not** model injuries, role changes, park effects, coaching strategy, or transaction context in a comprehensive way.
194
+ - It does **not** guarantee predictive superiority versus commercial/public projection systems.
195
+ - It should be treated as a **baseline projection tool**, not a betting or financial decision engine.
196
+
197
+ ---
198
+
199
+ ## Development and quality checks
200
+
201
+ For release preparation and PyPI Trusted Publishing, see [RELEASING.md](RELEASING.md).
202
+ Changes for the next release are recorded in [CHANGELOG.md](CHANGELOG.md).
203
+
204
+ ### Run tests
205
+
206
+ ```bash
207
+ pytest
208
+ ```
209
+
210
+ ### Coverage + JUnit reports
211
+
212
+ ```bash
213
+ pytest --junitxml=pytest.xml --cov=src --cov-report=xml:coverage.xml
214
+ ```
215
+
216
+ ### Lint and types
217
+
218
+ ```bash
219
+ ruff check .
220
+ mypy
221
+ ```
222
+
223
+ ### SonarScanner
224
+
225
+ ```bash
226
+ sonar-scanner -Dsonar.projectVersion=$(PYTHONPATH=src python -c "from backcaster import __version__; print(__version__)")
227
+ ```
228
+
229
+ Sonar consumes:
230
+ - `coverage.xml`
231
+ - `pytest.xml`
232
+
233
+ ---
234
+
235
+ ## Security and responsible use
236
+
237
+ Please review `SECURITY.md` for reporting guidance. Backcaster is an analytical utility and should be validated independently before use in production pipelines.
@@ -0,0 +1,13 @@
1
+ backcaster/__init__.py,sha256=euTgnCilMbmN6lU2sTIDND4iY9W7tk_sCrcfBAP51JI,75
2
+ backcaster/cli.py,sha256=Jv0lyC3JOkC5rogWOzRMND2M2KInAvvt9sch6ALYyXc,5315
3
+ backcaster/data.py,sha256=ot5zTu-A5P6iFHmxbnrBgYO6GP6TUHlvsTDDLgJfn7o,8138
4
+ backcaster/marcel.py,sha256=f9qpMRJ0u0NrCfDInIAgHQ-KW3SsydyQ1kA_n-Dhgks,7342
5
+ backcaster/normalize.py,sha256=gJWS5uXCVgJWf456RdoWoZ9BII1IqfeyFdsRPvFcljc,405
6
+ backcaster/outputs.py,sha256=RQQOUegTmeBqz3NkOH7QKR1pT2pUqifWvlw1Ccy3jbQ,589
7
+ backcaster/schemas.py,sha256=jCsVixeJFQjHfqKFM24f6xg1emGUD-83-41p9xauTUo,925
8
+ backcaster-0.3.0.dist-info/licenses/LICENSE,sha256=FwqY_Dpco13UKumXJCjQqeZastouwYyUyIz6_UBOa44,1080
9
+ backcaster-0.3.0.dist-info/METADATA,sha256=Cg8hHrIbVKC0yHj3yjFDkBtJ8g-KJhFqdt6FnPPp0Gg,6597
10
+ backcaster-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ backcaster-0.3.0.dist-info/entry_points.txt,sha256=rowOJPeGAsJbYR_64Y8wGSS6UszIMC7YxH-BnSqH77Y,51
12
+ backcaster-0.3.0.dist-info/top_level.txt,sha256=gF7yELu88KPtSo1912jnCC4zVJ-ND7SuA-x1aijvDAI,11
13
+ backcaster-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ backcaster = backcaster.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 backcaster contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ backcaster