stathead 0.2.1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ node_modules
2
+ dist
3
+ # ...except the published MCP server bundle (the distributable artifact).
4
+ !mcp/dist/
5
+ !mcp/dist/server.mjs
6
+ .DS_Store
7
+ *.local
8
+ .env
9
+ !.env.example
10
+ public/data/*
11
+ !public/data/schedule-*.json
12
+ !public/data/redraft-projections-presets.json
13
+ # Mike Clay's NFL Projection Guide (ESPN+) is a paid product — its raw extracts
14
+ # are LOCAL-ONLY (regenerate via scripts/extract_clay_*.py from a PDF you own).
15
+ # Never commit clay-*.json; only the derived/anonymized blends ship. See
16
+ # DATA_SOURCES.md ("Paid products — never redistribute the source").
17
+ public/data/clay-projections-*.json
18
+ public/data/clay-unit-grades-*.json
19
+ public/data/clay-matchups-*.json
20
+ public/data/clay-team-projections-*.json
21
+ !public/data/training-rows-cache-*.json
22
+ !public/data/trained-models-cache-*.json
23
+ !public/data/model-cache-*.json
24
+ !public/data/feature-store/
25
+ !public/data/score-store/
26
+ !public/data/ktc_rankings_*.json
27
+ !public/data/ktc_history.json
28
+ !public/data/nflverse_weekly_2025.json
29
+ !public/data/ppg-feature-ablation.json
30
+ !public/data/ppg-hyperparam-sweep.json
31
+ !public/data/residual-feature-ablation.json
32
+ !public/data/residual-hyperparam-sweep.json
33
+ !public/data/ktc-feature-ablation.json
34
+ !public/data/ktc-hyperparam-sweep.json
35
+ !public/data/pdf-career-ablation-predraft.json
36
+ !public/data/pdf-career-ablation-postdraft.json
37
+ !public/data/rsp-career-ablation-predraft.json
38
+ !public/data/rsp-career-ablation-postdraft.json
39
+ !public/data/pdf-only-career-test.json
40
+ !public/data/qb-beast-ablation.json
41
+ !public/data/ktc-forecasts-*.json
42
+ !public/data/redraft-projections.json
43
+ !public/data/projection-base-*.json
44
+ !public/data/model-eval-*.json
45
+ !public/data/team-metrics-*.json
46
+ !public/data/coach-tendencies.json
47
+ !public/data/player-metrics-*.json
48
+ !public/data/pbp-slim-*.json.gz
49
+ !public/data/prospect-boom-bust.json
50
+ !public/data/prospect-grades-*.json
51
+ !public/data/depth-order-2026.json
52
+ !public/data/career-2027.json
53
+ !public/data/cfbd-college-stats.json
54
+ !public/data/cfbd-sp-ratings.json
55
+ !public/data/cfbd-recruiting.json
56
+ !public/data/cfbd-games.json
57
+ !public/data/cfbd-team-talent.json
58
+ !public/data/cfbd-player-usage.json
59
+ !public/data/cfbd/
60
+ !public/data/cfbd/*.json
61
+ !public/data/pdf-prospect-features.json
62
+ !public/data/pdf-prospect-features-merged.json
63
+ !public/data/rsp-historical-rankings.json
64
+ !public/data/rsp-qb-features.json
65
+ !public/data/manual-cfbd-overrides.json
66
+ !public/data/player-crosswalk.json
67
+ !public/data/player-id-map.json
68
+ !public/data/player-aliases.json
69
+ !public/data/player-promotions.json
70
+ !public/data/player-conflicts.json
71
+ !public/data/player-buzz.json
72
+ # Raw upstream sources committed as .csv.gz (~35 MB vs ~200 MB raw).
73
+ # TS readLocalFile auto-decompresses; Python runs `bash scripts/extract-data.sh`
74
+ # once to materialize the .csv. See scripts/pull-all-data-sources.sh.
75
+ !public/data/*.csv.gz
76
+ !public/data/ffc_adp_ppr_*.json
77
+ !public/data/ffc_adp_2qb_*.json
78
+ !public/data/espn_adp_*.json
79
+ !public/data/espn-nfl-ids.json
80
+ !public/data/fantasycalc_*.json
81
+ !public/data/fantasycalc_history_*.json
82
+ !public/data/sleeper-players.json
83
+ !public/data/sleeper-adp-*.json
84
+ !public/data/ktc-fc-rescale.json
85
+ # Aggregated, anonymized expert activity (no usernames) — committed/deployed.
86
+ # Built by scripts/build_expert_data.py from the gitignored private/ list.
87
+ !public/data/expert-ownership.json
88
+ !public/data/expert-adds.json
89
+ !public/data/expert-trades.json
90
+ !public/data/expert-graph.json
91
+ !public/data/expert-league-graph.json
92
+ !public/data/expert-candidates.json
93
+ # expert-names.json (index -> username) is intentionally NOT un-ignored: it stays
94
+ # local-only so the social graph / rankings can show names in dev but never ship.
95
+ # Its passphrase-encrypted form (scripts/encrypt-expert-names.mjs) IS committed:
96
+ # the deployed Social Graph decrypts it in-browser after an "unlock" passphrase.
97
+ # The ciphertext is public, so the passphrase is the only protection — keep it strong.
98
+ !public/data/expert-names.enc.json
99
+ .claude/*
100
+ !.claude/commands/
101
+ !.claude/commands/**
102
+ CLAUDE.local.md
103
+ __pycache__/
104
+ .nflverse-cache/
105
+ .cache/
106
+ .venv/
107
+ pdfs/
108
+
109
+ # Proprietary inputs (Clay PDFs + parsed dataset) — never committed.
110
+ /private/
111
+ # Local-only Clay projection set consumed at runtime by the "Consensus" preset.
112
+ # Never committed — absent in the public deploy so Clay's numbers ship nowhere.
113
+ /public/data/clay/
114
+
115
+ # Downloaded headshot images
116
+ headshots.zip
117
+ headshots_part*.zip
118
+ !public/data/depth-starters-*.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stathead
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Python client for the StatHead fantasy football model — rookie career predictions, historical ADP, and flattened feature matrices.
5
5
  Project-URL: Homepage, https://github.com/dachhack/stathead
6
6
  Project-URL: Source, https://github.com/dachhack/stathead
@@ -39,8 +39,8 @@ pip install stathead
39
39
  Optional extras:
40
40
 
41
41
  ```bash
42
- pip install "stathead[polars]" # for .to_polars() helpers
43
- pip install "stathead[duckdb]" # for local SQL querying
42
+ pip install "stathead[polars]" # for sh.to_polars() / sh.load_polars() helpers
43
+ pip install "stathead[duckdb]" # for local SQL querying (sh.query)
44
44
  ```
45
45
 
46
46
  ## Quick start
@@ -62,6 +62,58 @@ adp = sh.load_adp_historical()
62
62
  adp[(adp.season == 2023) & (adp.adp <= 24)]
63
63
  ```
64
64
 
65
+ ## SQL querying
66
+
67
+ With the `duckdb` extra, `sh.query()` runs SQL over the loaders — the same
68
+ surface as the site's Data Query tab, in Python. Every table joins on
69
+ `player_key`.
70
+
71
+ ```bash
72
+ pip install "stathead[duckdb]"
73
+ ```
74
+
75
+ ```python
76
+ import stathead as sh
77
+
78
+ sh.query("""
79
+ SELECT c.name, c.position, c.predictedCareerPPG, d.value_1qb
80
+ FROM career_2026 c
81
+ JOIN dynasty_values d USING (player_key)
82
+ WHERE c.percentile >= 80
83
+ ORDER BY d.value_1qb DESC
84
+ """)
85
+
86
+ sh.list_tables() # every queryable table name
87
+ ```
88
+
89
+ Tables load lazily — only the ones a query references are materialized, so a
90
+ query that never touches `player_stats` (~400k rows) doesn't pay for it.
91
+ Register your own DataFrame (a roster, a league export) to join against the
92
+ model tables:
93
+
94
+ ```python
95
+ sh.register("my_roster", roster_df)
96
+ sh.query("SELECT * FROM career_2026 c JOIN my_roster r USING (player_key)")
97
+ ```
98
+
99
+ ## Polars
100
+
101
+ Loaders return pandas. With the `polars` extra, convert any of them — handy on
102
+ the big tables like `load_player_stats` (~400k rows) where polars is faster.
103
+
104
+ ```bash
105
+ pip install "stathead[polars]"
106
+ ```
107
+
108
+ ```python
109
+ import stathead as sh
110
+
111
+ pl_df = sh.to_polars(sh.load_player_stats(2024))
112
+
113
+ # Or convert a loader by reference, without calling it yourself:
114
+ pl_df = sh.load_polars(sh.load_player_stats, 2024)
115
+ ```
116
+
65
117
  ## Pinning to a specific version
66
118
 
67
119
  Loaders resolve against the upstream GitHub repo. Pin to a commit SHA, tag,
@@ -85,16 +137,60 @@ cache directory or call `clear_cache()` to force a refresh.
85
137
 
86
138
  ## Available loaders
87
139
 
140
+ Every table on the [StatHead site](https://github.com/dachhack/stathead) is
141
+ available here as a pandas DataFrame.
142
+
143
+ **Predictions & prospects**
144
+
88
145
  | Function | Returns | Shape |
89
146
  |---|---|---|
90
147
  | `load_career_predictions_2026()` | 2026 rookie predictions | ~77 × ~80 cols |
91
148
  | `load_career_backtest()` | Historical rookies with pred + actual PPG | ~1087 × ~100 cols |
149
+ | `load_prospect_grades(year=2026)` | Scouting-report grades | ~200 × 7 |
150
+ | `load_career_2027()` | 2027 draft-class early board + college aggregates | ~200 × ~30 |
151
+
152
+ **Projections (model outputs)**
153
+
154
+ | Function | Returns | Shape |
155
+ |---|---|---|
156
+ | `load_redraft_projections()` | Seasonal redraft PPG (PPR) + receptions/game | ~250 × 7 |
157
+ | `load_ppg_projections()` | Model-predicted PPG for established players | ~250 × 4 |
158
+ | `load_adp_value_model()` | VOR vs ADP, hit probability, confidence interval | ~153 × 10 |
159
+ | `load_volume_projections()` | Team pass/rush/target volumes with low/high bands | ~153 × ~14 |
160
+ | `load_share_projections()` | Predicted target + rush share | ~153 × 6 |
161
+ | `load_taxi_predictions()` | Taxi-squad roster/drop probabilities (+ `df.attrs['meta']`) | ~96 × 6 |
162
+
163
+ **Market & stats**
164
+
165
+ | Function | Returns | Shape |
166
+ |---|---|---|
167
+ | `load_player_stats(season=None)` | Per-player per-week NFL box scores 2010-present | ~400k × ~50 |
168
+ | `load_dynasty_values()` | In-house blended dynasty value (1QB + Superflex) | ~500 × 10 |
169
+ | `load_dynasty_value_history()` | Blended daily dynasty value history | variable |
92
170
  | `load_adp_historical()` | Model-training ADP 2010-2025 | 4507 × 10 |
93
171
  | `load_adp_ffc(season=None)` | FFC PPR raw ADP (per season as fetched) — data via [Fantasy Football Calculator](https://fantasyfootballcalculator.com/adp/ppr) | variable |
94
- | `load_prospect_grades(year=2026)` | Scouting-report grades | ~200 × 7 |
172
+
173
+ **Identity & raw**
174
+
175
+ | Function | Returns | Shape |
176
+ |---|---|---|
177
+ | `load_player_crosswalk()` | Canonical cross-source player IDs | ~10k × ~20 |
178
+ | `resolve_player(name, position=None)` / `get_player(key)` / `load_player_profile(key)` | Name → `player_key` helpers | — |
95
179
  | `load_feature_matrix()` | Raw `feature-matrix.json` (dict) | — |
96
180
  | `load_manual_overrides()` | Manual CFBD usage overrides (dict) | — |
97
181
 
182
+ Every row-shaped loader carries a `player_key` column that joins to
183
+ `load_player_crosswalk()`.
184
+
185
+ ### Dynasty values are the app's in-house blend
186
+
187
+ `load_dynasty_values()` returns the same blended value the web app displays:
188
+ KTC rankings rescaled into FantasyCalc's scale via a per-player ratio
189
+ (`fc_value / ktc_value`, with a positional-median fallback below a value
190
+ floor). The ratio snapshot is built offline and committed as
191
+ `public/data/dynasty-fc-rescale.json`; the loader just applies it. No raw KTC
192
+ value is exposed — only the rescaled blend.
193
+
98
194
  ## Feature columns
99
195
 
100
196
  Career-prediction and backtest rows include flattened model features
@@ -11,8 +11,8 @@ pip install stathead
11
11
  Optional extras:
12
12
 
13
13
  ```bash
14
- pip install "stathead[polars]" # for .to_polars() helpers
15
- pip install "stathead[duckdb]" # for local SQL querying
14
+ pip install "stathead[polars]" # for sh.to_polars() / sh.load_polars() helpers
15
+ pip install "stathead[duckdb]" # for local SQL querying (sh.query)
16
16
  ```
17
17
 
18
18
  ## Quick start
@@ -34,6 +34,58 @@ adp = sh.load_adp_historical()
34
34
  adp[(adp.season == 2023) & (adp.adp <= 24)]
35
35
  ```
36
36
 
37
+ ## SQL querying
38
+
39
+ With the `duckdb` extra, `sh.query()` runs SQL over the loaders — the same
40
+ surface as the site's Data Query tab, in Python. Every table joins on
41
+ `player_key`.
42
+
43
+ ```bash
44
+ pip install "stathead[duckdb]"
45
+ ```
46
+
47
+ ```python
48
+ import stathead as sh
49
+
50
+ sh.query("""
51
+ SELECT c.name, c.position, c.predictedCareerPPG, d.value_1qb
52
+ FROM career_2026 c
53
+ JOIN dynasty_values d USING (player_key)
54
+ WHERE c.percentile >= 80
55
+ ORDER BY d.value_1qb DESC
56
+ """)
57
+
58
+ sh.list_tables() # every queryable table name
59
+ ```
60
+
61
+ Tables load lazily — only the ones a query references are materialized, so a
62
+ query that never touches `player_stats` (~400k rows) doesn't pay for it.
63
+ Register your own DataFrame (a roster, a league export) to join against the
64
+ model tables:
65
+
66
+ ```python
67
+ sh.register("my_roster", roster_df)
68
+ sh.query("SELECT * FROM career_2026 c JOIN my_roster r USING (player_key)")
69
+ ```
70
+
71
+ ## Polars
72
+
73
+ Loaders return pandas. With the `polars` extra, convert any of them — handy on
74
+ the big tables like `load_player_stats` (~400k rows) where polars is faster.
75
+
76
+ ```bash
77
+ pip install "stathead[polars]"
78
+ ```
79
+
80
+ ```python
81
+ import stathead as sh
82
+
83
+ pl_df = sh.to_polars(sh.load_player_stats(2024))
84
+
85
+ # Or convert a loader by reference, without calling it yourself:
86
+ pl_df = sh.load_polars(sh.load_player_stats, 2024)
87
+ ```
88
+
37
89
  ## Pinning to a specific version
38
90
 
39
91
  Loaders resolve against the upstream GitHub repo. Pin to a commit SHA, tag,
@@ -57,16 +109,60 @@ cache directory or call `clear_cache()` to force a refresh.
57
109
 
58
110
  ## Available loaders
59
111
 
112
+ Every table on the [StatHead site](https://github.com/dachhack/stathead) is
113
+ available here as a pandas DataFrame.
114
+
115
+ **Predictions & prospects**
116
+
60
117
  | Function | Returns | Shape |
61
118
  |---|---|---|
62
119
  | `load_career_predictions_2026()` | 2026 rookie predictions | ~77 × ~80 cols |
63
120
  | `load_career_backtest()` | Historical rookies with pred + actual PPG | ~1087 × ~100 cols |
121
+ | `load_prospect_grades(year=2026)` | Scouting-report grades | ~200 × 7 |
122
+ | `load_career_2027()` | 2027 draft-class early board + college aggregates | ~200 × ~30 |
123
+
124
+ **Projections (model outputs)**
125
+
126
+ | Function | Returns | Shape |
127
+ |---|---|---|
128
+ | `load_redraft_projections()` | Seasonal redraft PPG (PPR) + receptions/game | ~250 × 7 |
129
+ | `load_ppg_projections()` | Model-predicted PPG for established players | ~250 × 4 |
130
+ | `load_adp_value_model()` | VOR vs ADP, hit probability, confidence interval | ~153 × 10 |
131
+ | `load_volume_projections()` | Team pass/rush/target volumes with low/high bands | ~153 × ~14 |
132
+ | `load_share_projections()` | Predicted target + rush share | ~153 × 6 |
133
+ | `load_taxi_predictions()` | Taxi-squad roster/drop probabilities (+ `df.attrs['meta']`) | ~96 × 6 |
134
+
135
+ **Market & stats**
136
+
137
+ | Function | Returns | Shape |
138
+ |---|---|---|
139
+ | `load_player_stats(season=None)` | Per-player per-week NFL box scores 2010-present | ~400k × ~50 |
140
+ | `load_dynasty_values()` | In-house blended dynasty value (1QB + Superflex) | ~500 × 10 |
141
+ | `load_dynasty_value_history()` | Blended daily dynasty value history | variable |
64
142
  | `load_adp_historical()` | Model-training ADP 2010-2025 | 4507 × 10 |
65
143
  | `load_adp_ffc(season=None)` | FFC PPR raw ADP (per season as fetched) — data via [Fantasy Football Calculator](https://fantasyfootballcalculator.com/adp/ppr) | variable |
66
- | `load_prospect_grades(year=2026)` | Scouting-report grades | ~200 × 7 |
144
+
145
+ **Identity & raw**
146
+
147
+ | Function | Returns | Shape |
148
+ |---|---|---|
149
+ | `load_player_crosswalk()` | Canonical cross-source player IDs | ~10k × ~20 |
150
+ | `resolve_player(name, position=None)` / `get_player(key)` / `load_player_profile(key)` | Name → `player_key` helpers | — |
67
151
  | `load_feature_matrix()` | Raw `feature-matrix.json` (dict) | — |
68
152
  | `load_manual_overrides()` | Manual CFBD usage overrides (dict) | — |
69
153
 
154
+ Every row-shaped loader carries a `player_key` column that joins to
155
+ `load_player_crosswalk()`.
156
+
157
+ ### Dynasty values are the app's in-house blend
158
+
159
+ `load_dynasty_values()` returns the same blended value the web app displays:
160
+ KTC rankings rescaled into FantasyCalc's scale via a per-player ratio
161
+ (`fc_value / ktc_value`, with a positional-median fallback below a value
162
+ floor). The ratio snapshot is built offline and committed as
163
+ `public/data/dynasty-fc-rescale.json`; the loader just applies it. No raw KTC
164
+ value is exposed — only the rescaled blend.
165
+
70
166
  ## Feature columns
71
167
 
72
168
  Career-prediction and backtest rows include flattened model features
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stathead"
7
- version = "0.2.1"
7
+ version = "0.3.1"
8
8
  description = "Python client for the StatHead fantasy football model — rookie career predictions, historical ADP, and flattened feature matrices."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -24,11 +24,24 @@ from .crosswalk import (
24
24
  load_player_profile,
25
25
  resolve_player,
26
26
  )
27
+ from .dynasty import load_dynasty_value_history, load_dynasty_values
27
28
  from .features import load_feature_matrix, load_manual_overrides
28
29
  from .predictions import load_career_backtest, load_career_predictions_2026
30
+ from .projections import (
31
+ load_adp_value_model,
32
+ load_career_2027,
33
+ load_ppg_projections,
34
+ load_redraft_projections,
35
+ load_share_projections,
36
+ load_taxi_predictions,
37
+ load_volume_projections,
38
+ )
39
+ from .polars import load_polars, to_polars
29
40
  from .prospects import load_prospect_grades
41
+ from .sql import list_tables, query, register
42
+ from .stats import load_player_stats
30
43
 
31
- __version__ = "0.2.1"
44
+ __version__ = "0.3.1"
32
45
 
33
46
  __all__ = [
34
47
  "__version__",
@@ -37,13 +50,28 @@ __all__ = [
37
50
  "set_ref",
38
51
  "load_adp_ffc",
39
52
  "load_adp_historical",
53
+ "load_adp_value_model",
54
+ "load_career_2027",
40
55
  "load_career_backtest",
41
56
  "load_career_predictions_2026",
57
+ "load_dynasty_value_history",
58
+ "load_dynasty_values",
42
59
  "load_feature_matrix",
43
60
  "load_manual_overrides",
44
61
  "load_player_crosswalk",
45
62
  "load_player_profile",
63
+ "load_player_stats",
64
+ "load_ppg_projections",
46
65
  "load_prospect_grades",
66
+ "load_redraft_projections",
67
+ "load_share_projections",
68
+ "load_taxi_predictions",
69
+ "load_volume_projections",
47
70
  "get_player",
48
71
  "resolve_player",
72
+ "query",
73
+ "register",
74
+ "list_tables",
75
+ "to_polars",
76
+ "load_polars",
49
77
  ]
@@ -72,6 +72,42 @@ def _crosswalk_cached() -> list[dict[str, Any]]:
72
72
  return fetch_json("public/data/player-crosswalk.json").get("players") or []
73
73
 
74
74
 
75
+ @lru_cache(maxsize=1)
76
+ def key_by_name_pos() -> dict[tuple[str, str], str]:
77
+ """``(_norm(name), position) -> player_key`` for every name a player has
78
+ gone by (display name, ``all_names``, aliases) crossed with every
79
+ position they've held. First writer wins so the canonical record is
80
+ preferred over an alias. Used by loaders that only carry name+position
81
+ (dynasty blend, projections) to stamp the canonical ``player_key``."""
82
+ out: dict[tuple[str, str], str] = {}
83
+ for rec in _crosswalk_cached():
84
+ key = rec.get("player_key")
85
+ if not key:
86
+ continue
87
+ names = {rec.get("display_name", "")}
88
+ names.update(rec.get("all_names") or [])
89
+ names.update(a.get("name", "") for a in rec.get("aliases") or [])
90
+ positions = set(rec.get("all_positions") or []) | {rec.get("position") or ""}
91
+ for nm in names:
92
+ n = _norm(nm)
93
+ if not n:
94
+ continue
95
+ for pos in positions:
96
+ out.setdefault((n, pos), key)
97
+ return out
98
+
99
+
100
+ @lru_cache(maxsize=1)
101
+ def key_by_gsis() -> dict[str, str]:
102
+ """``gsis_id -> player_key``. Used to stamp the canonical key onto
103
+ weekly ``player_stats`` rows (whose ``player_id`` is the GSIS ID)."""
104
+ return {
105
+ str(rec["gsis_id"]): rec["player_key"]
106
+ for rec in _crosswalk_cached()
107
+ if rec.get("gsis_id") and rec.get("player_key")
108
+ }
109
+
110
+
75
111
  def resolve_player(name: str, position: str | None = None) -> str:
76
112
  """Return the canonical ``player_key`` for a player name.
77
113
 
@@ -0,0 +1,192 @@
1
+ """In-house dynasty value — the same blended value the web app shows.
2
+
3
+ The app is built on a KTC-trained forecast but displays every value on
4
+ FantasyCalc's scale. The bridge is a per-player ratio ``fc_value /
5
+ ktc_value`` (with a positional-median fallback for players below a value
6
+ floor or missing from FantasyCalc), precomputed offline into
7
+ ``public/data/dynasty-fc-rescale.json`` by ``scripts/build-rescale-snapshot.cjs``.
8
+
9
+ These loaders apply that existing snapshot to the KTC values — they do not
10
+ invent a new blend. The output is the canonical "blended dynasty value"
11
+ (KTC rescaled to FC scale); no raw KTC value is exposed.
12
+
13
+ Mirrors ``src/lib/valueRescale.ts``. Keep in sync.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from statistics import median
18
+ from typing import Any
19
+
20
+ import pandas as pd
21
+
22
+ from ._fetch import fetch_json
23
+ from .crosswalk import _norm, key_by_name_pos
24
+
25
+ _SUPPORTED_POS = ("QB", "RB", "WR", "TE")
26
+ # KTC values below this fall back to the positional-median ratio (mirrors
27
+ # RESCALE_FLOOR in valueRescale.ts).
28
+ _RESCALE_FLOOR = 500
29
+
30
+
31
+ class _Rescaler:
32
+ """Resolve and apply per-player / positional KTC->FC ratios."""
33
+
34
+ def __init__(self, snap: dict[str, Any]):
35
+ self._floor = snap.get("floor", _RESCALE_FLOOR)
36
+ self._per_player: dict[int, dict[str, float]] = {
37
+ int(pid): v for pid, v in (snap.get("perPlayer") or {}).items()
38
+ }
39
+ self._positional: dict[str, dict[str, float]] = snap.get("positional") or {}
40
+
41
+ def ratio(self, player_id: int, ktc_value: float, position: str,
42
+ fmt: str) -> float | None:
43
+ if position not in _SUPPORTED_POS:
44
+ return None
45
+ key = "oneQB" if fmt == "1qb" else "sf"
46
+ player = self._per_player.get(player_id)
47
+ if player and player.get(key) is not None and ktc_value >= self._floor:
48
+ return player[key]
49
+ return (self._positional.get(position) or {}).get(key)
50
+
51
+ def value(self, player_id: int, ktc_value: float, position: str,
52
+ fmt: str) -> float:
53
+ r = self.ratio(player_id, ktc_value, position, fmt)
54
+ return ktc_value if r is None else round(ktc_value * r)
55
+
56
+
57
+ def _load_rescaler() -> _Rescaler:
58
+ """Load the committed snapshot; if absent, derive one from the current
59
+ KTC + FantasyCalc snapshots (mirrors buildRescaleSnapshot)."""
60
+ try:
61
+ return _Rescaler(fetch_json("public/data/dynasty-fc-rescale.json"))
62
+ except Exception:
63
+ return _Rescaler(_build_snapshot())
64
+
65
+
66
+ def _build_snapshot() -> dict[str, Any]:
67
+ """Fallback: build the KTC->FC ratio snapshot from live values, exactly
68
+ as scripts/build-rescale-snapshot.cjs does."""
69
+ ktc = fetch_json("public/data/ktc_rankings_1qb.json") or []
70
+ fc_1qb = fetch_json("public/data/fantasycalc_dynasty_1qb.json") or []
71
+ fc_sf = fetch_json("public/data/fantasycalc_dynasty_sf.json") or []
72
+
73
+ def fc_map(src: list[dict]) -> dict[tuple[str, str], float]:
74
+ out: dict[tuple[str, str], float] = {}
75
+ for r in src:
76
+ p = r.get("player") or {}
77
+ out[(_norm(p.get("name", "")), p.get("position", ""))] = r.get("value")
78
+ return out
79
+
80
+ fc1q, fcsf = fc_map(fc_1qb), fc_map(fc_sf)
81
+ per_player: dict[str, dict[str, float]] = {}
82
+ samples: dict[str, dict[str, list[float]]] = {
83
+ pos: {"oneQB": [], "sf": []} for pos in _SUPPORTED_POS
84
+ }
85
+ for k in ktc:
86
+ pos = k.get("position", "")
87
+ if pos not in _SUPPORTED_POS:
88
+ continue
89
+ key = (_norm(k.get("playerName", "")), pos)
90
+ entry: dict[str, float] = {}
91
+ fc1 = fc1q.get(key)
92
+ fcs = fcsf.get(key)
93
+ if fc1 is not None and (k.get("value") or 0) >= _RESCALE_FLOOR:
94
+ entry["oneQB"] = fc1 / k["value"]
95
+ samples[pos]["oneQB"].append(entry["oneQB"])
96
+ if fcs is not None and (k.get("superflexValue") or 0) >= _RESCALE_FLOOR:
97
+ entry["sf"] = fcs / k["superflexValue"]
98
+ samples[pos]["sf"].append(entry["sf"])
99
+ if entry:
100
+ per_player[str(k["playerID"])] = entry
101
+
102
+ positional = {
103
+ pos: {
104
+ "oneQB": median(samples[pos]["oneQB"]) if samples[pos]["oneQB"] else 1,
105
+ "sf": median(samples[pos]["sf"]) if samples[pos]["sf"] else 1,
106
+ }
107
+ for pos in _SUPPORTED_POS
108
+ }
109
+ return {"floor": _RESCALE_FLOOR, "perPlayer": per_player, "positional": positional}
110
+
111
+
112
+ def load_dynasty_values() -> pd.DataFrame:
113
+ """Blended dynasty market value (1QB + Superflex) — the value the web
114
+ app displays.
115
+
116
+ KTC rankings rescaled into FantasyCalc's scale via the in-house
117
+ per-player ratio snapshot. Unsupported positions (K, picks) keep their
118
+ raw value; everything else is the rescaled blend.
119
+
120
+ Columns: ``player_key``, ``name``, ``position``, ``team``, ``age``,
121
+ ``value_1qb``, ``value_superflex``, ``positionRank``, ``isRookie``.
122
+ Sorted by ``value_1qb`` desc.
123
+ """
124
+ ktc = fetch_json("public/data/ktc_rankings_1qb.json") or []
125
+ rescaler = _load_rescaler()
126
+ keymap = key_by_name_pos()
127
+
128
+ rows: list[dict[str, Any]] = []
129
+ for r in ktc:
130
+ pid, pos = r.get("playerID"), r.get("position", "")
131
+ name = r.get("playerName", "")
132
+ rows.append({
133
+ "player_key": keymap.get((_norm(name), pos)),
134
+ "name": name,
135
+ "position": pos,
136
+ "team": r.get("team"),
137
+ "age": r.get("age"),
138
+ "value_1qb": rescaler.value(pid, r.get("value") or 0, pos, "1qb"),
139
+ "value_superflex": rescaler.value(pid, r.get("superflexValue") or 0, pos, "superflex"),
140
+ "positionRank": r.get("positionRank"),
141
+ "isRookie": r.get("isRookie"),
142
+ })
143
+ df = pd.DataFrame(rows)
144
+ return df.sort_values("value_1qb", ascending=False,
145
+ na_position="last").reset_index(drop=True)
146
+
147
+
148
+ def load_dynasty_value_history() -> pd.DataFrame:
149
+ """Daily dynasty value history (1QB + Superflex), rescaled to FC scale.
150
+
151
+ Same per-player ratio as :func:`load_dynasty_values`, applied to KTC's
152
+ daily history. One row per (player, date) with both formats side-by-side.
153
+
154
+ Columns: ``player_key``, ``name``, ``position``, ``date``,
155
+ ``value_1qb``, ``value_superflex``. Sorted by ``name``, ``date``.
156
+ """
157
+ ktc = fetch_json("public/data/ktc_rankings_1qb.json") or []
158
+ history = fetch_json("public/data/ktc_history.json") or []
159
+ rescaler = _load_rescaler()
160
+ keymap = key_by_name_pos()
161
+
162
+ # KTC history is keyed by playerID; map it back to name/position/team.
163
+ info = {
164
+ r.get("playerID"): (r.get("playerName", ""), r.get("position", ""))
165
+ for r in ktc
166
+ }
167
+
168
+ rows: list[dict[str, Any]] = []
169
+ for h in history:
170
+ pid = h.get("playerID")
171
+ name, pos = info.get(pid, ("", ""))
172
+ if not name:
173
+ continue
174
+ key = keymap.get((_norm(name), pos))
175
+ sf_by_date = {
176
+ p.get("d"): p.get("v")
177
+ for p in (h.get("superflex") or {}).get("valueHistory") or []
178
+ }
179
+ for p in (h.get("oneQB") or {}).get("valueHistory") or []:
180
+ date, v1 = p.get("d"), p.get("v")
181
+ vsf = sf_by_date.get(date)
182
+ rows.append({
183
+ "player_key": key,
184
+ "name": name,
185
+ "position": pos,
186
+ "date": date,
187
+ "value_1qb": rescaler.value(pid, v1 or 0, pos, "1qb"),
188
+ "value_superflex": (rescaler.value(pid, vsf, pos, "superflex")
189
+ if vsf is not None else None),
190
+ })
191
+ df = pd.DataFrame(rows)
192
+ return df.sort_values(["name", "date"]).reset_index(drop=True)
@@ -0,0 +1,65 @@
1
+ """Polars interop — convert loader output to polars DataFrames.
2
+
3
+ Loaders return pandas (that's the package's lingua franca); this is a thin
4
+ bridge for callers who prefer polars, especially on the big tables like
5
+ ``load_player_stats`` (~400k rows) where polars is noticeably faster.
6
+
7
+ import stathead as sh
8
+
9
+ pl_df = sh.to_polars(sh.load_player_stats(2024))
10
+
11
+ # Or convert a loader by reference, without calling it yourself:
12
+ pl_df = sh.load_polars(sh.load_player_stats, 2024)
13
+
14
+ Requires the ``polars`` extra::
15
+
16
+ pip install "stathead[polars]"
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Any, Callable
21
+
22
+ if TYPE_CHECKING:
23
+ import pandas as pd
24
+ import polars as pl
25
+
26
+
27
+ def _polars() -> Any:
28
+ try:
29
+ import polars # noqa: PLC0415
30
+ except ImportError as e: # pragma: no cover - exercised via message
31
+ raise ImportError(
32
+ "stathead.to_polars() needs polars. Install it with:\n"
33
+ ' pip install "stathead[polars]"'
34
+ ) from e
35
+ return polars
36
+
37
+
38
+ def to_polars(df: "pd.DataFrame") -> "pl.DataFrame":
39
+ """Convert a pandas DataFrame (any loader's output) to a polars DataFrame.
40
+
41
+ >>> import stathead as sh
42
+ >>> sh.to_polars(sh.load_dynasty_values()).head()
43
+ """
44
+ pl = _polars()
45
+ try:
46
+ return pl.from_pandas(df)
47
+ except ImportError:
48
+ # from_pandas needs pyarrow for object / extension-dtype columns
49
+ # (names, positions). Keep the polars extra light by falling back to a
50
+ # column-dict construction. NaN -> None first, or polars chokes on a
51
+ # str column with a float NaN mixed in.
52
+ clean = df.astype(object).where(df.notna(), None)
53
+ return pl.DataFrame({c: clean[c].to_list() for c in clean.columns})
54
+
55
+
56
+ def load_polars(loader: Callable[..., "pd.DataFrame"], *args: Any, **kwargs: Any) -> "pl.DataFrame":
57
+ """Call a loader and return its result as a polars DataFrame.
58
+
59
+ Sugar for ``to_polars(loader(*args, **kwargs))`` so you don't repeat the
60
+ DataFrame:
61
+
62
+ >>> import stathead as sh
63
+ >>> sh.load_polars(sh.load_player_stats, 2024)
64
+ """
65
+ return to_polars(loader(*args, **kwargs))
@@ -0,0 +1,116 @@
1
+ """Model projection outputs — the same numbers the web app's projection,
2
+ ADP-value, volume, and taxi-squad tabs render.
3
+
4
+ All of these are StatHead's own model outputs (scored from the feature
5
+ store), so there's no third-party redistribution concern. Each loader
6
+ stamps a canonical ``player_key`` where a name + position is available.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import pandas as pd
11
+
12
+ from ._fetch import fetch_json
13
+ from .crosswalk import _norm, key_by_name_pos
14
+
15
+
16
+ def _stamp_keys(df: pd.DataFrame, name_col: str = "name",
17
+ pos_col: str = "position") -> pd.DataFrame:
18
+ """Add a leading ``player_key`` column resolved from (name, position)."""
19
+ if name_col not in df.columns or pos_col not in df.columns:
20
+ return df
21
+ keymap = key_by_name_pos()
22
+ keys = [
23
+ keymap.get((_norm(str(n)), str(p)))
24
+ for n, p in zip(df[name_col], df[pos_col])
25
+ ]
26
+ df.insert(0, "player_key", keys)
27
+ return df
28
+
29
+
30
+ def load_redraft_projections() -> pd.DataFrame:
31
+ """Seasonal redraft projections — projected PPG (PPR) + receptions/game.
32
+
33
+ Veterans blend prior-season actuals, a 2-year average, and an age curve;
34
+ rookies use the career model's best-2-of-3 PPG with a per-position,
35
+ pick-based Year-1 discount. ``recPG`` (receptions/game) supports TE-premium
36
+ scoring.
37
+
38
+ Columns: ``player_key``, ``name``, ``position``, ``ppg``, ``recPG``,
39
+ ``season``, ``scoring``.
40
+ """
41
+ data = fetch_json("public/data/redraft-projections.json")
42
+ df = pd.DataFrame(data.get("players") or [])
43
+ df["season"] = data.get("season")
44
+ df["scoring"] = data.get("scoring")
45
+ return _stamp_keys(df)
46
+
47
+
48
+ def load_ppg_projections() -> pd.DataFrame:
49
+ """Model-predicted points-per-game for established players.
50
+
51
+ Columns: ``player_key``, ``name``, ``position``, ``predictedPPG``.
52
+ """
53
+ df = pd.DataFrame(fetch_json("public/data/score-store/ppg.json"))
54
+ return _stamp_keys(df)
55
+
56
+
57
+ def load_adp_value_model() -> pd.DataFrame:
58
+ """ADP value model — predicted value-over-replacement vs market ADP,
59
+ with a calibrated hit probability and confidence interval.
60
+
61
+ Columns: ``player_key``, ``name``, ``position``, ``team``, ``adp``,
62
+ ``predictedVor``, ``hitProb`` (label), ``ciLower``, ``ciUpper``,
63
+ ``isRookie``.
64
+ """
65
+ df = pd.DataFrame(fetch_json("public/data/score-store/adp.json"))
66
+ df = df.drop(columns=[c for c in ("headshotUrl",) if c in df.columns])
67
+ return _stamp_keys(df)
68
+
69
+
70
+ def load_volume_projections() -> pd.DataFrame:
71
+ """Team + player volume projections with low/high bands.
72
+
73
+ Columns: ``player_key``, ``name``, ``position``, ``team``,
74
+ ``teamPassAtt`` (+ ``Low`` / ``High``), ``teamRushAtt`` (+ bands),
75
+ ``teamTargets`` (+ bands), ``projPlayerPPG``.
76
+ """
77
+ df = pd.DataFrame(fetch_json("public/data/score-store/volumes.json"))
78
+ return _stamp_keys(df)
79
+
80
+
81
+ def load_share_projections() -> pd.DataFrame:
82
+ """Predicted target share + rush share for each player.
83
+
84
+ Columns: ``player_key``, ``name``, ``position``, ``team``,
85
+ ``predTargetShare``, ``predRushShare``.
86
+ """
87
+ df = pd.DataFrame(fetch_json("public/data/score-store/shares.json"))
88
+ return _stamp_keys(df)
89
+
90
+
91
+ def load_taxi_predictions() -> pd.DataFrame:
92
+ """Taxi-squad model — probability a young player makes / sticks on a
93
+ fantasy roster.
94
+
95
+ Columns: ``player_key``, ``name``, ``position``, ``p1`` (year-1 roster
96
+ probability), ``p2`` (year-2), ``pEver`` (ever rostered). Model metadata
97
+ (training date, thresholds, LOSO AUC) is attached on
98
+ ``df.attrs['meta']``.
99
+ """
100
+ data = fetch_json("public/data/score-store/taxi.json")
101
+ df = pd.DataFrame(data.get("players") or [])
102
+ df = _stamp_keys(df)
103
+ df.attrs["meta"] = data.get("meta") or {}
104
+ return df
105
+
106
+
107
+ def load_career_2027() -> pd.DataFrame:
108
+ """2027 draft-class early prospect board — grades + college aggregates.
109
+
110
+ The next class after :func:`~stathead.load_prospect_grades`. Columns
111
+ include ``name``, ``pos``, ``school``, ``grade``, ``projPick``,
112
+ ``projRound``, ``tier``, plus college career box-score totals
113
+ (``careerPassYds``, ``careerRushYds``, ``careerRecYds``, …).
114
+ """
115
+ df = pd.DataFrame(fetch_json("public/data/career-2027.json"))
116
+ return _stamp_keys(df, pos_col="pos")
@@ -0,0 +1,162 @@
1
+ """Local SQL over the loaders, via DuckDB.
2
+
3
+ ``sh.query(sql)`` registers each loader as a DuckDB table and runs SQL
4
+ against it — the same surface as the web app's Data Query tab, in Python.
5
+ Tables join on ``player_key``.
6
+
7
+ import stathead as sh
8
+
9
+ sh.query('''
10
+ SELECT c.name, c.predictedCareerPPG, d.value_1qb
11
+ FROM career_2026 c
12
+ JOIN dynasty_values d USING (player_key)
13
+ WHERE c.percentile >= 80
14
+ ORDER BY d.value_1qb DESC
15
+ ''')
16
+
17
+ Requires the ``duckdb`` extra::
18
+
19
+ pip install "stathead[duckdb]"
20
+
21
+ Tables are loaded lazily — only those a query references are materialized,
22
+ so a query that never touches ``player_stats`` (~400k rows) doesn't pay for
23
+ it. Loaded tables are cached for the process and keyed to the pinned ref;
24
+ calling :func:`~stathead.pin_version` resets the cache.
25
+
26
+ Register your own DataFrame (a roster, a league export) to join against the
27
+ model tables::
28
+
29
+ sh.register("my_roster", roster_df)
30
+ sh.query("SELECT * FROM career_2026 c JOIN my_roster r USING (player_key)")
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from typing import TYPE_CHECKING, Any, Callable
36
+
37
+ from . import _fetch
38
+ from .adp import load_adp_ffc, load_adp_historical
39
+ from .crosswalk import load_player_crosswalk
40
+ from .dynasty import load_dynasty_value_history, load_dynasty_values
41
+ from .predictions import load_career_backtest, load_career_predictions_2026
42
+ from .projections import (
43
+ load_adp_value_model,
44
+ load_career_2027,
45
+ load_ppg_projections,
46
+ load_redraft_projections,
47
+ load_share_projections,
48
+ load_taxi_predictions,
49
+ load_volume_projections,
50
+ )
51
+ from .prospects import load_prospect_grades
52
+ from .stats import load_player_stats
53
+
54
+ if TYPE_CHECKING:
55
+ import pandas as pd
56
+
57
+ # Table name -> loader. Names mirror the web app's Data Query tab where they
58
+ # overlap, with the projection loaders added.
59
+ _TABLES: dict[str, Callable[[], "pd.DataFrame"]] = {
60
+ "player_crosswalk": load_player_crosswalk,
61
+ "career_2026": load_career_predictions_2026,
62
+ "career_2027": load_career_2027,
63
+ "backtest": load_career_backtest,
64
+ "prospects": load_prospect_grades,
65
+ "player_stats": load_player_stats,
66
+ "adp_historical": load_adp_historical,
67
+ "adp_ffc": load_adp_ffc,
68
+ "adp_value_model": load_adp_value_model,
69
+ "redraft_projections": load_redraft_projections,
70
+ "ppg_projections": load_ppg_projections,
71
+ "volume_projections": load_volume_projections,
72
+ "share_projections": load_share_projections,
73
+ "taxi_predictions": load_taxi_predictions,
74
+ "dynasty_values": load_dynasty_values,
75
+ "dynasty_value_history": load_dynasty_value_history,
76
+ }
77
+
78
+ # Lazily-built per-process state, keyed to the pinned ref so a pin_version()
79
+ # call transparently invalidates it.
80
+ _conn: Any = None
81
+ _conn_ref: str | None = None
82
+ _registered: set[str] = set()
83
+ # DataFrames must stay referenced for the lifetime of the DuckDB view.
84
+ _frames: dict[str, "pd.DataFrame"] = {}
85
+ _user_tables: dict[str, "pd.DataFrame"] = {}
86
+
87
+ # Matches bare identifiers so we can spot which known tables a query names.
88
+ _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
89
+
90
+
91
+ def _duckdb() -> Any:
92
+ try:
93
+ import duckdb # noqa: PLC0415
94
+ except ImportError as e: # pragma: no cover - exercised via message
95
+ raise ImportError(
96
+ "stathead.query() needs DuckDB. Install it with:\n"
97
+ ' pip install "stathead[duckdb]"'
98
+ ) from e
99
+ return duckdb
100
+
101
+
102
+ def _connection() -> Any:
103
+ """Return the cached connection, rebuilding if the ref changed."""
104
+ global _conn, _conn_ref
105
+ if _conn is None or _conn_ref != _fetch.current_ref():
106
+ _conn = _duckdb().connect()
107
+ _conn_ref = _fetch.current_ref()
108
+ _registered.clear()
109
+ _frames.clear()
110
+ # Re-register any user tables onto the fresh connection.
111
+ for name, df in _user_tables.items():
112
+ _conn.register(name, df)
113
+ return _conn
114
+
115
+
116
+ def _ensure_registered(conn: Any, names: set[str]) -> None:
117
+ for name in names:
118
+ if name in _registered or name in _user_tables:
119
+ continue
120
+ loader = _TABLES.get(name)
121
+ if loader is None:
122
+ continue
123
+ df = loader()
124
+ _frames[name] = df # keep alive
125
+ conn.register(name, df)
126
+ _registered.add(name)
127
+
128
+
129
+ def _referenced_tables(sql: str) -> set[str]:
130
+ """Known table names that appear as identifiers in the query. Loading a
131
+ superset is harmless (an unreferenced table just goes unused), so a
132
+ cheap token scan beats a real SQL parse."""
133
+ tokens = {t.lower() for t in _IDENT_RE.findall(sql)}
134
+ return {name for name in _TABLES if name in tokens}
135
+
136
+
137
+ def register(name: str, df: "pd.DataFrame") -> None:
138
+ """Register a user DataFrame as a queryable table.
139
+
140
+ Persists across :func:`query` calls (and across a ref change). Pass the
141
+ same name again to replace it.
142
+ """
143
+ _user_tables[name] = df
144
+ if _conn is not None:
145
+ _conn.register(name, df)
146
+
147
+
148
+ def list_tables() -> list[str]:
149
+ """Names available to :func:`query` — built-in loaders plus any tables
150
+ added via :func:`register`."""
151
+ return sorted(set(_TABLES) | set(_user_tables))
152
+
153
+
154
+ def query(sql: str) -> "pd.DataFrame":
155
+ """Run SQL over the loader tables and return a pandas DataFrame.
156
+
157
+ Only the tables referenced by the query are loaded (and then cached).
158
+ See the module docstring for the available tables and examples.
159
+ """
160
+ conn = _connection()
161
+ _ensure_registered(conn, _referenced_tables(sql))
162
+ return conn.execute(sql).df()
@@ -0,0 +1,56 @@
1
+ """Weekly NFL player stats — per-player per-week box scores, 2010-present."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+
6
+ import pandas as pd
7
+
8
+ from ._fetch import fetch_csv_gz
9
+ from .crosswalk import key_by_gsis
10
+
11
+ # nflverse weekly stats ship one gzipped CSV per season. Coverage grows as
12
+ # the repo's refresh workflow commits the current year, so we probe a
13
+ # generous range and skip seasons not present in the pinned ref.
14
+ _SEASONS = tuple(range(2010, 2027))
15
+
16
+
17
+ def load_player_stats(season: int | None = None) -> pd.DataFrame:
18
+ """Per-player per-week NFL stats (regular + postseason).
19
+
20
+ One row per (player, season, week). Includes passing / rushing /
21
+ receiving box-score columns plus ``fantasy_points`` and
22
+ ``fantasy_points_ppr``, and a canonical ``player_key`` stamped from the
23
+ crosswalk so this joins cleanly to every other loader.
24
+
25
+ Pass a ``season`` to load a single year, or leave ``None`` to load every
26
+ season available in the pinned ref (2010-present, ~400k rows).
27
+
28
+ The upstream schema drifted in 2025 (``recent_team`` → ``team``,
29
+ ``interceptions`` → ``passing_interceptions``, ``sacks`` →
30
+ ``sacks_suffered``). Both spellings are kept side-by-side when present;
31
+ ``COALESCE`` / :meth:`~pandas.Series.combine_first` in your own code to
32
+ normalize across years.
33
+
34
+ Columns (non-exhaustive): ``player_key``, ``player_id``, ``player_name``,
35
+ ``player_display_name``, ``position``, ``recent_team`` / ``team``,
36
+ ``season``, ``week``, ``season_type``, ``opponent_team``,
37
+ ``passing_yards``, ``passing_tds``, ``rushing_yards``, ``rushing_tds``,
38
+ ``carries``, ``receptions``, ``targets``, ``receiving_yards``,
39
+ ``receiving_tds``, ``fantasy_points``, ``fantasy_points_ppr``.
40
+ """
41
+ seasons = [season] if season is not None else list(_SEASONS)
42
+ frames: list[pd.DataFrame] = []
43
+ for s in seasons:
44
+ try:
45
+ raw = fetch_csv_gz(f"public/data/player_stats_{s}.csv.gz")
46
+ except Exception:
47
+ continue
48
+ frames.append(pd.read_csv(io.BytesIO(raw), low_memory=False))
49
+ if not frames:
50
+ return pd.DataFrame()
51
+ # sort=False keeps first-seen column order; concat unions columns across
52
+ # the 2024/2025 schema drift (missing cells become NaN), mirroring the
53
+ # web app's UNION ALL BY NAME.
54
+ out = pd.concat(frames, ignore_index=True, sort=False)
55
+ out.insert(0, "player_key", out["player_id"].astype("string").map(key_by_gsis()))
56
+ return out
@@ -114,6 +114,164 @@ def test_manual_overrides_keyed_by_name_pos():
114
114
  assert any("|" in k for k in keys)
115
115
 
116
116
 
117
+ def test_player_stats_weekly_box_scores():
118
+ df = stathead.load_player_stats(2024)
119
+ assert not df.empty
120
+ assert {"player_key", "player_id", "season", "week",
121
+ "fantasy_points_ppr"}.issubset(df.columns)
122
+ assert (df.season == 2024).all()
123
+ # player_key should resolve for the vast majority of rows (every NFL
124
+ # player with a GSIS ID is in the crosswalk).
125
+ assert df.player_key.notna().mean() > 0.9
126
+
127
+
128
+ def test_redraft_projections():
129
+ df = stathead.load_redraft_projections()
130
+ assert not df.empty
131
+ assert {"player_key", "name", "position", "ppg", "recPG"}.issubset(df.columns)
132
+
133
+
134
+ def test_ppg_and_adp_value_model():
135
+ ppg = stathead.load_ppg_projections()
136
+ adp = stathead.load_adp_value_model()
137
+ assert {"name", "position", "predictedPPG"}.issubset(ppg.columns)
138
+ assert {"name", "position", "adp", "predictedVor", "hitProb"}.issubset(adp.columns)
139
+ # headshot URLs are stripped from the public surface.
140
+ assert "headshotUrl" not in adp.columns
141
+
142
+
143
+ def test_volume_and_share_projections():
144
+ vol = stathead.load_volume_projections()
145
+ shr = stathead.load_share_projections()
146
+ assert {"name", "teamPassAtt", "teamTargets", "projPlayerPPG"}.issubset(vol.columns)
147
+ assert {"name", "predTargetShare", "predRushShare"}.issubset(shr.columns)
148
+
149
+
150
+ def test_taxi_predictions_carry_meta():
151
+ df = stathead.load_taxi_predictions()
152
+ assert {"name", "position", "p1", "p2", "pEver"}.issubset(df.columns)
153
+ assert isinstance(df.attrs.get("meta"), dict)
154
+ assert "thresholds" in df.attrs["meta"]
155
+
156
+
157
+ def test_career_2027_class():
158
+ df = stathead.load_career_2027()
159
+ assert not df.empty
160
+ assert {"name", "pos", "grade"}.issubset(df.columns)
161
+
162
+
163
+ def test_dynasty_values_match_app_blend():
164
+ df = stathead.load_dynasty_values()
165
+ assert not df.empty
166
+ assert {"name", "position", "value_1qb", "value_superflex",
167
+ "positionRank", "isRookie"}.issubset(df.columns)
168
+ # Sorted by 1QB value, descending.
169
+ top = df.value_1qb.dropna().tolist()
170
+ assert top == sorted(top, reverse=True)
171
+ # Values are rescaled onto FantasyCalc's scale, so the top asset no
172
+ # longer sits at KTC's hard 9999 ceiling (it maps up into FC's range).
173
+ assert df.value_1qb.max() != 9999
174
+ assert df.value_1qb.min() >= 0
175
+
176
+
177
+ def test_dynasty_value_history_rescaled():
178
+ df = stathead.load_dynasty_value_history()
179
+ assert not df.empty
180
+ assert {"name", "position", "date", "value_1qb",
181
+ "value_superflex"}.issubset(df.columns)
182
+
183
+
184
+ def test_query_runs_a_join():
185
+ df = stathead.query("""
186
+ SELECT c.name, c.predictedCareerPPG, d.value_1qb
187
+ FROM career_2026 c
188
+ JOIN dynasty_values d USING (player_key)
189
+ WHERE c.percentile >= 80
190
+ ORDER BY d.value_1qb DESC
191
+ LIMIT 10
192
+ """)
193
+ assert not df.empty
194
+ assert list(df.columns) == ["name", "predictedCareerPPG", "value_1qb"]
195
+ # value_1qb is sorted descending.
196
+ vals = df.value_1qb.dropna().tolist()
197
+ assert vals == sorted(vals, reverse=True)
198
+
199
+
200
+ def test_list_tables_covers_core_loaders():
201
+ names = set(stathead.list_tables())
202
+ assert {"career_2026", "backtest", "player_stats", "dynasty_values",
203
+ "adp_historical", "prospects"}.issubset(names)
204
+
205
+
206
+ def test_register_user_table_and_join():
207
+ import pandas as pd
208
+ # Pick a real 2026-class name so the join is guaranteed to resolve.
209
+ rookie = stathead.load_career_predictions_2026().iloc[0]["name"]
210
+ roster = pd.DataFrame({"name": [rookie], "slot": ["pick"]})
211
+ stathead.register("my_roster", roster)
212
+ assert "my_roster" in stathead.list_tables()
213
+ df = stathead.query("""
214
+ SELECT c.name, r.slot, c.percentile
215
+ FROM career_2026 c JOIN my_roster r USING (name)
216
+ """)
217
+ assert (df.name == rookie).any()
218
+
219
+
220
+ def test_query_only_loads_referenced_tables():
221
+ from stathead import sql
222
+ # A query that names only career_2026 must not materialize player_stats.
223
+ sql._connection()
224
+ sql._registered.discard("player_stats")
225
+ stathead.query("SELECT COUNT(*) AS n FROM career_2026")
226
+ assert "player_stats" not in sql._registered
227
+
228
+
229
+ def test_query_without_duckdb_gives_install_hint(monkeypatch):
230
+ import builtins
231
+ from stathead import sql
232
+ real_import = builtins.__import__
233
+
234
+ def _no_duckdb(name, *args, **kwargs):
235
+ if name == "duckdb":
236
+ raise ImportError("No module named 'duckdb'")
237
+ return real_import(name, *args, **kwargs)
238
+
239
+ monkeypatch.setattr(builtins, "__import__", _no_duckdb)
240
+ with pytest.raises(ImportError, match=r"stathead\[duckdb\]"):
241
+ sql._duckdb()
242
+
243
+
244
+ def test_to_polars_roundtrip():
245
+ pl = pytest.importorskip("polars")
246
+ df = stathead.load_dynasty_values()
247
+ pdf = stathead.to_polars(df)
248
+ assert isinstance(pdf, pl.DataFrame)
249
+ assert pdf.height == len(df)
250
+ assert list(pdf.columns) == list(df.columns)
251
+
252
+
253
+ def test_load_polars_calls_loader():
254
+ pl = pytest.importorskip("polars")
255
+ pdf = stathead.load_polars(stathead.load_redraft_projections)
256
+ assert isinstance(pdf, pl.DataFrame)
257
+ assert "name" in pdf.columns
258
+
259
+
260
+ def test_to_polars_without_polars_gives_install_hint(monkeypatch):
261
+ import builtins
262
+ from stathead import polars as polars_mod
263
+ real_import = builtins.__import__
264
+
265
+ def _no_polars(name, *args, **kwargs):
266
+ if name == "polars":
267
+ raise ImportError("No module named 'polars'")
268
+ return real_import(name, *args, **kwargs)
269
+
270
+ monkeypatch.setattr(builtins, "__import__", _no_polars)
271
+ with pytest.raises(ImportError, match=r"stathead\[polars\]"):
272
+ polars_mod._polars()
273
+
274
+
117
275
  def test_no_vendor_named_columns_leak():
118
276
  """Source-agnostic feature naming — no rsp*/pdf* columns reach users."""
119
277
  pred = stathead.load_career_predictions_2026()
stathead-0.2.1/.gitignore DELETED
@@ -1,63 +0,0 @@
1
- node_modules
2
- dist
3
- .DS_Store
4
- *.local
5
- public/data/*
6
- !public/data/training-rows-cache-*.json
7
- !public/data/trained-models-cache-*.json
8
- !public/data/model-cache-*.json
9
- !public/data/feature-store/
10
- !public/data/score-store/
11
- !public/data/ktc_rankings_*.json
12
- !public/data/ktc_history.json
13
- !public/data/nflverse_weekly_2025.json
14
- !public/data/ppg-feature-ablation.json
15
- !public/data/ppg-hyperparam-sweep.json
16
- !public/data/residual-feature-ablation.json
17
- !public/data/residual-hyperparam-sweep.json
18
- !public/data/ktc-feature-ablation.json
19
- !public/data/ktc-hyperparam-sweep.json
20
- !public/data/pdf-career-ablation-predraft.json
21
- !public/data/pdf-career-ablation-postdraft.json
22
- !public/data/rsp-career-ablation-predraft.json
23
- !public/data/rsp-career-ablation-postdraft.json
24
- !public/data/pdf-only-career-test.json
25
- !public/data/qb-beast-ablation.json
26
- !public/data/ktc-forecasts-*.json
27
- !public/data/redraft-projections.json
28
- !public/data/depth-chart-2026.json
29
- !public/data/career-2027.json
30
- !public/data/cfbd-college-stats.json
31
- !public/data/cfbd-sp-ratings.json
32
- !public/data/cfbd-recruiting.json
33
- !public/data/cfbd-games.json
34
- !public/data/cfbd-team-talent.json
35
- !public/data/cfbd-player-usage.json
36
- !public/data/cfbd/
37
- !public/data/cfbd/*.json
38
- !public/data/pdf-prospect-features.json
39
- !public/data/pdf-prospect-features-merged.json
40
- !public/data/rsp-historical-rankings.json
41
- !public/data/rsp-qb-features.json
42
- !public/data/manual-cfbd-overrides.json
43
- !public/data/player-crosswalk.json
44
- !public/data/player-aliases.json
45
- !public/data/player-promotions.json
46
- # Raw upstream sources committed as .csv.gz (~35 MB vs ~200 MB raw).
47
- # TS readLocalFile auto-decompresses; Python runs `bash scripts/extract-data.sh`
48
- # once to materialize the .csv. See scripts/pull-all-data-sources.sh.
49
- !public/data/*.csv.gz
50
- !public/data/ffc_adp_ppr_*.json
51
- !public/data/espn_adp_*.json
52
- !public/data/fantasycalc_*.json
53
- !public/data/fantasycalc_history_*.json
54
- !public/data/ktc-fc-rescale.json
55
- .claude/*
56
- !.claude/commands/
57
- !.claude/commands/**
58
- CLAUDE.local.md
59
- __pycache__/
60
- .nflverse-cache/
61
- .cache/
62
- .venv/
63
- pdfs/
File without changes