ffl-bigquery 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ffl_bigquery/__init__.py +3 -0
- ffl_bigquery/_schema_samples/__init__.py +54 -0
- ffl_bigquery/_schema_samples/ff_opportunity.parquet +0 -0
- ffl_bigquery/_schema_samples/ff_rankings.parquet +0 -0
- ffl_bigquery/_schema_samples/ftn_charting.parquet +0 -0
- ffl_bigquery/_schema_samples/injuries.parquet +0 -0
- ffl_bigquery/_schema_samples/participation.parquet +0 -0
- ffl_bigquery/_schema_samples/snap_counts.parquet +0 -0
- ffl_bigquery/_transform_util.py +18 -0
- ffl_bigquery/_version.py +1 -0
- ffl_bigquery/adp/__init__.py +0 -0
- ffl_bigquery/adp/ffc.py +66 -0
- ffl_bigquery/adp/mfl.py +81 -0
- ffl_bigquery/adp/resolve.py +132 -0
- ffl_bigquery/adp/schema.py +193 -0
- ffl_bigquery/adp/sync.py +175 -0
- ffl_bigquery/adp/transform.py +124 -0
- ffl_bigquery/cli.py +141 -0
- ffl_bigquery/coaches/__init__.py +0 -0
- ffl_bigquery/coaches/schema.py +63 -0
- ffl_bigquery/coaches/sync.py +23 -0
- ffl_bigquery/coaches/transform.py +43 -0
- ffl_bigquery/coordinators/__init__.py +0 -0
- ffl_bigquery/coordinators/schema.py +104 -0
- ffl_bigquery/coordinators/sync.py +121 -0
- ffl_bigquery/coordinators/wikipedia.py +160 -0
- ffl_bigquery/derive/__init__.py +1 -0
- ffl_bigquery/derive/personnel.py +122 -0
- ffl_bigquery/derive/points_weekly.py +139 -0
- ffl_bigquery/derive/scheme_week.py +469 -0
- ffl_bigquery/http.py +90 -0
- ffl_bigquery/nflverse/__init__.py +0 -0
- ffl_bigquery/nflverse/driver.py +161 -0
- ffl_bigquery/nflverse/runs.py +110 -0
- ffl_bigquery/nflverse/spec.py +25 -0
- ffl_bigquery/nflverse/tables/__init__.py +62 -0
- ffl_bigquery/nflverse/tables/depth_charts.py +179 -0
- ffl_bigquery/nflverse/tables/ftn_charting.py +92 -0
- ffl_bigquery/nflverse/tables/injuries.py +54 -0
- ffl_bigquery/nflverse/tables/opportunity.py +50 -0
- ffl_bigquery/nflverse/tables/participation.py +131 -0
- ffl_bigquery/nflverse/tables/rankings.py +54 -0
- ffl_bigquery/nflverse/tables/snap_counts.py +44 -0
- ffl_bigquery/partition.py +40 -0
- ffl_bigquery/runs.py +135 -0
- ffl_bigquery/schema.py +68 -0
- ffl_bigquery/schema_gen.py +86 -0
- ffl_bigquery/verify/__init__.py +60 -0
- ffl_bigquery/verify/adp.py +138 -0
- ffl_bigquery/verify/tables.py +220 -0
- ffl_bigquery/writer.py +271 -0
- ffl_bigquery/xref/__init__.py +0 -0
- ffl_bigquery/xref/schema.py +100 -0
- ffl_bigquery/xref/sync.py +39 -0
- ffl_bigquery/xref/transform.py +15 -0
- ffl_bigquery-0.1.0.dist-info/METADATA +264 -0
- ffl_bigquery-0.1.0.dist-info/RECORD +60 -0
- ffl_bigquery-0.1.0.dist-info/WHEEL +4 -0
- ffl_bigquery-0.1.0.dist-info/entry_points.txt +2 -0
- ffl_bigquery-0.1.0.dist-info/licenses/LICENSE +21 -0
ffl_bigquery/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Parquet schema samples shipped inside the wheel.
|
|
2
|
+
|
|
3
|
+
Six of the season-chunked nflverse tables (ff_opportunity, snap_counts,
|
|
4
|
+
injuries, ftn_charting, participation) plus ff_rankings build their BigQuery
|
|
5
|
+
schema at import time by sampling a real upstream frame's dtypes
|
|
6
|
+
(`schema_gen.specs_from_frame`). That sample has to be readable from an
|
|
7
|
+
*installed* package, not just a repo checkout: the previous approach --
|
|
8
|
+
`Path(__file__).resolve().parents[3] / "tests/fixtures/nflverse/<x>.parquet"`
|
|
9
|
+
-- resolves to the repo root only when `ffl_bigquery/` sits inside a checkout
|
|
10
|
+
three directories below that fixture. Once pip unpacks the wheel into
|
|
11
|
+
`site-packages/`, `parents[3]` lands outside site-packages entirely (`tests/`
|
|
12
|
+
isn't even shipped), so every one of those six modules raised
|
|
13
|
+
`FileNotFoundError` at import time -- and because `load_all_specs()` imports
|
|
14
|
+
all of them eagerly, that took down `sync-nflverse` for every `--tables`
|
|
15
|
+
value, including `--dry-run`.
|
|
16
|
+
|
|
17
|
+
These parquet files (~230 KB total) are the fix: they live inside the
|
|
18
|
+
installed package itself and are located via `importlib.resources`, which
|
|
19
|
+
resolves correctly in a repo checkout, an editable install, and a built
|
|
20
|
+
wheel alike (`pyproject.toml` force-includes them into the wheel since
|
|
21
|
+
hatchling does not pick up non-Python files under `packages=` by default).
|
|
22
|
+
|
|
23
|
+
The same files also back the fidelity tests in
|
|
24
|
+
`tests/test_play_level_tables.py` and `tests/test_nflverse_tables_simple.py`,
|
|
25
|
+
via this same `read_sample`/`sample_path` helpers, so there is exactly one
|
|
26
|
+
copy of each parquet on disk -- not a package copy plus a test copy.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
from importlib import resources
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextmanager
|
|
39
|
+
def sample_path(name: str) -> Iterator[Path]:
|
|
40
|
+
"""Yield a real on-disk Path to `<name>.parquet`.
|
|
41
|
+
|
|
42
|
+
`importlib.resources.as_file` (not a bare `Path(__file__)`) is required
|
|
43
|
+
because a zipped/compressed install has no on-disk file to hand back
|
|
44
|
+
directly -- `as_file` materializes one (a temp copy, if needed) for the
|
|
45
|
+
duration of the `with` block.
|
|
46
|
+
"""
|
|
47
|
+
with resources.as_file(resources.files(__name__) / f"{name}.parquet") as p:
|
|
48
|
+
yield p
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_sample(name: str) -> pd.DataFrame:
|
|
52
|
+
"""Read `<name>.parquet` from this package's bundled schema samples."""
|
|
53
|
+
with sample_path(name) as p:
|
|
54
|
+
return pd.read_parquet(p)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared transform helper: reindex to schema columns + stamp ingested_at."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from ffl_bigquery.schema import ColumnSpec, spec_names
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def align_to_schema(
|
|
12
|
+
df: pd.DataFrame, schema: list[ColumnSpec], *, stamp: bool = True
|
|
13
|
+
) -> pd.DataFrame:
|
|
14
|
+
df = df.copy()
|
|
15
|
+
if stamp:
|
|
16
|
+
df["ingested_at"] = datetime.now(UTC)
|
|
17
|
+
# Reindex adds missing NULLABLE columns as NA and drops columns not in schema.
|
|
18
|
+
return df.reindex(columns=spec_names(schema))
|
ffl_bigquery/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
ffl_bigquery/adp/ffc.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Fantasy Football Calculator ADP fetch.
|
|
2
|
+
|
|
3
|
+
Terms (their published API docs): free for personal and commercial use, attribution
|
|
4
|
+
requested, data updates once daily — do not poll frequently.
|
|
5
|
+
|
|
6
|
+
FFC signals "no data for this year/format" with HTTP 200 in two different shapes,
|
|
7
|
+
both observed 2026-07-29:
|
|
8
|
+
|
|
9
|
+
standard/2007 -> {"status": "Success", "meta": {...}, "players": []}
|
|
10
|
+
ppr/2007 -> {"status": "Error", "errors": "No ADP data found."}
|
|
11
|
+
|
|
12
|
+
Emptiness is therefore decided by the absence of players, never by `status`.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from ffl_bigquery.adp.schema import FFC_FORMATS
|
|
20
|
+
from ffl_bigquery.http import ThrottledSession
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
FFC_BASE_URL = "https://fantasyfootballcalculator.com/api/v1/adp"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class FfcResponse:
|
|
29
|
+
players: list[dict]
|
|
30
|
+
total_drafts: int | None
|
|
31
|
+
window_start: str | None
|
|
32
|
+
window_end: str | None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_empty(self) -> bool:
|
|
36
|
+
return not self.players
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_ffc(
|
|
40
|
+
session: ThrottledSession,
|
|
41
|
+
*,
|
|
42
|
+
season: int,
|
|
43
|
+
scoring_format: str,
|
|
44
|
+
teams: int,
|
|
45
|
+
) -> FfcResponse:
|
|
46
|
+
if scoring_format not in FFC_FORMATS:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"unknown scoring_format {scoring_format!r}; expected one of {FFC_FORMATS}"
|
|
49
|
+
)
|
|
50
|
+
payload = session.get_json(
|
|
51
|
+
f"{FFC_BASE_URL}/{scoring_format}", {"teams": teams, "year": season}
|
|
52
|
+
)
|
|
53
|
+
players = payload.get("players") or []
|
|
54
|
+
meta = payload.get("meta") or {}
|
|
55
|
+
if not players:
|
|
56
|
+
log.info(
|
|
57
|
+
"FFC empty: season=%s format=%s teams=%s status=%s errors=%s",
|
|
58
|
+
season, scoring_format, teams,
|
|
59
|
+
payload.get("status"), payload.get("errors"),
|
|
60
|
+
)
|
|
61
|
+
return FfcResponse(
|
|
62
|
+
players=list(players),
|
|
63
|
+
total_drafts=meta.get("total_drafts"),
|
|
64
|
+
window_start=meta.get("start_date"),
|
|
65
|
+
window_end=meta.get("end_date"),
|
|
66
|
+
)
|
ffl_bigquery/adp/mfl.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""MyFantasyLeague ADP fetch (export?TYPE=adp).
|
|
2
|
+
|
|
3
|
+
Free, no key, but the endpoint refuses requests without a real User-Agent.
|
|
4
|
+
|
|
5
|
+
Three shapes matter, all observed 2026-07-29:
|
|
6
|
+
* every numeric field is a STRING ("averagePick": "3.28") — casting is the
|
|
7
|
+
transform's job, not this module's
|
|
8
|
+
* pre-2011 returns {"adp": {"totalPicks": "0", "totalDrafts": "0"}} with no
|
|
9
|
+
"player" key at all
|
|
10
|
+
* MFL's JSON derives from XML, so a single-element list collapses to a bare
|
|
11
|
+
object; "player" may be a dict
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ffl_bigquery.http import ThrottledSession
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
MFL_BASE_URL = "https://api.myfantasyleague.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _as_int(value: Any) -> int | None:
|
|
27
|
+
if value is None or value == "":
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return int(str(value).strip())
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class MflResponse:
|
|
37
|
+
players: list[dict]
|
|
38
|
+
total_drafts: int | None
|
|
39
|
+
total_picks: int | None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_empty(self) -> bool:
|
|
43
|
+
return not self.players
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fetch_mfl(
|
|
47
|
+
session: ThrottledSession,
|
|
48
|
+
*,
|
|
49
|
+
season: int,
|
|
50
|
+
teams: int | None = None,
|
|
51
|
+
is_ppr: bool | None = None,
|
|
52
|
+
is_keeper: bool | None = None,
|
|
53
|
+
is_mock: bool | None = None,
|
|
54
|
+
) -> MflResponse:
|
|
55
|
+
params: dict[str, Any] = {"TYPE": "adp", "JSON": 1}
|
|
56
|
+
if teams is not None:
|
|
57
|
+
params["FCOUNT"] = teams
|
|
58
|
+
if is_ppr is not None:
|
|
59
|
+
params["IS_PPR"] = int(is_ppr)
|
|
60
|
+
if is_keeper is not None:
|
|
61
|
+
params["IS_KEEPER"] = int(is_keeper)
|
|
62
|
+
if is_mock is not None:
|
|
63
|
+
params["IS_MOCK"] = int(is_mock)
|
|
64
|
+
|
|
65
|
+
payload = session.get_json(f"{MFL_BASE_URL}/{season}/export", params)
|
|
66
|
+
adp = payload.get("adp") or {}
|
|
67
|
+
raw = adp.get("player")
|
|
68
|
+
if raw is None:
|
|
69
|
+
players: list[dict] = []
|
|
70
|
+
elif isinstance(raw, dict):
|
|
71
|
+
# XML-derived JSON collapses a single-element list into an object.
|
|
72
|
+
players = [raw]
|
|
73
|
+
else:
|
|
74
|
+
players = list(raw)
|
|
75
|
+
if not players:
|
|
76
|
+
log.info("MFL empty: season=%s params=%s", season, params)
|
|
77
|
+
return MflResponse(
|
|
78
|
+
players=players,
|
|
79
|
+
total_drafts=_as_int(adp.get("totalDrafts")),
|
|
80
|
+
total_picks=_as_int(adp.get("totalPicks")),
|
|
81
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Resolve ff_adp rows to nflverse gsis_id.
|
|
2
|
+
|
|
3
|
+
The two sources take different paths, and this asymmetry is not incidental:
|
|
4
|
+
|
|
5
|
+
* MFL publishes its own player id, which IS one of the 20 id systems in
|
|
6
|
+
ff_playerids -> exact join on mfl_id.
|
|
7
|
+
* FFC publishes an internal player_id that appears in NO nflverse id system
|
|
8
|
+
(verified against all 20 columns) -> name-based join only.
|
|
9
|
+
|
|
10
|
+
Ambiguity is refused rather than resolved. A wrong gsis_id silently attributes one
|
|
11
|
+
player's draft market to another; a NULL is visible and countable.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ffverse merge_name convention, verified against load_ff_playerids():
|
|
23
|
+
# lowercase, drop name suffixes, drop periods and apostrophes, keep hyphens.
|
|
24
|
+
_SUFFIXES = {"jr", "sr", "ii", "iii", "iv", "v"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_merge_name(name: str) -> str:
|
|
28
|
+
if not isinstance(name, str):
|
|
29
|
+
return ""
|
|
30
|
+
s = name.lower().replace(".", "").replace("'", "").replace("’", "")
|
|
31
|
+
tokens = [t for t in s.split() if t]
|
|
32
|
+
while len(tokens) > 1 and tokens[-1] in _SUFFIXES:
|
|
33
|
+
tokens.pop()
|
|
34
|
+
return " ".join(tokens)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _notna_rows(df: pd.DataFrame, col: str) -> pd.DataFrame:
|
|
38
|
+
"""Boolean-mask row filtering (``df[df[col].notna()]``).
|
|
39
|
+
|
|
40
|
+
Without pandas-stubs, pyright resolves ``DataFrame.__getitem__(Series[bool])``
|
|
41
|
+
to an ambiguous overload, and the filtered frame's type then infects every
|
|
42
|
+
downstream attribute access on it (``.map``, ``.fillna``, ``.values``, ...)
|
|
43
|
+
with its own error. Asserting the known-correct return type once, at the
|
|
44
|
+
filter itself, is cheaper and more precise than re-suppressing each of
|
|
45
|
+
those cascaded call sites individually.
|
|
46
|
+
"""
|
|
47
|
+
mask = cast(pd.Series, df[col].notna())
|
|
48
|
+
return cast(pd.DataFrame, df[mask])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _eq_mask(df: pd.DataFrame, col: str, value: str) -> pd.Series:
|
|
52
|
+
"""Same ambiguity as `_notna_rows`, for an equality mask instead of notna."""
|
|
53
|
+
return cast(pd.Series, df[col].eq(value))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _mfl_map(xref: pd.DataFrame) -> pd.Series:
|
|
57
|
+
x = _notna_rows(xref, "gsis_id")
|
|
58
|
+
# pd.to_numeric(...).astype("Int64") is a genuine pandas-stub gap (also
|
|
59
|
+
# suppressed at writer.py:45 for the same reason); cast the result so the
|
|
60
|
+
# gap doesn't cascade into every downstream use of `keys` below.
|
|
61
|
+
keys = cast(pd.Series, pd.to_numeric(x["mfl_id"], errors="coerce").astype("Int64")) # type: ignore[union-attr]
|
|
62
|
+
# Filter on the parsed key's own null-ness, NOT Series.dropna() on the
|
|
63
|
+
# constructed map: dropna() only drops NaN *values*, not a NaN *index*. A
|
|
64
|
+
# xref row with a populated gsis_id but a missing/non-numeric mfl_id would
|
|
65
|
+
# otherwise survive into the map at index pd.NA — and Series.map() treats
|
|
66
|
+
# that as a real match against any ADP row whose own malformed
|
|
67
|
+
# source_player_id also parsed to pd.NA, silently attributing one player's
|
|
68
|
+
# draft market to a different, unrelated player.
|
|
69
|
+
valid = keys.notna()
|
|
70
|
+
return pd.Series(x["gsis_id"].values[valid.to_numpy()], index=keys[valid])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _name_map(xref: pd.DataFrame) -> dict[tuple[str, str], str]:
|
|
74
|
+
x = _notna_rows(xref, "gsis_id").copy()
|
|
75
|
+
x["_key"] = list(
|
|
76
|
+
zip(
|
|
77
|
+
x["merge_name"].map(normalize_merge_name),
|
|
78
|
+
x["position"].fillna("").astype(str),
|
|
79
|
+
strict=False,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
counts = x["_key"].value_counts()
|
|
83
|
+
# Refuse any (merge_name, position) that maps to more than one player.
|
|
84
|
+
# Series.map() genuinely accepts a Series as a value-lookup mapping (not
|
|
85
|
+
# just a function) at runtime; the bundled pandas stub's `.map()` overload
|
|
86
|
+
# only types the function form, so this one has no cast-able boundary.
|
|
87
|
+
unique = x[x["_key"].map(counts) == 1] # type: ignore[arg-type]
|
|
88
|
+
dropped = len(x) - len(unique)
|
|
89
|
+
if dropped:
|
|
90
|
+
log.info("refused %d ambiguous name matches in xref", dropped)
|
|
91
|
+
return dict(zip(unique["_key"], unique["gsis_id"], strict=False))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_gsis_ids(adp: pd.DataFrame, xref: pd.DataFrame) -> pd.DataFrame:
|
|
95
|
+
if adp.empty:
|
|
96
|
+
return adp
|
|
97
|
+
out = adp.copy()
|
|
98
|
+
resolved = pd.Series([pd.NA] * len(out), index=out.index, dtype="object")
|
|
99
|
+
|
|
100
|
+
is_mfl = _eq_mask(out, "source", "mfl")
|
|
101
|
+
if is_mfl.any():
|
|
102
|
+
# pd.to_numeric(...).astype("Int64") is the same genuine stub gap as
|
|
103
|
+
# in `_mfl_map` above.
|
|
104
|
+
ids = cast(
|
|
105
|
+
pd.Series,
|
|
106
|
+
pd.to_numeric(
|
|
107
|
+
out.loc[is_mfl, "source_player_id"], errors="coerce"
|
|
108
|
+
).astype("Int64"), # type: ignore[union-attr]
|
|
109
|
+
)
|
|
110
|
+
resolved.loc[is_mfl] = ids.map(_mfl_map(xref)).values
|
|
111
|
+
|
|
112
|
+
is_ffc = _eq_mask(out, "source", "ffc")
|
|
113
|
+
if is_ffc.any():
|
|
114
|
+
name_map = _name_map(xref)
|
|
115
|
+
keys = list(
|
|
116
|
+
zip(
|
|
117
|
+
out.loc[is_ffc, "player_name"].map(normalize_merge_name),
|
|
118
|
+
out.loc[is_ffc, "position"].fillna("").astype(str),
|
|
119
|
+
strict=False,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
resolved.loc[is_ffc] = [name_map.get(k, pd.NA) for k in keys]
|
|
123
|
+
|
|
124
|
+
out["gsis_id"] = resolved
|
|
125
|
+
log.info("resolved %d/%d ADP rows to gsis_id", out["gsis_id"].notna().sum(), len(out))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolution_rate(adp: pd.DataFrame) -> float:
|
|
130
|
+
if adp.empty:
|
|
131
|
+
return 0.0
|
|
132
|
+
return float(adp["gsis_id"].notna().mean())
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""ff_adp: one row per player per ADP snapshot per league configuration.
|
|
2
|
+
|
|
3
|
+
Snapshot grain is not an aesthetic choice. FFC's start_date/end_date query
|
|
4
|
+
parameters are silently ignored (verified 2026-07-29: requests for 2026-06-01..15
|
|
5
|
+
and 2026-07-01..15 both returned the identical current window), so intra-preseason
|
|
6
|
+
ADP drift cannot be backfilled — it exists only if captured forward, daily.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ffl_bigquery.partition import TimePartition
|
|
11
|
+
from ffl_bigquery.schema import INGESTED_AT_SPEC, ColumnSpec
|
|
12
|
+
|
|
13
|
+
ADP_SOURCES: tuple[str, ...] = ("ffc", "mfl")
|
|
14
|
+
FFC_FORMATS: tuple[str, ...] = (
|
|
15
|
+
"standard", "ppr", "half-ppr", "2qb", "dynasty", "rookie",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
FF_ADP_KEYS: list[str] = [
|
|
19
|
+
"source", "season", "scoring_format", "teams", "snapshot_date", "source_player_id",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
FF_ADP_PARTITION = TimePartition(
|
|
23
|
+
field="snapshot_date",
|
|
24
|
+
clustering=["season", "source", "scoring_format", "teams"],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _spec(
|
|
29
|
+
name: str,
|
|
30
|
+
type: str,
|
|
31
|
+
mode: str,
|
|
32
|
+
short: str,
|
|
33
|
+
definition: str,
|
|
34
|
+
*,
|
|
35
|
+
tags: list[str] | None = None,
|
|
36
|
+
valid_range: tuple[float, float] | None = None,
|
|
37
|
+
valid_values: list[str] | None = None,
|
|
38
|
+
example: object | None = None,
|
|
39
|
+
gotchas: list[str] | None = None,
|
|
40
|
+
source_field: str = "",
|
|
41
|
+
) -> ColumnSpec:
|
|
42
|
+
return ColumnSpec(
|
|
43
|
+
name=name, type=type, mode=mode, short_description=short, # type: ignore[arg-type]
|
|
44
|
+
business_definition=definition, semantic_tags=tags or [],
|
|
45
|
+
valid_range=valid_range, valid_values=valid_values, example_value=example,
|
|
46
|
+
gotchas=gotchas or [], source_field=source_field or name,
|
|
47
|
+
deprecated_in_year=None,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
FF_ADP_SCHEMA: list[ColumnSpec] = [
|
|
52
|
+
_spec("source", "STRING", "REQUIRED",
|
|
53
|
+
"Which ADP market this row came from.",
|
|
54
|
+
"Provider of the ADP observation. 'ffc' = Fantasy Football Calculator, "
|
|
55
|
+
"'mfl' = MyFantasyLeague. The two are independent markets and their ADP "
|
|
56
|
+
"values are not interchangeable.",
|
|
57
|
+
tags=["identifier", "primary_key", "dimension"],
|
|
58
|
+
valid_values=["ffc", "mfl"], example="ffc"),
|
|
59
|
+
_spec("season", "INT64", "REQUIRED",
|
|
60
|
+
"NFL season the draft was for.",
|
|
61
|
+
"Season year of the fantasy draft. FFC has data from 2010; MFL from 2011. "
|
|
62
|
+
"Earlier years return empty responses.",
|
|
63
|
+
tags=["identifier", "primary_key"], valid_range=(2010.0, 2100.0), example=2026),
|
|
64
|
+
_spec("scoring_format", "STRING", "REQUIRED",
|
|
65
|
+
"Scoring system of the drafts aggregated into this ADP.",
|
|
66
|
+
"League scoring format. FFC exposes standard/ppr/half-ppr/2qb/dynasty/rookie, "
|
|
67
|
+
"though half-ppr history is shallow. For MFL rows this is derived from the "
|
|
68
|
+
"IS_PPR request parameter.",
|
|
69
|
+
tags=["identifier", "primary_key", "dimension"], example="ppr",
|
|
70
|
+
gotchas=["half-ppr returned no data for 2010 or 2015; treat pre-recent "
|
|
71
|
+
"half-ppr as unavailable rather than zero."]),
|
|
72
|
+
_spec("teams", "INT64", "REQUIRED",
|
|
73
|
+
"League size the ADP was aggregated over.",
|
|
74
|
+
"Number of teams in the drafts aggregated into this ADP value (FFC 'teams' "
|
|
75
|
+
"parameter, MFL 'FCOUNT'). ADP is only comparable within a league size.",
|
|
76
|
+
tags=["identifier", "primary_key", "dimension"],
|
|
77
|
+
valid_range=(4.0, 32.0), example=12),
|
|
78
|
+
_spec("snapshot_date", "DATE", "REQUIRED",
|
|
79
|
+
"Date this ADP observation was captured.",
|
|
80
|
+
"UTC date the sync ran. This is the drift axis: the same (source, season, "
|
|
81
|
+
"format, teams, player) appears once per capture day, so day-over-day "
|
|
82
|
+
"movement is queryable. Not the date the drafts occurred.",
|
|
83
|
+
tags=["identifier", "primary_key", "partition_key"], example="2026-07-29",
|
|
84
|
+
gotchas=["Rows only exist for days the cron actually ran. Gaps are real "
|
|
85
|
+
"gaps, not zero-movement days."]),
|
|
86
|
+
_spec("source_player_id", "STRING", "REQUIRED",
|
|
87
|
+
"The provider's own player identifier.",
|
|
88
|
+
"Player id as issued by the source. For 'mfl' this is the MFL id and joins "
|
|
89
|
+
"ff_player_xref.mfl_id directly. For 'ffc' this is FFC's internal id, which "
|
|
90
|
+
"appears in NO nflverse id system — FFC rows resolve by name instead.",
|
|
91
|
+
tags=["identifier", "primary_key"], example="925",
|
|
92
|
+
gotchas=["FFC ids are not portable. Never attempt to join them to an "
|
|
93
|
+
"nflverse id column."]),
|
|
94
|
+
_spec("gsis_id", "STRING", "NULLABLE",
|
|
95
|
+
"Resolved nflverse player id, if resolution succeeded.",
|
|
96
|
+
"Canonical nflverse gsis_id, resolved via ff_player_xref. NULL when "
|
|
97
|
+
"resolution failed. Unresolved rows are retained deliberately — dropping "
|
|
98
|
+
"them would silently thin the market.",
|
|
99
|
+
tags=["identifier", "join_key"], example="00-0033873",
|
|
100
|
+
gotchas=["Always NULL-guard before joining to nfl_plays or "
|
|
101
|
+
"ff_points_weekly, or unresolved players vanish from results."]),
|
|
102
|
+
_spec("player_name", "STRING", "NULLABLE",
|
|
103
|
+
"Player name as published by the source.",
|
|
104
|
+
"Source-provided display name. Present for FFC; absent for MFL, whose ADP "
|
|
105
|
+
"export returns ids only.",
|
|
106
|
+
tags=["dimension"], example="Adrian Peterson"),
|
|
107
|
+
_spec("position", "STRING", "NULLABLE",
|
|
108
|
+
"Player position as published by the source.",
|
|
109
|
+
"Source-provided position. Present for FFC; absent for MFL.",
|
|
110
|
+
tags=["dimension"], example="RB"),
|
|
111
|
+
_spec("team", "STRING", "NULLABLE",
|
|
112
|
+
"NFL team as published by the source.",
|
|
113
|
+
"Source-provided team abbreviation at time of capture. Present for FFC; "
|
|
114
|
+
"absent for MFL. May disagree with nflverse abbreviations.",
|
|
115
|
+
tags=["dimension"], example="MIN"),
|
|
116
|
+
_spec("adp", "FLOAT64", "REQUIRED",
|
|
117
|
+
"Average draft position.",
|
|
118
|
+
"Mean overall pick number at which this player was selected across the "
|
|
119
|
+
"aggregated drafts. Lower is earlier.",
|
|
120
|
+
tags=["metric"], valid_range=(1.0, 500.0), example=1.8),
|
|
121
|
+
_spec("adp_formatted", "STRING", "NULLABLE",
|
|
122
|
+
"ADP rendered as round.pick.",
|
|
123
|
+
"FFC's human-readable round.pick form of adp, e.g. '1.02'. Provided by FFC "
|
|
124
|
+
"only; NULL for MFL.",
|
|
125
|
+
tags=["metric"], example="1.02"),
|
|
126
|
+
_spec("adp_stdev", "FLOAT64", "NULLABLE",
|
|
127
|
+
"Standard deviation of pick number.",
|
|
128
|
+
"Dispersion of the pick numbers behind adp — a consensus measure. High "
|
|
129
|
+
"stdev means drafters disagreed. FFC only; NULL for MFL.",
|
|
130
|
+
tags=["metric"], valid_range=(0.0, 200.0), example=1.0),
|
|
131
|
+
_spec("adp_earliest_pick", "INT64", "NULLABLE",
|
|
132
|
+
"Earliest (smallest) pick number observed.",
|
|
133
|
+
"The soonest this player was taken in any aggregated draft. Maps from FFC's "
|
|
134
|
+
"'high' field and MFL's 'minPick'.",
|
|
135
|
+
tags=["metric"], valid_range=(1.0, 500.0), example=1,
|
|
136
|
+
source_field="high|minPick",
|
|
137
|
+
gotchas=["FFC calls this 'high' because it is the highest draft position — "
|
|
138
|
+
"which is the LOWEST number. Renamed here to prevent inverted "
|
|
139
|
+
"comparisons."]),
|
|
140
|
+
_spec("adp_latest_pick", "INT64", "NULLABLE",
|
|
141
|
+
"Latest (largest) pick number observed.",
|
|
142
|
+
"The latest this player was taken in any aggregated draft. Maps from FFC's "
|
|
143
|
+
"'low' field and MFL's 'maxPick'.",
|
|
144
|
+
tags=["metric"], valid_range=(1.0, 500.0), example=5,
|
|
145
|
+
source_field="low|maxPick"),
|
|
146
|
+
_spec("times_drafted", "INT64", "NULLABLE",
|
|
147
|
+
"Drafts this player was selected in.",
|
|
148
|
+
"Count of aggregated drafts in which this player was taken. FFC "
|
|
149
|
+
"'times_drafted'; MFL 'draftsSelectedIn'. The denominator for "
|
|
150
|
+
"draft_selected_pct.",
|
|
151
|
+
tags=["metric"], example=329, source_field="times_drafted|draftsSelectedIn"),
|
|
152
|
+
_spec("draft_selected_pct", "FLOAT64", "NULLABLE",
|
|
153
|
+
"Share of drafts the player was selected in.",
|
|
154
|
+
"MFL 'draftSelPct' as a percentage (0-100). NULL for FFC.",
|
|
155
|
+
tags=["metric"], valid_range=(0.0, 100.0), example=13.0,
|
|
156
|
+
source_field="draftSelPct"),
|
|
157
|
+
_spec("source_rank", "INT64", "NULLABLE",
|
|
158
|
+
"The provider's own ADP rank ordering.",
|
|
159
|
+
"Rank by adp as published by the source. MFL 'rank'; NULL for FFC, where "
|
|
160
|
+
"row order conveys rank.",
|
|
161
|
+
tags=["metric"], example=1, source_field="rank"),
|
|
162
|
+
_spec("total_drafts", "INT64", "NULLABLE",
|
|
163
|
+
"Drafts in the aggregation pool.",
|
|
164
|
+
"Total drafts the provider aggregated for this request. FFC "
|
|
165
|
+
"meta.total_drafts; MFL adp.totalDrafts. A sample-size caveat: a 2026 "
|
|
166
|
+
"July snapshot may rest on far fewer drafts than a September one.",
|
|
167
|
+
tags=["metric", "quality"], example=3673),
|
|
168
|
+
_spec("bye", "INT64", "NULLABLE",
|
|
169
|
+
"Player's bye week that season.",
|
|
170
|
+
"Bye week as published by FFC. NULL for MFL.",
|
|
171
|
+
tags=["dimension"], valid_range=(1.0, 18.0), example=6),
|
|
172
|
+
_spec("window_start_date", "DATE", "NULLABLE",
|
|
173
|
+
"First draft date in the provider's aggregation window.",
|
|
174
|
+
"FFC meta.start_date — the earliest draft included. NULL for MFL, which "
|
|
175
|
+
"does not publish a window.",
|
|
176
|
+
tags=["quality"], example="2026-07-22",
|
|
177
|
+
gotchas=["This is the provider's window, NOT a request parameter. FFC "
|
|
178
|
+
"ignores start_date/end_date on input."]),
|
|
179
|
+
_spec("window_end_date", "DATE", "NULLABLE",
|
|
180
|
+
"Last draft date in the provider's aggregation window.",
|
|
181
|
+
"FFC meta.end_date — the latest draft included. NULL for MFL.",
|
|
182
|
+
tags=["quality"], example="2026-07-29"),
|
|
183
|
+
_spec("is_keeper", "BOOL", "NULLABLE",
|
|
184
|
+
"Whether keeper leagues were included.",
|
|
185
|
+
"MFL IS_KEEPER request slicer. NULL for FFC, which does not expose it.",
|
|
186
|
+
tags=["dimension"], example=False),
|
|
187
|
+
_spec("is_mock", "BOOL", "NULLABLE",
|
|
188
|
+
"Whether mock drafts were included.",
|
|
189
|
+
"MFL IS_MOCK request slicer. NULL for FFC. Mock-only ADP rests on very "
|
|
190
|
+
"few drafts (measured: 12 for 2025) and should be read with care.",
|
|
191
|
+
tags=["dimension"], example=False),
|
|
192
|
+
INGESTED_AT_SPEC,
|
|
193
|
+
]
|