File size: 15,204 Bytes
1290dbb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
"""
Read-only data layer over lake/gold/nfl/*.csv.

Deliberately does NOT touch lake/nfl.duckdb -- that file is gitignored and
not guaranteed to exist on a fresh clone (see docs/HANDOFF.md sec 5). Every
gold CSV under lake/gold/nfl/ is tracked in git and is the source of truth
this backend reads.

Per HANDOFF_CLAUDE_CODE.md sec 0, rule 1: only a chart whose _index.csv row
has status == "ok" is ever rendered with real data -- anything else returns
an explicit empty/stale/error state, never a silent fallback to placeholder
values.

Known real gap, documented rather than worked around silently: prop_best_price
/ prop_line_rotowire key players by RotoWire's own numeric player_id, not the
nflverse gsis_id used everywhere else in the lake (docs/HANDOFF.md sec 5.8 --
the id bridge table exists but has never been populated). This layer joins
those two worlds by normalized player name, the same workaround
scripts/matchup_report.py already uses for the same reason.
"""
from __future__ import annotations

import csv
import pathlib
import re
import threading
from functools import lru_cache

REPO = pathlib.Path(__file__).resolve().parent.parent
GOLD = REPO / "lake" / "gold" / "nfl"

_lock = threading.Lock()


def _read_csv(name: str) -> list[dict]:
    path = GOLD / f"{name}.csv"
    if not path.exists():
        return []
    with path.open(newline="", encoding="utf-8") as fh:
        return list(csv.DictReader(fh))


@lru_cache(maxsize=64)
def _cached_csv(name: str, _version: int) -> list[dict]:
    return _read_csv(name)


_VERSION = 0  # bump to invalidate the cache (not needed -- data is static per session)


def load(name: str) -> list[dict]:
    with _lock:
        return _cached_csv(name, _VERSION)


def normalize_name(name: str) -> str:
    """Loose match key: lowercase, strip punctuation/suffixes, collapse spaces."""
    n = name.lower()
    n = re.sub(r"[.'\-]", "", n)
    n = re.sub(r"\b(jr|sr|ii|iii|iv)\b", "", n)
    n = re.sub(r"\s+", " ", n).strip()
    return n


# ---------------------------------------------------------------------------
# _index.csv -- the chart-status gate every endpoint must honor
# ---------------------------------------------------------------------------

def chart_index() -> dict[str, dict]:
    rows = load("_index")
    return {r["chart_name"]: r for r in rows}


def chart_status(chart_name: str) -> str:
    row = chart_index().get(chart_name)
    return row["status"] if row else "error"


# ---------------------------------------------------------------------------
# Player dimension -- gsis_id -> {name, team, position}, built from whichever
# gold CSVs actually carry gsis ids + names (player_photos/_index.csv is the
# most complete display-name source; team/position resolved from the most
# recent week seen in player_scrimmage_week.csv).
# ---------------------------------------------------------------------------

@lru_cache(maxsize=1)
def player_dimension() -> dict[str, dict]:
    photos = {r["player_id"]: r["player_display_name"] for r in load("player_photos/_index") if r.get("player_id")}
    dim: dict[str, dict] = {}
    for row in load("player_scrimmage_week"):
        pid = row.get("player_id")
        if not pid:
            continue
        wk = int(row.get("week") or 0)
        existing = dim.get(pid)
        if existing is None or wk >= existing["_week"]:
            dim[pid] = {
                "player_id": pid,
                "name": photos.get(pid, pid),
                "team": row.get("team", ""),
                "_week": wk,
            }
    # position isn't on the weekly stat tables; player_usage.csv is the one
    # gold export that carries it, keyed by (season, team, player_id).
    for row in load("player_usage"):
        pid = row.get("player_id")
        if pid in dim:
            dim[pid]["position"] = row.get("position", "")
    for pid, row in dim.items():
        row.setdefault("name", photos.get(pid, pid))
        row.setdefault("position", "")
        row.pop("_week", None)
    return dim


def player_name(player_id: str) -> str:
    dim = player_dimension()
    if player_id in dim:
        return dim[player_id]["name"]
    photos = {r["player_id"]: r["player_display_name"] for r in load("player_photos/_index")}
    return photos.get(player_id, player_id)


def player_team(player_id: str) -> str:
    return player_dimension().get(player_id, {}).get("team", "")


@lru_cache(maxsize=1)
def _headshots() -> dict[str, str]:
    return {r["player_id"]: r["headshot_url"] for r in load("player_headshots")}


def _enhance_cloudinary(url: str | None, w: int = 400, h: int = 400) -> str | None:
    """Inject face-crop + size transforms into NFL.com Cloudinary headshot URLs."""
    if not url:
        return url
    # Pattern: .../image/upload/<existing_transforms>/...
    import re
    m = re.match(r"(https://static\.www\.nfl\.com/image/upload/)([^/]+)(/.+)", url)
    if m:
        return f"{m.group(1)}w_{w},h_{h},c_fill,g_face,{m.group(2)}{m.group(3)}"
    return url


def photo_url(player_id: str) -> str | None:
    """Tank01's ESPN headshot first (matched on name + team), nflverse's headshot as fallback.
    The browser loads the URL directly; the frontend shows an initials avatar if it fails."""
    d = player_dimension().get(player_id)
    if d:
        try:
            import tank01
            hits = tank01.player_photos().get(normalize_name(d["name"]), []) if tank01.available() else []
        except Exception:
            hits = []
        same_team = [u for t, u in hits if t == d.get("team")]
        if same_team:
            return same_team[0]
        if len(hits) == 1:
            return hits[0][1]
    return _enhance_cloudinary(_headshots().get(player_id))


def search_players(query: str, limit: int = 20) -> list[dict]:
    q = normalize_name(query)
    if not q:
        return []
    out = []
    for pid, row in player_dimension().items():
        if q in normalize_name(row["name"]):
            out.append({"playerId": pid, "name": row["name"], "team": row["team"]})
    return out[:limit]


# ---------------------------------------------------------------------------
# Prop lines -- RotoWire, name-matched to the gsis player dimension
# ---------------------------------------------------------------------------

MARKET_TO_STAT = {
    "rushyds": ("player_rushing_week", "rushing_yards"),
    "recyds": ("player_receiving_week", "receiving_yards"),
    "recs": ("player_receiving_week", "receptions"),
    "passyds": ("player_passing_week", "passing_yards"),
    "passtd": ("player_passing_week", "passing_tds"),
}

PROP_LABELS = {
    "rushyds": "Rush Yds",
    "recyds": "Rec Yds",
    "recs": "Receptions",
    "passyds": "Pass Yds",
    "passtd": "Pass TDs",
    "anytd": "Any TD",
}


@lru_cache(maxsize=1)
def _name_to_best_price() -> dict[str, list[dict]]:
    out: dict[str, list[dict]] = {}
    for row in load("prop_best_price"):
        key = normalize_name(row.get("player_name", ""))
        out.setdefault(key, []).append(row)
    return out


def best_price_for(player_name_str: str, market_slug: str) -> dict | None:
    key = normalize_name(player_name_str)
    for row in _name_to_best_price().get(key, []):
        if row.get("market_slug") == market_slug:
            return row
    return None


@lru_cache(maxsize=1)
def _name_to_rotowire_fanduel() -> dict[str, list[dict]]:
    out: dict[str, list[dict]] = {}
    for row in load("prop_line_rotowire"):
        if row.get("book_slug") != "fanduel":
            continue
        key = normalize_name(row.get("player_name", ""))
        out.setdefault(key, []).append(row)
    return out


def fanduel_line_for(player_name_str: str, market_slug: str) -> dict | None:
    """
    The real FanDuel line/price for a player+market, per the user's explicit
    choice (FanDuel only, not best-price-across-books). Reads
    prop_line_rotowire.csv filtered to book_slug == 'fanduel' -- the raw,
    single-book source -- rather than prop_best_price.csv, which mixes
    whichever book happened to have the best number per market. Falls back
    to prop_best_price only if no FanDuel row exists at all for this
    player/market, and labels the book honestly either way.
    """
    key = normalize_name(player_name_str)
    candidates = [r for r in _name_to_rotowire_fanduel().get(key, []) if r.get("market_slug") == market_slug]
    if candidates:
        # multiple pulls/snapshots can exist; take the most recently fetched
        latest = max(candidates, key=lambda r: r.get("fetched_at_utc", ""))
        return {
            "line": latest.get("line"),
            "book": "fanduel",
            "over_price": latest.get("over_price_american"),
            "under_price": latest.get("under_price_american"),
            "moneyline_price": latest.get("moneyline_american"),
        }
    fallback = best_price_for(player_name_str, market_slug)
    if fallback:
        return {
            "line": fallback.get("line"),
            "book": fallback.get("best_over_book") or fallback.get("best_moneyline_book") or "unknown",
            "over_price": fallback.get("best_over_price"),
            "under_price": fallback.get("best_under_price"),
            "moneyline_price": fallback.get("best_moneyline_price"),
        }
    return None


# ---------------------------------------------------------------------------
# Weekly game log for a player + prop, with hit-rate vs the current line
# ---------------------------------------------------------------------------

def _num(v, digits: int = 3):
    try:
        return round(float(v), digits)
    except (TypeError, ValueError):
        return None


def player_trust(player_id: str) -> dict | None:
    """Usage trust, red-zone tiers and TD conversion for one player (three gold tables)."""
    usage = next((r for r in load("player_usage") if r.get("player_id") == player_id), None)
    corr = next((r for r in load("player_usage_td_correlation") if r.get("player_id") == player_id), None)
    tiers = [r for r in load("redzone_tiers") if r.get("player_id") == player_id]
    if not (usage or corr or tiers):
        return None
    row = usage or corr or {}
    return {
        "usageIndex": _num(row.get("usage_index_score"), 1),
        "role": row.get("usage_role", ""),
        "shareOverall": _num(row.get("share_overall")),
        "shareCalm": _num(row.get("share_calm")),
        "shareStress": _num(row.get("share_stress")),
        "confidenceDelta": _num(row.get("confidence_delta")),
        "redZone": [
            {
                "type": t.get("opportunity_type", ""),
                "tiers": [
                    {"label": label, "opps": _num(t.get(f"opp_{k}"), 0), "tds": _num(t.get(f"td_{k}"), 0),
                     "share": _num(t.get(f"pct_{k}_intra"))}
                    for label, k in (("INSIDE 20", "i20"), ("INSIDE 10", "i10"), ("INSIDE 5", "i5"), ("GOAL TO GO", "g2g"))
                    if t.get(f"opp_{k}") not in (None, "")
                ],
            }
            for t in tiers
        ],
        "td": {
            "total": _num(corr.get("tds"), 0), "redZone": _num(corr.get("tds_red_zone"), 0),
            "goalToGo": _num(corr.get("tds_goal_to_go"), 0), "rushing": _num(corr.get("tds_rush"), 0),
            "receiving": _num(corr.get("tds_rec"), 0), "gamesWithTd": _num(corr.get("games_with_td"), 0),
            "perRedZoneOpp": _num(corr.get("td_per_redzone_opp")), "flag": corr.get("correlation_flag", ""),
        } if corr else None,
    }


def player_prop_chart(player_id: str, market_slug: str) -> dict:
    name = player_name(player_id)
    team = player_team(player_id)
    status = chart_status(MARKET_TO_STAT.get(market_slug, ("prop_best_price",))[0])

    if market_slug == "anytd":
        games = anytime_td_games(player_id)
        line_row = None
        line = 0.5
        book = None
    else:
        src, stat_col = MARKET_TO_STAT.get(market_slug, (None, None))
        if src is None:
            return {"error": f"unknown market_slug {market_slug}"}
        games = weekly_games(player_id, src, stat_col)
        line_row = fanduel_line_for(name, market_slug)
        line = float(line_row["line"]) if line_row and line_row.get("line") else None
        book = line_row.get("book") if line_row else None

    games_sorted = sorted(games, key=lambda g: g["week"])
    bars = [{"gameDate": f"Week {g['week']}", "opponent": g["opponent"], "value": g["value"]} for g in games_sorted]

    def hit_rate(window: list[dict]) -> float | None:
        if not window or line is None:
            return None
        hits = sum(1 for g in window if g["value"] >= line)
        return round(hits / len(window), 3)

    l5 = games_sorted[-5:]
    l10 = games_sorted[-10:]
    l20 = games_sorted[-20:]

    import bpl
    official = bpl.nfl_player_line(player_id, market_slug, name, team) if market_slug in bpl.NFL_MARKETS else None
    return {
        "playerId": player_id,
        "name": name,
        "team": team,
        "position": player_dimension().get(player_id, {}).get("position", ""),
        "bpl": official,
        "trust": player_trust(player_id),
        "prop": PROP_LABELS.get(market_slug, market_slug),
        "marketSlug": market_slug,
        "line": line,
        "book": book or "FanDuel",
        "games": bars,
        "splits": [
            {"label": "L5", "hitRate": hit_rate(l5)},
            {"label": "L10", "hitRate": hit_rate(l10)},
            {"label": "L20", "hitRate": hit_rate(l20)},
        ],
        "sourceStatus": status,
        "photoUrl": photo_url(player_id),
    }


def weekly_games(player_id: str, src: str, stat_col: str) -> list[dict]:
    out = []
    for row in load(src):
        if row.get("player_id") != player_id:
            continue
        try:
            value = float(row.get(stat_col) or 0)
        except ValueError:
            value = 0.0
        out.append({"week": int(row.get("week") or 0), "opponent": row.get("opponent_team", ""), "value": value})
    return out


def anytime_td_games(player_id: str) -> list[dict]:
    out = []
    for row in load("player_scoring_week"):
        if row.get("player_id") != player_id:
            continue
        tds = float(row.get("total_tds") or 0)
        out.append({"week": int(row.get("week") or 0), "opponent": row.get("opponent_team", ""), "value": tds})
    return out


def hit_rate_probability(player_id: str, market_slug: str, window: int = 10) -> float | None:
    """
    The parlay-leg 'probability' score, per the user's explicit choice:
    hit-rate-vs-line only (no usage/toxicity/redzone weighting for v1).
    This is a documented model output, not a raw lake fact -- see
    sql/nfl_parlay_probability_schema.sql for the equivalent view definition
    and its own _index.csv row (added for when the lake is rebuilt with
    DuckDB in an environment that has it).
    """
    chart = player_prop_chart(player_id, market_slug)
    if chart.get("line") is None:
        return None
    games = sorted(chart["games"], key=lambda g: g["gameDate"])[-window:]
    if not games:
        return None
    hits = sum(1 for g in games if g["value"] >= chart["line"])
    return round(hits / len(games), 3)