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 +4 -0
- backcaster/cli.py +161 -0
- backcaster/data.py +246 -0
- backcaster/marcel.py +202 -0
- backcaster/normalize.py +13 -0
- backcaster/outputs.py +24 -0
- backcaster/schemas.py +34 -0
- backcaster-0.3.0.dist-info/METADATA +237 -0
- backcaster-0.3.0.dist-info/RECORD +13 -0
- backcaster-0.3.0.dist-info/WHEEL +5 -0
- backcaster-0.3.0.dist-info/entry_points.txt +2 -0
- backcaster-0.3.0.dist-info/licenses/LICENSE +21 -0
- backcaster-0.3.0.dist-info/top_level.txt +1 -0
backcaster/__init__.py
ADDED
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)
|
backcaster/normalize.py
ADDED
|
@@ -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,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
|