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.
Files changed (60) hide show
  1. ffl_bigquery/__init__.py +3 -0
  2. ffl_bigquery/_schema_samples/__init__.py +54 -0
  3. ffl_bigquery/_schema_samples/ff_opportunity.parquet +0 -0
  4. ffl_bigquery/_schema_samples/ff_rankings.parquet +0 -0
  5. ffl_bigquery/_schema_samples/ftn_charting.parquet +0 -0
  6. ffl_bigquery/_schema_samples/injuries.parquet +0 -0
  7. ffl_bigquery/_schema_samples/participation.parquet +0 -0
  8. ffl_bigquery/_schema_samples/snap_counts.parquet +0 -0
  9. ffl_bigquery/_transform_util.py +18 -0
  10. ffl_bigquery/_version.py +1 -0
  11. ffl_bigquery/adp/__init__.py +0 -0
  12. ffl_bigquery/adp/ffc.py +66 -0
  13. ffl_bigquery/adp/mfl.py +81 -0
  14. ffl_bigquery/adp/resolve.py +132 -0
  15. ffl_bigquery/adp/schema.py +193 -0
  16. ffl_bigquery/adp/sync.py +175 -0
  17. ffl_bigquery/adp/transform.py +124 -0
  18. ffl_bigquery/cli.py +141 -0
  19. ffl_bigquery/coaches/__init__.py +0 -0
  20. ffl_bigquery/coaches/schema.py +63 -0
  21. ffl_bigquery/coaches/sync.py +23 -0
  22. ffl_bigquery/coaches/transform.py +43 -0
  23. ffl_bigquery/coordinators/__init__.py +0 -0
  24. ffl_bigquery/coordinators/schema.py +104 -0
  25. ffl_bigquery/coordinators/sync.py +121 -0
  26. ffl_bigquery/coordinators/wikipedia.py +160 -0
  27. ffl_bigquery/derive/__init__.py +1 -0
  28. ffl_bigquery/derive/personnel.py +122 -0
  29. ffl_bigquery/derive/points_weekly.py +139 -0
  30. ffl_bigquery/derive/scheme_week.py +469 -0
  31. ffl_bigquery/http.py +90 -0
  32. ffl_bigquery/nflverse/__init__.py +0 -0
  33. ffl_bigquery/nflverse/driver.py +161 -0
  34. ffl_bigquery/nflverse/runs.py +110 -0
  35. ffl_bigquery/nflverse/spec.py +25 -0
  36. ffl_bigquery/nflverse/tables/__init__.py +62 -0
  37. ffl_bigquery/nflverse/tables/depth_charts.py +179 -0
  38. ffl_bigquery/nflverse/tables/ftn_charting.py +92 -0
  39. ffl_bigquery/nflverse/tables/injuries.py +54 -0
  40. ffl_bigquery/nflverse/tables/opportunity.py +50 -0
  41. ffl_bigquery/nflverse/tables/participation.py +131 -0
  42. ffl_bigquery/nflverse/tables/rankings.py +54 -0
  43. ffl_bigquery/nflverse/tables/snap_counts.py +44 -0
  44. ffl_bigquery/partition.py +40 -0
  45. ffl_bigquery/runs.py +135 -0
  46. ffl_bigquery/schema.py +68 -0
  47. ffl_bigquery/schema_gen.py +86 -0
  48. ffl_bigquery/verify/__init__.py +60 -0
  49. ffl_bigquery/verify/adp.py +138 -0
  50. ffl_bigquery/verify/tables.py +220 -0
  51. ffl_bigquery/writer.py +271 -0
  52. ffl_bigquery/xref/__init__.py +0 -0
  53. ffl_bigquery/xref/schema.py +100 -0
  54. ffl_bigquery/xref/sync.py +39 -0
  55. ffl_bigquery/xref/transform.py +15 -0
  56. ffl_bigquery-0.1.0.dist-info/METADATA +264 -0
  57. ffl_bigquery-0.1.0.dist-info/RECORD +60 -0
  58. ffl_bigquery-0.1.0.dist-info/WHEEL +4 -0
  59. ffl_bigquery-0.1.0.dist-info/entry_points.txt +2 -0
  60. ffl_bigquery-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ from ffl_bigquery._version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -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)
@@ -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))
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -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
+ )
@@ -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
+ ]