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.
- stathead-0.3.1/.gitignore +118 -0
- {stathead-0.2.1 → stathead-0.3.1}/PKG-INFO +100 -4
- {stathead-0.2.1 → stathead-0.3.1}/README.md +99 -3
- {stathead-0.2.1 → stathead-0.3.1}/pyproject.toml +1 -1
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/__init__.py +29 -1
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/crosswalk.py +36 -0
- stathead-0.3.1/src/stathead/dynasty.py +192 -0
- stathead-0.3.1/src/stathead/polars.py +65 -0
- stathead-0.3.1/src/stathead/projections.py +116 -0
- stathead-0.3.1/src/stathead/sql.py +162 -0
- stathead-0.3.1/src/stathead/stats.py +56 -0
- {stathead-0.2.1 → stathead-0.3.1}/tests/test_smoke.py +158 -0
- stathead-0.2.1/.gitignore +0 -63
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/_fetch.py +0 -0
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/_renames.py +0 -0
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/adp.py +0 -0
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/features.py +0 -0
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/predictions.py +0 -0
- {stathead-0.2.1 → stathead-0.3.1}/src/stathead/prospects.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|