Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ jobs:
# Pin ruff — unpinned it floats to whatever released last, and 0.16.0
# turned on a batch of new default rules (ISC/I001/…) that red-lit the
# whole repo overnight with pre-existing style nits. Bump deliberately.
- run: pip install -r requirements.txt ruff==0.15.21
- run: pip install -r requirements.txt ruff==0.15.21 pytest==8.4.1
- run: ruff check .
- run: pytest tests/ -q
- name: Import smoke test
env:
SUPABASE_URL: https://example.supabase.co
Expand Down
58 changes: 58 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
get_players as sleeper_get_players,
)
from engine.sleeper_sync import build_nfl_rules, compute_pick_inventory, build_roster_items
from engine import fantasycalc
from engine.nfl_dynasty import build_payload as build_nfl_dynasty_payload, derive_fc_params
from engine.nfl_trade import (
build_nfl_trade_context,
build_nfl_trade_prompt,
Expand Down Expand Up @@ -2294,6 +2296,62 @@ async def sleeper_sync(body: SleeperSyncRequest, user: dict = Depends(get_curren
raise HTTPException(status_code=500, detail=str(e))


@app.post("/dashboard/nfl_roster")
async def dashboard_nfl_roster(body: DashboardRequest, user: dict = Depends(get_current_user)):
"""Dynasty roster view payload for Sleeper NFL leagues: FantasyCalc market
values joined onto the synced roster + picks, plus deterministic age-curve,
depth, and roster-window reads (engine/nfl_dynasty.py). No AI call and no
dashboard_cache row — the only cached piece is the FantasyCalc response
(in-process per settings combo, engine/fantasycalc.py), so the page is
always fresh after a sync. `force` refetches FantasyCalc. Named under
/dashboard/ so the frontend's useDashboardWidget hook works unchanged."""
try:
sb = get_supabase()
require_league_owner(sb, user, body.league_id)

def _load():
league = (
sb.table("leagues").select("rules").eq("id", body.league_id)
.single().execute()
).data or {}
rows = (
sb.table("rosters")
.select("team_name, roster_items, draft_picks")
.eq("league_id", body.league_id)
.eq("fantrax_team_id", body.my_team_id)
.limit(1)
.execute()
).data
return league, (rows[0] if rows else None)

league, roster_row = await asyncio.to_thread(_load)
rules = league.get("rules") or {}
if (rules.get("sport") or "").upper() != "NFL":
raise HTTPException(status_code=400, detail="Not an NFL league.")
if not roster_row:
raise HTTPException(
status_code=404, detail="No synced roster yet — sync your league first."
)

params = derive_fc_params(rules)
fc = await asyncio.to_thread(
fantasycalc.get_values,
params["num_qbs"], params["ppr"], params["num_teams"], body.force,
)
payload = build_nfl_dynasty_payload(
roster_row.get("roster_items"), roster_row.get("draft_picks"),
rules, fc["entries"],
)
payload["team_name"] = roster_row.get("team_name") or "Your Team"
payload["values_updated_at"] = fc["fetched_at"]
return payload
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))


@app.post("/roster/sync")
async def roster_sync(
background_tasks: BackgroundTasks,
Expand Down
2 changes: 2 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Repo-root conftest: its presence puts the repo root on sys.path so tests can
`import engine.*` without packaging. Keep even if empty."""
60 changes: 60 additions & 0 deletions engine/fantasycalc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""FantasyCalc dynasty market values (free API, no key). Values are identical
for every league sharing a settings combo (numQbs, ppr, numTeams), so the
response is cached in-process per combo — ~12h TTL, stale-if-error — instead of
per-league in dashboard_cache: there is no AI cost to protect here, and a
per-league payload cache would go stale the moment a sync changes the roster."""

import threading
import time
from datetime import datetime, timezone

import httpx

_URL = "https://api.fantasycalc.com/values/current"
_TTL_S = 12 * 3600

# combo key -> (monotonic fetch time, wall-clock ISO, entries)
_cache: dict[tuple, tuple[float, str, list]] = {}
# Single-flight: concurrent cache misses share one fetch instead of stampeding
# the free API. Coarse (all combos serialize) but the fetch takes ~a second and
# runs on worker threads, so contention is negligible at this app's scale.
_lock = threading.Lock()


def get_values(num_qbs: int, ppr: float, num_teams: int, force: bool = False) -> dict:
"""Current dynasty values for a settings combo. Returns
{"entries": [...], "fetched_at": iso} — stale entries when a refetch fails,
and {"entries": None, "fetched_at": None} only when nothing was ever
fetched. Blocking (httpx) — call via asyncio.to_thread."""
key = (num_qbs, ppr, num_teams)
with _lock:
return _get_values_locked(key, num_qbs, ppr, num_teams, force)


def _get_values_locked(key: tuple, num_qbs: int, ppr: float, num_teams: int,
force: bool) -> dict:
hit = _cache.get(key)
if hit and not force and time.monotonic() - hit[0] < _TTL_S:
return {"entries": hit[2], "fetched_at": hit[1]}
try:
resp = httpx.get(
_URL,
params={
"isDynasty": "true",
"numQbs": num_qbs,
"ppr": ppr,
"numTeams": num_teams,
},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
if isinstance(data, list) and data:
fetched_at = datetime.now(timezone.utc).isoformat()
_cache[key] = (time.monotonic(), fetched_at, data)
return {"entries": data, "fetched_at": fetched_at}
except Exception as e:
print(f"[fantasycalc] fetch failed for {key}: {e}")
if hit: # stale-if-error: old market values beat no values
return {"entries": hit[2], "fetched_at": hit[1]}
return {"entries": None, "fetched_at": None}
221 changes: 221 additions & 0 deletions engine/nfl_dynasty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"""Deterministic dynasty logic for the NFL roster view: age-curve bands,
FantasyCalc param derivation and joins, positional depth flags, and the
roster-window verdict. Pure functions over already-loaded data — no I/O and no
AI, so the always-on roster page costs nothing per view. Unit-test target
(tests/test_nfl_dynasty.py)."""

from collections import Counter

# Per-position (ascending ≤, prime ≤, aging ≤) age edges; older = cliff.
# K/DEF get no curve — age barely moves their dynasty value.
AGE_BANDS = {
"RB": (22, 25, 27),
"WR": (23, 28, 30),
"TE": (24, 29, 31),
"QB": (24, 32, 35),
}

_STAGE_SCORE = {"ascending": 0.0, "prime": 1.0, "aging": 2.0, "cliff": 3.0}

# Sleeper roster_positions entries that aren't starting lineup slots.
_NON_STARTING = {"BN", "TAXI", "IR"}

# Statuses that can actually take a lineup slot this week.
_STARTABLE = {"starter", "bench"}


def age_band(position: str | None, age) -> str | None:
"""ascending | prime | aging | cliff, or None (no curve / unknown age)."""
edges = AGE_BANDS.get(position or "")
if not edges or not isinstance(age, (int, float)):
return None
ascending, prime, aging = edges
if age <= ascending:
return "ascending"
if age <= prime:
return "prime"
if age <= aging:
return "aging"
return "cliff"


def derive_fc_params(rules: dict) -> dict:
"""FantasyCalc query params from a synced Sleeper rules blob. `ppr` is the
raw rec value (leagues run 0.25, 1.5, …) snapped to FantasyCalc's 0/0.5/1;
numQbs counts QB + SUPER_FLEX slots so true 2-QB leagues also price QBs as
premium."""
rules = rules or {}
positions = rules.get("roster_positions") or []
qb_slots = sum(1 for p in positions if p in ("QB", "SUPER_FLEX"))
num_qbs = 2 if (qb_slots >= 2 or rules.get("superflex")) else 1
raw = rules.get("ppr")
raw = raw if isinstance(raw, (int, float)) else 0
ppr = 1 if raw >= 0.75 else (0.5 if raw >= 0.25 else 0)
size = rules.get("league_size")
num_teams = int(size) if isinstance(size, (int, float)) and size else 10
return {"num_qbs": num_qbs, "ppr": ppr, "num_teams": num_teams}


def index_fc(entries: list | None) -> tuple[dict, dict]:
"""Split a FantasyCalc response into lookups: players keyed by sleeperId
(exact match to our roster item ids) and picks keyed by name — generic
future-pick names ("2027 1st") exactly match compute_pick_inventory labels.
Slot-specific current-year picks ("2026 Pick 1.01") simply never match."""
players: dict[str, dict] = {}
picks: dict[str, dict] = {}
for e in entries or []:
p = e.get("player") or {}
if p.get("position") == "PICK":
picks[(p.get("name") or "").strip()] = e
else:
sid = str(p.get("sleeperId") or "")
if sid:
players[sid] = e
return players, picks


def starting_slots(roster_positions: list | None) -> list[str]:
return [p for p in roster_positions or [] if p and p not in _NON_STARTING]


def depth_flags(players: list[dict], rules: dict) -> list[dict]:
"""At most 3 deterministic lineup-construction warnings, worst first, from
startable bodies (taxi/IR excluded) vs dedicated slots. FLEX-type slots are
ignored on purpose: a dedicated slot can only be filled by its position, so
`have < need` is a real hole regardless of flex math."""
rules = rules or {}
slots = starting_slots(rules.get("roster_positions"))
dedicated = Counter(s for s in slots if s in ("QB", "RB", "WR", "TE", "K", "DEF"))
available = Counter(
p.get("position") for p in players if p.get("status") in _STARTABLE
)
superflex = bool(rules.get("superflex")) or "SUPER_FLEX" in slots

flags: list[dict] = []
qb_critical = False
for pos in ("QB", "RB", "WR", "TE", "K", "DEF"):
need, have = dedicated.get(pos, 0), available.get(pos, 0)
if need and have < need:
qb_critical = qb_critical or pos == "QB"
flags.append({
"level": "critical",
"text": f"Only {have} startable {pos} for {need} {pos} "
f"slot{'s' if need != 1 else ''} — you can't field a legal lineup.",
})
if superflex and not qb_critical and available.get("QB", 0) < 2:
flags.append({
"level": "critical",
"text": f"Superflex league with only {available.get('QB', 0)} startable QB — "
"a second QB is the top roster priority.",
})
for pos in ("QB", "RB", "WR", "TE"):
need, have = dedicated.get(pos, 0), available.get(pos, 0)
if pos == "QB" and superflex:
continue # QB thinness in superflex is already the flag above
if need and have == need:
flags.append({
"level": "warn",
"text": f"No depth behind your {pos} starter{'s' if need != 1 else ''} "
f"({have} rostered for {need} slot{'s' if need != 1 else ''}).",
})
flags.sort(key=lambda f: 0 if f["level"] == "critical" else 1)
return flags[:3]


def roster_window(players: list[dict], picks: list[dict], rules: dict) -> dict:
"""One-line dynasty window verdict: value-weighted life-stage of the core
(top-N by market value, N = starting slots so chaff doesn't skew) plus pick
capital's share of total asset value. Taxi/IR players count — a stashed
rookie is part of the young core."""
n = max(1, len(starting_slots((rules or {}).get("roster_positions"))))
valued = [p for p in players if p.get("value") and p.get("band")]
core = sorted(valued, key=lambda p: p["value"], reverse=True)[:n]
if not core:
return {"verdict": None, "detail": "No market values available to read the window."}

core_value = sum(p["value"] for p in core)
pick_value = sum(pk.get("value") or 0 for pk in picks)
stage = sum(_STAGE_SCORE[p["band"]] * p["value"] for p in core) / core_value
pick_share = pick_value / (core_value + pick_value) if core_value + pick_value else 0.0

if stage < 0.9:
verdict, blurb = "Ascending", "young core still gaining value"
elif stage <= 1.5:
if pick_share >= 0.15:
verdict, blurb = "Balanced", "prime core backed by real pick capital"
else:
verdict, blurb = "Win-now", "prime core, light on picks — push your chips in"
else:
verdict, blurb = "Aging — sell high", "core past its prime; move vets while they still hold value"
return {
"verdict": verdict,
"detail": f"{blurb} (core life-stage {stage:.1f}/3, picks {round(pick_share * 100)}% of asset value)",
"stage": round(stage, 2),
"pick_share": round(pick_share, 3),
}


def build_payload(roster_items: list | None, draft_picks: list | None,
rules: dict, fc_entries: list | None) -> dict:
"""The ready-to-render /dashboard/nfl_roster body (minus team_name /
values_updated_at, which the endpoint adds). Joins are best-effort: players
or picks FantasyCalc doesn't know keep value None and still render."""
fc_players, fc_picks = index_fc(fc_entries)

players = []
for it in roster_items or []:
e = fc_players.get(str(it.get("id") or ""))
meta = (e or {}).get("player") or {}
age = it.get("age")
if not isinstance(age, (int, float)):
age = meta.get("maybeAge") # backfill: Sleeper dump ages go missing
yexp = it.get("years_exp")
players.append({
"id": it.get("id"),
"name": it.get("name"),
"position": it.get("position"),
"team": it.get("team"),
"status": it.get("status") or "bench",
"injury_status": it.get("injury_status"),
"age": age,
"band": age_band(it.get("position"), age),
"rookie_tag": "Rookie" if yexp == 0 else ("2nd yr" if yexp == 1 else None),
"value": (e or {}).get("value"),
"overall_rank": (e or {}).get("overallRank"),
"pos_rank": (e or {}).get("positionRank"),
"tier": (e or {}).get("maybeTier"),
"trend": (e or {}).get("trend30Day"),
})

picks = []
for pk in sorted(
draft_picks or [], key=lambda p: (p.get("season") or 0, p.get("round") or 0)
):
e = fc_picks.get((pk.get("label") or "").strip())
picks.append({
"label": pk.get("label"),
"season": pk.get("season"),
"round": pk.get("round"),
"value": (e or {}).get("value"),
"trend": (e or {}).get("trend30Day"),
})

groups: dict[str, list] = {"starter": [], "bench": [], "taxi": [], "ir": []}
for p in players:
groups.get(p["status"], groups["bench"]).append(p)
for group in groups.values():
group.sort(key=lambda p: (-(p.get("value") or 0), p.get("name") or ""))

valued_picks = [p for p in picks if p["value"]]
return {
"fc_available": bool(fc_entries),
"players": groups,
"picks": picks,
"pick_capital": {
"total": sum(p["value"] for p in valued_picks),
"valued": len(valued_picks),
"unvalued": len(picks) - len(valued_picks),
},
"window": roster_window(players, picks, rules),
"depth_flags": depth_flags(players, rules),
}
Loading
Loading