Blends three independent ADP feeds into one master board, then layers a live draft-day assistant on top.
pip install -r requirements.txt
python main.py # builds the ADP board
python -m streamlit run draft_gui.py # live draft tracker, at localhost:8501
Reference data (offense and bye-week rankings) is already filled in for
2026, nothing to configure to try it. Re-run python main.py any time for
fresher ADP numbers; board writes to output/adp_master_2026_ppr_12tm.csv.
| Feed | Population | Auth | Notes |
|---|---|---|---|
| Fantasy Football Calculator | Mock drafts on FFC's site, computer picks stripped | none | Free for personal + commercial use, attribution requested, updates once daily |
| MyFantasyLeague | Real home-league drafts run on MFL | none | No imported drafts accepted, so genuinely independent. Cache, don't retry on failure, watch for 429 |
| ESPN (undocumented v3) | Drafts on ESPN's platform | none | X-Fantasy-Filter header mandatory or you get 50 players. Schema can change without notice |
None of the three aggregates the others, so blending them isn't double counting. They are correlated (same news cycle, same rankings sites), which is expected and fine.
Drop a CSV into reference/custom_sources/ with player and adp columns
(position and team too, recommended, they're what makes matching
reliable) and it's automatically blended in as one more feed, no code to
write. The filename becomes the source tag, so friends_mock.csv shows up as
column adp_friends_mock, folded into adp_master/adp_rank and the source
agreement table right alongside FFC/MFL/ESPN. Team defenses in a custom file
are matched by team code same as the built-in feeds; everyone else by name.
Unlike the three fetched feeds, a custom source has no sample-size concept,
so it never gets filtered by MIN_DRAFT_PCT, a hand-typed list is trusted at
face value.
Adding a genuinely new live source (another site's API, not a static list)
means writing a sources/yourname.py with a fetch() following the pattern
in sources/ffc.py, then wiring it into main.py's collect() the same way
FFC is. aggregate.py doesn't hardcode a source count, so nothing else needs
to change.
adp_rank adp_slot adp_master player pos team off_rank off_flag
bye_week adp_ffc adp_ffc_stdev adp_mfl adp_espn sources_n
adp_spread mfl_id
- adp_master: median of the raw ADPs. Read this as "where he goes".
- adp_rank: median of within-source ranks, re-ranked. Use for ordering; ranks are the scale-safe comparison across feeds with different depth.
- adp_spread: max minus min across feeds. Big spreads mean platform format is driving the price, not consensus.
- off_flag:
TOP5/BOT5from youroffense.csv. - adp_ffc_stdev: FFC's per-player ADP standard deviation, used by the live draft tracker's survival-probability calc.
reference/offense.csv (team, off_rank) and reference/byeweeks.csv
(team, bye_week). Neither has a free API worth trusting. 32 rows each,
validated hard on load (offense ranks exactly 1-32, byeweeks just needs every
team present). A half-filled table crashes rather than quietly producing a
plausible-looking board. Re-check offense.csv after final cuts.
Starting a new season from scratch: python main.py --templates writes blank
versions of both, ready to fill in.
python -m streamlit run draft_gui.py (see Quickstart above), a local,
manual-entry app, no ESPN integration, no network, that you run
alongside your real draft and update pick by pick. You only enter who got
picked, with one click: search narrows the "Draft a player" list, click
Draft on a row. Or use the quick draft box at the top: type a name and
press Enter (or click "Draft top match") to draft the best-ranked undrafted
match immediately, no scrolling. Whether it was your pick is derived
automatically from the pick count and your draft slot (standard snake order).
Only your own roster is tracked, not the other 11.
Your draft slot is set live in the sidebar (My draft slot), no restart
needed. Ownership of each recorded pick is recomputed from the current slot
on every load, so correcting your slot mid-draft re-attributes your roster
instead of leaving stale picks behind. ROSTER_SLOTS (starting lineup +
bench, currently QB1 RB2 WR2 TE1 FLEX1 K1 DST1 BN6) lives in config.py.
On top of the static board:
- tier: within-position groups; a new tier starts when the
adp_rankgap to the previous player exceedsconfig.TIER_GAP_RANK. Resets per position, so a WR tier 2 isn't the RB tier 2. Fixed against the full pre-draft board so it can't fracture as players come off it. - value_delta: pick number minus
adp_rank, also shown in fractional rounds. Positive means falling past consensus. - survival_prob: modeled probability the player lasts to your next
meaningful pick, using FFC's real per-player stdev where available,
adp_spread / 4as a rough fallback otherwise. Conditioned on "still here right now", not the raw unconditional tail, so it never quotes a player's ADP distribution as if the picks already made hadn't happened. At a snake-draft turn (back-to-back picks, e.g. the last pick of a round and the first of the next both landing on you), the first of the pair reads ~100% (nobody else acts before your very next pick), but the second skips past that trivial continuation and reflects real uncertainty about your following turn instead, since that's the next point another team actually gets to act. - Recent picks: a position-colored trail of the last 15 picks league-wide, hover for the name.
- positional-run banner: flags a position at 3+ of the last 6 picks.
- tier depletion: each row's tier badge shows "X/Y left", color-coded by urgency, counted against that player's fixed tier group.
- My Roster (sidebar): starters filled before bench, in draft order, plus a warning when 2+ starters share a bye week.
- NEED badge: flags a player whose position fills one of your still-open
starter slots (including FLEX for RB/WR/TE). Bench needs don't count,
since almost every remaining player "needs" bench eventually and that's
not a useful signal. Also a
fills_needcolumn in the "Board" table.
- Biggest Fallers: leaderboard of players falling most past consensus,
ranked by
scarcity_value(below), only once pastconfig.VALUE_FALL_THRESHOLD(default 5 picks). - scarcity_value:
value_deltamultiplied by how depleted that player's tier is (1x untouched, up to 2x nearly gone). - ACT NOW / VALUE, SAFE badges: combines
value_deltawithsurvival_prob. Falling and unlikely to survive (config.ACT_NOW_SURVIVAL, default 30%) flags red; falling but safe to wait (config.SAFE_VALUE_SURVIVAL, default 50%) flags green. - My Draft Value (sidebar): running self-scorecard across your own picks, average value, best pick, biggest reach. No opponent comparison.
Simulate 1 CPU pick / Simulate to my next pick auto-fill other teams' picks so you can rehearse a full draft without recording all 11 opponents. Both disabled on your own turn.
Once your roster is completely full, an "Auto-finish draft with CPU
picks" button appears to fill the remaining rounds in one click. It's a
button, not automatic: this app also tracks real drafts, and auto-firing CPU
picks the instant your roster fills would silently overwrite real opponents'
picks you're still recording. Once the whole draft
(TEAMS x sum(ROSTER_SLOTS) picks) is done, a "Draft complete" banner
replaces the pick-entry sections; roster, value scorecard, and the final
board stay visible.
Start Over (sidebar) clears the pick log for a new draft. Gated behind a "Yes, clear this draft" checkbox since it's destructive. Keeps your draft slot; only the picks reset.
CPU picks are stateless on purpose, no roster-need modeling for opponents,
just a weighted random draw from the top config.MOCK_POOL_SIZE available
players by adp_rank (geometric decay via config.MOCK_DECAY). Mostly the
consensus top player, occasionally a slight reach or fall.
State lives in draft/session_{SEASON}.json. Delete it, or use Start Over,
for a fresh session.
Noted but not built, mostly because the data isn't free the way ADP is:
- Real value-based drafting (VBD): needs actual point projections, not just ADP.
- Handcuff / depth-chart awareness: needs a depth-chart feed we don't have.
- Opponent-need-aware pick suggestions: blend value, tier, open roster slots, and bye fit into one "best pick right now" score.
- Day-over-day ADP drift:
cache/already stores dated JSON snapshots; diffing them would show risers/fallers over the preseason for free.
In config.py:
MIN_SOURCES = 2: appear in at least 2 of 3 feedsMIN_DRAFT_PCT = 5.0: per-source floor; a player taken in 3% of drafts has an ADP, but it's the price three people paid, not a marketTOP_N_FLAG = 5
- Name matching. FFC has no shared player id, so it goes through
normalised name + position. Rookies and suffix changes break first. The
run prints unmatched players lowest-ADP-first; add them to
ids.MANUAL_MFL_IDSrather than loosening the matcher. Team defenses are handled separately: DynastyProcess's crosswalk has zero DST rows, so they're matched by team code instead, viasources.mfl.fetch_dst_ids()andids.attach_dst_mfl_id. - MFL early preseason. The filtered pool (
IS_MOCK=0,IS_KEEPER=0) can be genuinely empty in July. TryPERIOD=ALLor a lowerCUTOFF. - ESPN autodraft. ESPN doesn't say whether autodrafted picks are excluded. The run prints the Spearman between ESPN ADP and ESPN's own editorial rank; above ~0.98 means the feed is partly restating ESPN's list, and you should think about down-weighting it.
ADP data courtesy of Fantasy Football Calculator, MyFantasyLeague and ESPN. Player ID crosswalk from DynastyProcess.