Skip to content

Changelog

All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • --deltas rooting guide in the HTML report (#99): --format html --deltas previously dropped the per-game deltas silently, because only the Excel workbook had a Deltas sheet. FantasyHtmlExporter now has export_deltas (same signature as the Excel exporter), and the fantasyfb CLI feeds the deltas to every active exporter. The report gains a Rooting guide tab, shown only when --deltas ran. For the team picked in the Viewing selector it lists this week's games (their own game first) with the change in that team's expected earnings if either side wins and a "Root for ..." call, followed by the full league-wide matrix shaded red to green around zero. Test coverage added.

[0.10.0] — 2026-10-06

Interactive HTML report, game-day cache freshness, and live-week projection fixes.

Added

  • Interactive HTML report as an alternative to the Excel workbook (#93): a new FantasyHtmlExporter (fantasyfb.io.html_exporter) has the same shape as FantasyExcelExporter (export_rosters, export_available, export_schedule, export_standings, export_analysis, close) but writes one self-contained .html file. The tables are embedded as a JSON blob and rendered client-side by inlined vanilla JS and the "Industry" design-system CSS, so the report works offline and can be emailed (only Google Fonts is fetched, with a system-font fallback). Tabs: My week (matchup card with score distributions, outlook, season path, lineup and bench), Standings (sortable, click a team to scope the whole report to it), Schedule (week-by-week matchup cards), Moves (Adds, Pickups, Drops and Trades, shown only for the analyses that ran) and Free agents (search, position filter, minimum rostered % slider defaulting to 5%, WAR-sorted). The Viewing selector rescopes the report to any team, and the tab and team are kept in the URL hash. For --bestball runs, which have no schedule, the matchup, season path and Schedule views are hidden.
  • --format excel|html|both flag on fantasyfb (#93, default excel, so existing behavior is unchanged): html writes FantasyFootballProjections_<Weekday>Week<N>.html next to where the workbook would go, both writes both. Deltas remains Excel-only.
  • --drop-safe-threshold and --drop-depth-threshold (defaults -4 and -12): the dollar cutoffs behind the Drops view's "Safe to cut" / "Depth" / "Keep" verdicts in the HTML report.
  • jinja2>=3.1 is now a runtime dependency (used to render the report template, which ships as package data).

Changed

  • Shorter NFL data cache on game days (#95): the fantasyfb CLI now uses a 30 minute cache TTL instead of nflreadpy's 24 hour default in September through January on every day except Wednesday and Friday (is_live_nfl_window), so rerunning on Sunday or Monday picks up newly finished games and nflverse stat corrections without --refresh-cache. Before this, a team whose players had all finished could still show a nonzero chance of losing because the cached schedule and box scores predated the final whistle. Wednesday, Friday, and the offseason keep the 24 hour default. An explicit cache_duration argument or the NFLREADPY_CACHE_DURATION environment variable still wins, and --refresh-cache still forces a fresh pull. New live_cache_duration argument on NflreadpyProvider. Test coverage added.

Fixed

  • IR-slotted players counted as extra starters during a live week (#92): LineupOptimizer._handle_live_week_lineup treated any rostered player not in a BN slot whose NFL game had finished as "already started", which included players parked on IR. They were locked in with starter=True on top of a full lineup, so their projection was added to the team's live-week total. In week 3 of 2026, Nico Collins and Rico Dowdle both sat on IR and pushed finished teams' projections above their final Yahoo scores. Inactive slots (BN, IR, IR+, NA) are now excluded from both the started set and the lineup candidates. Test coverage added.
  • Team defenses never matched their stats, so every DEF was projected as a league-average defense (#92): PlayerDataManager.map_player_ids set a defense's player_id_sr to the platform's display name (Yahoo's "Chiefs"), while NflreadpyProvider keys team-DEF stat rows by team code ("KC"). The join never matched, so every defense had num_games=0 and fell back to the positional average projection all season, and during a live week no defense ever got its actual points substituted in (the Chiefs kept a 10.2 projection after their game ended). Defenses are now keyed by current_team. League.get_rates keeps the platform's display name rather than renaming defenses to their team code. Test coverage added.
  • Kickers in distance-scored leagues got an extra 3 points per made field goal (#92): apply_default_scoring_categories added FG 0-19 = 3 whenever that key was missing, even for leagues that score kicks only by FG Yds (0.1 per yard). Every made field goal was double-counted, overprojecting kickers all season and overstating live-week actuals by 3 to 9 points per kicker in week 3 of 2026 (e.g. Will Reichard at 26.3 vs Yahoo's 17.3). The flat default now applies only when the league has no FG * scoring category at all. Test coverage added.

[0.9.0] — 2026-09-15

Removed

  • Dead V1 CLI flags --games and --basaloppstringtime (#85): both were parsed and accepted but silently ignored downstream, League.load_parameters() explicitly documented them as "accepted for backward compatibility with the V1 CLI but ignored downstream." A user passing either flag would reasonably expect their projections to change; nothing happened. Removed from cli.py's argument parsing (including the basaloppstringtime comma-split/validation block), the corresponding League.__init__/load_parameters parameters and placeholder self.reference_games/self.basaloppstringtime attributes, and their rows in the CLI reference docs. Breaking change: passing either flag now raises optparse's "no such option" error instead of silently doing nothing. Fixes #83.

Fixed

  • MoveAnalyzer.possible_pickups crashed with a TypeError for any healthy drop candidate (#86): "'>=' not supported between instances of 'NoneType' and 'int'". until is NaN/None by default for a player with no active injury designation (set only when PlayerDataManager.add_injuries finds one), but the "is this player out through the current week" check compared it directly against an int with no null guard, unlike the free-agent-availability filter two lines earlier in the same method, which already handled this correctly via until.isnull() | (until < 17). A null until now takes the same branch a healthy player should: only free agents within 0.5 WAR of the player being dropped are considered, rather than "any free agent is an upgrade" (reserved for a player who's actually out and contributing nothing this week). First test coverage added for MoveAnalyzer, which is how this went unnoticed.
  • 2025+ depth-chart schema labeled kickers "PK" instead of "K" (follow-up to #78): nflreadpy's newer depth-chart feed uses pos_abb value "PK" for kickers, while everywhere else in fantasyfb (the players DataFrame, the legacy pre-2025 depth-chart schema, MatchupModel's _STARTER_SLOTS/_DEFAULT_WEIGHTS) keys on "K". get_depth_charts() didn't normalize this, so the live feed returned zero "K" rows and PlayerDataManager.add_depth_charts's name+position fallback could never match a kicker, silently defaulting every one of them to string=2.0. Currently harmless in practice since K's gamma weight is 0.0 (the string penalty never fires for kickers regardless), but would misfire the moment that weight is tuned away from 0. get_depth_charts() now normalizes "PK" to "K" right after the schema-specific rename.
  • Already-started players in a completed game showed as benched during a live week (follow-up to #18): LineupOptimizer._handle_live_week_lineup correctly identifies players whose game has finished and who were active (selected_position != "BN") in the real platform lineup, but only ever used that set to compute remaining roster capacity -- nothing set players["starter"] = True for them, so they fell back to the starter=False default despite being legitimately active. That silently broke every downstream consumer of the starter flag: League._apply_live_week_actuals's real-score blending never substituted in their actual points, and season_sims never counted their points toward the week's team total at all. First test coverage added for LineupOptimizer (previously untested), which is how this went unnoticed.
  • Depth-chart rows fanned out into duplicates for any player with a secondary role (follow-up to #78): nflreadpy's depth chart has one row per (player, role) -- a WR who also returns punts, or an RB who also returns kicks, has multiple rows sharing the same player_id_sr. get_depth_charts() joined these onto the players DataFrame with no dedup, so 200+ players league-wide were duplicated downstream. That corrupted LineupOptimizer's per-position slot-filling: two rows for one real player could each independently pass the "not yet a starter" filter, silently consuming a roster slot meant for a different, lower-scoring teammate. A new _dedupe_depth_chart() collapses to one row per player_id_sr, preferring the fantasy-relevant offensive/kicking role over a special-teams-only one (a naive "lowest string wins" rule doesn't work -- a KR/PR string is often numerically lower than a player's real offensive string).
  • MatchupModel's depth-chart string penalty crushed real WR2/WR3/RB2/TE2 starters (#78): the flat max(0, string - 1) penalty treated nflreadpy's pos_rank (a full ordinal depth ranking, 1-9+) as a starter/backup flag for every position, when that assumption only holds for QB -- real NFL offenses routinely start 2-3 WRs and use multiple RBs. Legitimate starting WR2s/WR3s/RB2s/TE2s were penalized as if they were backups, corrupting points_avg, lineup selection, and season_sims for every week of the season. Replaced with a per-position "real starter slots" cutoff (QB: 1, RB: 2, WR: 3, TE: 2) below which no penalty applies, tapering in beyond that. Also fixed sim/backtest.py's hardcoded string=1.0, which meant the walk-forward fit never actually exercised the string penalty: get_depth_charts() now accepts an optional (season, week) to look up real historical depth-chart data (available back to at least 2015, contrary to a since-removed comment claiming otherwise).
  • Current-week matchup projections ignored points already on the board mid-slate (#18): during a live week, the season simulator summed every starter's pre-game points_avg for the current week even after their NFL game had finished, so a matchup's projected score and win probability stayed frozen at their pre-kickoff values as Sunday unfolded. Added League._apply_live_week_actuals: for the active season's current week, any starter whose NFL game has finished gets their actual fantasy points substituted in as points_avg with points_stdev set to 0, so that part of the matchup becomes deterministic and only pending games still carry variance.

[0.8.2] — 2026-08-26

Fixed

  • ADP merge silently dropped every suffixed player name (#68): merge_adp joined projections onto ADP with an exact string match on [name, position]. FantasyPros-style ADP exports append generational suffixes (James Cook III, Travis Etienne Jr.) that the projections' nflreadpy-sourced name column omits, so ~two dozen players a season, including early-round starters like James Cook (RB5), Kenneth Walker III (RB12), and Travis Etienne Jr. (RB20), silently got NaN ADP and were treated as undraftable depth. A new _normalize_name_key helper builds a suffix-stripped, whitespace-collapsed, lowercased join key used for the merge only; both display names are preserved untouched. The ADP side is deduped on that key (earliest pick wins) so a collapsed namesake pair can't fan out a projection row, and the suffix regex is anchored so an ordinary trailing name token is never stripped.

[0.8.1] — 2026-08-24

Fixed

  • current_team stuck on a player's previous team during the offseason/preseason (#65): PlayerDataManager.add_depth_charts already fetched nflreadpy's live depth chart, which carries the correct current_team, but only pulled its string (depth-chart rank) column out, leaving current_team to come from the season-locked roster snapshot everywhere. That snapshot doesn't reflect a trade/signing until nflverse publishes the new season's roster file, well after free agency, so a player who changed teams (e.g. Stefon Diggs to WAS in 2026) still showed their old team (NE) on the draft board with no current-season games played yet to correct it. The name-based fallback join made this worse by requiring current_team to already match, which can never be true for exactly the players it exists to catch. current_team from the depth chart now overrides the stale value on both the id-based and name-based join paths; the name join is deduped on (name, position) first to avoid fanning out rows on rare same-name collisions.

[0.8.0] — 2026-08-23

Custom roster shapes for generic mock drafts, plus a fix for mock-opponent picks drifting from real-world ADP.

Added

  • --roster-spots on snake-draft and salary-cap-draft (#59): lets a --platform generic mock draft use a custom roster shape (extra flex, superflex via Q/W/R/T, custom bench size, etc.) instead of the fixed default, via comma-separated POSITION=COUNT pairs (e.g. QB=1,RB=2,W/R/T=2,Q/W/R/T=1,BN=6) or an interactive prompt when omitted. GenericClient and League(platform="generic") now accept an optional roster_spots DataFrame directly; an unrecognized position code raises ValueError at construction time instead of failing downstream in compute_vorp/Roster.

Fixed

  • random/random til me drafting far ahead of real-world ADP (#58): the candidate pool for simulated opponents was ranked top-VORP-first, with ADP only reweighting sampling within that already VORP-biased shortlist. random_pick now windows the candidate pool to players within window_rounds of the current overall pick (mirroring the nearest view) before ranking by need-adjusted VORP, falling back to the old top-VORP pool when pick_overall/num_teams aren't supplied or the window is empty.

[0.7.0] — 2026-08-18

Multi-platform league support: SleeperClient and a fully synthetic generic mock mode join Yahoo behind a shared FantasyPlatformClient interface, with --platform now available across all three draft CLIs. Also includes dual (position + flex) VORP, simadd win/earnings-delta evaluation during live drafts, and a rebranded navy/green logo.

Added

  • SleeperClient, parallel to YahooFantasyClient (#37): data/sleeper_client.py gains get_current_week, get_fantasy_teams, get_all_players, get_team_rosters, and get_schedule against Sleeper's public read API (no OAuth). player_id_sr is populated straight from Sleeper's gsis_id where available, matching the ID NflreadpyProvider already keys rosters/stats/depth-charts on, so it can join against nflreadpy output without a name-matching fallback. The full /players/nfl map is cached locally (~/.cache/fantasyfb) per Sleeper's integrator guidance against re-fetching it every run. Live smoke tests (tests/test_sleeper_client_smoke.py) hit the real public SFB16 league end-to-end; they self-skip rather than fail when Sleeper is unreachable, so sandboxed/offline environments and the default pytest -q stay green.
  • FantasyPlatformClient abstraction wires SleeperClient into League (#37): a shared read-only interface both YahooFantasyClient and SleeperClient implement, so League(platform="yahoo"|"sleeper") can pick either backend at construction time instead of hardcoding Yahoo.
  • --platform/--sleeper-league-id on snake-draft and salary-cap-draft: threads League's platform selection through both cockpits so a live draft can be tracked against a Sleeper league, not just Yahoo. Manual pick-by-pick entry is unchanged for either platform since it isn't polling either API for picks — only League construction differs.
  • --fresh-draft on snake-draft and salary-cap-draft: clears fantasy_team before the board snapshot, for mock-drafting against a league's settings/player-pool without the keeper-preservation logic (intended for resuming an in-progress Yahoo draft) treating that league's current real rosters as keepers. Needed for mock-drafting against a Sleeper league that has already completed its actual draft.
  • Generic (no Yahoo/Sleeper) mock draft mode (#47): League(platform="generic"), backed by a new GenericClient implementing FantasyPlatformClient, synthesizes teams/rosters/a round-robin regular-season schedule and pulls the real player pool (plus synthetic DEF rows) from NflreadpyProvider, reusing Sleeper's public /state/nfl endpoint for the current week. configs.py gains STANDARD_CONFIG/HALF_PPR_CONFIG/PPR_CONFIG presets (identical except Rec points) with a fixed roster (1 QB, 2 RB, 2 WR, 1 TE, 1 FLEX, 1 K, 1 DEF, 7 BN). Useful for sanity-checking VORP rankings against a familiar scoring baseline instead of an unusual one like SFB's, or for running any of the three draft CLIs with no live league credentials at all. Wired into snake-draft, salary-cap-draft, and draft-prep via --platform generic --num-teams --mock-scoring; generic is now the default platform when --platform is omitted (previously Yahoo was implied).
  • simadd command on snake-draft and salary-cap-draft (#39, #43): sims the top-N available candidates per position and ranks them by win/playoff/earnings delta from a cached baseline, wiring the existing MoveAnalyzer season-sim engine into the live draft loop so users can evaluate a specific pick's impact during slow drafts without committing to it. Sim count is dropped to 1000 during per-pick evaluation and restored after (matching the original V1 script's approach); K/DEF are excluded from candidate pools since their sim delta is noise. On snake-draft, --bestball (bare defaults to underdog; --bestball dk uses DraftKings settings) switches sim/simadd/the end-of-draft summary from season_sims to bestball_sims. On salary-cap-draft, since adding a candidate costs cap space rather than being free, each candidate is priced at its max_my_bid (inflated market value clipped to the user's remaining budget) before the hypothetical roster add is simulated.
  • best/simadd prompt for pool and scoring instead of separate commands (#40): replaces the earlier nearest/bestball/nearestbestball/nearestsimadd/bestballsimadd commands on snake-draft with interactive prompts — pool (best available vs. nearest ADP) and, outside --bestball drafts, scoring (normal vs. bestball upside). Bestball drafts skip the scoring prompt since bestball scoring is mandatory in that mode. Prompts now spell out the literal choice words (e.g. (best/nearest) [best]) rather than relying on prose alone. simadd also gains a per-run "players per position to simulate?" prompt (defaulting to --simadd-limit, still 3) so slow drafts can simulate more candidates and fast ones fewer without restarting with a different flag.
  • ADP blended into random/random til me's auto-pick sampling weights: previously sampled purely by need-adjusted VORP, ignoring market ADP entirely, so a model-favorite with poor market consensus was weighted the same as a widely-agreed top prospect. Sampling weight is now vorp_adjusted * (1/adp), so a player needs to look good by both signals to be picked often; the candidate pool itself (top-N by need-adjusted VORP) is unchanged. Players missing from the ADP source fall back to double the worst real ADP in the available pool, so they read as deep depth rather than tying for a top-ADP player.
  • Sample ADP CSV (examples/) as a reference for draft-prep's --adp flag, trimmed to the columns load_adp_csv actually reads (Player, POS, Team, AVG) rather than a full FantasyPros export, to avoid redistributing proprietary per-source data.
  • Persistent nflreadpy caching for offline draft prep (#50): NflreadpyProvider now defaults nflreadpy's cache to filesystem mode (24h TTL) instead of its own in-memory default, which was wiped at the end of every process and therefore provided no benefit across the many separate CLI invocations a draft-prep session runs. Once a pull succeeds, subsequent pulls within the cache window reuse it without a network connection. draft-prep, snake-draft, and salary-cap-draft gain a --refresh-cache flag to force a fresh download for a single run; an explicit NFLREADPY_CACHE env var (or a caller's own nflreadpy.update_config()) still takes precedence over the new default.
  • YahooFantasyClient.list_team_names(season): lists the user's fantasy team name(s) for a season without connecting to a league or picking one. Lets a caller (e.g. a UI) offer a picker before connect_to_league/League(name=...) needs one — previously, an account with multiple teams for a season could only be resolved via connect_to_league's interactive input() prompt, which hangs in any non-interactive context (e.g. a Streamlit app). connect_to_league now shares its league-lookup logic with the new method via a private _find_nfl_league_teams helper instead of duplicating it, and now raises a clear ValueError when no NFL league is found for the season instead of silently falling through with a stale/None league id.

Changed

  • --platform flag added to draft-prep, matching snake-draft/salary-cap-draft (#53): _build_league previously called fb.League(...) with no platform kwarg, so it always fell through to the Yahoo default — draft-prep hung indefinitely without live Yahoo credentials and had no way to run against Sleeper or a generic mock league. Ports the same --platform/--sleeper-league-id/--num-teams/--mock-scoring wiring already used by snake-draft and salary-cap-draft.
  • League()'s single-source version now comes from importlib.metadata instead of a hardcoded __version__ string, so pyproject.toml is the only place the version needs updating.
  • Rebranded logo: replaced the navy/gold snake-and-shield mark with a simpler navy/green "FFB51" football outline, cropped from its source photo and set as the new assets/fantasyfb_logo.png master (regenerated docs/assets/fantasyfb_logo.png and docs/assets/favicon.png via scripts/build-assets.py). docs/stylesheets/extra.css's Material palette now pulls navy #18304a / green #79ac57 from the new mark instead of the old navy #06152b / gold #b29d76.

Removed

  • --sfb CLI flag (#48) from snake-draft and draft-prep, along with the League(sfb=...) kwarg, configs.SFB_CONFIG, and configs.get_sfb_config_from_sleeper(). Now that --platform sleeper --sleeper-league-id <id> (added above) covers pulling a live Sleeper league's scoring/roster settings — including SFB leagues — through SleeperClient.get_league_config(), the separate --sfb overlay mechanism was redundant. Breaking change to League()'s signature and both CLIs. Historical SFB13–16 configs were preserved outside the repo (a gitignored notes/ dir) for posterity, not in the codebase.
  • PlayerDataManager.apply_name_corrections: fetched a legacy name_corrections.csv straight from raw.githubusercontent.com on every League construction, entirely outside NflreadpyProvider's persistent caching, purely to patch stale player-name spellings from the pre-nflreadpy, Pro-Football-Reference-based name-matching era. map_player_ids has linked players by yahoo_id/gsis_id instead of name for a while now, and snake-draft/salary-cap-draft's V2 rewrites had already dropped the equivalent inline fetch on that same assumption — this was the one remaining caller still carrying the dead weight. Surfaced as an offline-draft-prep crash (see Fixed below) before being removed outright; process_players/League._process_players no longer need the stats argument that only this step consumed.

Fixed

  • PlayerDataManager.map_player_ids ignoring Sleeper-resolved IDs: was entirely Yahoo-ID-shaped and ignored the player_id_sr that Sleeper already resolves via gsis_id, surfaced while live-testing the new SleeperClient integration against a real Sleeper league.
  • ScheduleManager._clean_schedule team/score swap could corrupt score_2: a pandas block-aliasing bug in the tuple-assignment swap, pre-existing and unrelated to the Sleeper work but caught along the way during the same live-testing pass.
  • NflreadpyProvider.get_depth_charts breaking offline draft prep on a cold cache: unlike get_schedule/get_rosters/get_player_stats, it always requested the current calendar year's depth-chart file directly instead of going through the season-clamping the others use, and had no fallback for a download failure — only for a successful-but-empty response. With no network and no cached depth-chart file yet, this raised a raw ConnectionError/ValueError straight out of League construction, defeating the persistent filesystem caching added above. It now degrades to an empty depth-chart frame in that case, same as the other providers' missing-season handling, so a second offline run completes rather than crashing.
  • PlayerDataManager.add_injuries breaking offline draft prep: fetches a small CSV directly from raw.githubusercontent.com, entirely outside NflreadpyProvider's persistent caching, with no fallback for a dead network. A --refresh-cache-free, fully offline second run (real-world repro: nflreadpy's own cache warm, wifi off) crashed with urllib.error.URLError from inside this step. Now catches OSError (covers bare urllib's and requests' connection failures alike), warns, and skips the injury-timespan enrichment rather than raising — it's optional signal, not required for a draft-prep session to complete.
  • connect_to_league connecting to the wrong league when a season's teams span multiple leagues: Yahoo groups a user's teams for a season under one response entry even when they belong to entirely different leagues (e.g. two teams in the same season, two different team_key league-id segments). The multi-team branch derived the league id from an arbitrary team's team_key rather than the selected team's own, so picking the second team by name could silently connect you to the first team's league instead. _find_nfl_league_teams now maps each team name to its own league id.
  • Dual VORP (position-specific + flex): compute_vorp now emits three additional columns — flex_replacement_rate, vorp_flex_per_game, and vorp_flex_season — alongside the existing position-only columns. The new compute_flex_replacement_levels function sets each position's replacement baseline against the combined pool of all players eligible for the deepest flex slot covering that position. In a superflex (Q/W/R/T) league this correctly compresses QB VORP to reflect that the superflex slot would otherwise be filled by a WR/RB/TE rather than another QB. All draft decision surfaces (vorp_adjusted in cockpit views, salary cap dollar values, mock draft pick strategies) now use vorp_flex_per_game/vorp_flex_season as their primary signal. For positions with no flex slot (K, DEF) and for QB in a non-superflex league, flex VORP equals position VORP so there is no behavioral change in those cases.

[0.6.0] — 2026-07-09

Sleeper-native scoring for SFB-style leagues (Third-Round Reversal, stacking yardage bonuses), V2 projection engine now the sole engine after winning the V1 bake-off (#24), and a new MkDocs documentation site (#21).

Added

  • Docs site (#21): MkDocs Material site at https://tefirman.github.io/fantasyfb/. Covers install, Yahoo OAuth setup, an end-to-end first-weekly-report walkthrough, full CLI reference for all four entry points, an architecture overview, and auto-generated API reference via mkdocstrings. Built and --strict-validated on every PR; deployed to GitHub Pages on push to main.
  • scripts/build-assets.py: regenerates the docs-site logo variants (web hero + favicon) from the high-res master at assets/fantasyfb_logo.png.
  • Sleeper scoring/roster settings: data/sleeper_client.py pulls a league's scoring, roster spots, and Third-Round Reversal setting directly from Sleeper's public read-only API (no OAuth) instead of hand-transcribing them. configs.get_sfb_config_from_sleeper(league_id) and League(sfb=<sleeper_league_id>) both use it to pull current settings live.
  • Third-Round Reversal support: drafts/tools.round_direction / MockDraft(reversal_round=...) and drafts/snake.snake_pick_slot(..., reversal_round=...) can express TRR draft order (matching Sleeper's own reversal_round draft setting) instead of assuming plain alternating snake. Exposed as --reversal-round on both snake-draft and draft-prep mock.
  • New yardage-bonus scoring categories: FantasyScorer now supports stacking Pass 300+/Pass 400+ and Rush 100+/200+/Rec 100+/200+ tiers, plus a combined Rush+Rec 100+/200+ bonus -- needed to accurately score SFB16's Sleeper-based ruleset. Per-play "long play" bonuses (40+ yard completions/rushes, 20-29/30-39/40+ yard receptions) are intentionally not modeled -- they require play-by-play data, not per-game box-score aggregates.
  • --sfb flag on snake-draft and draft-prep (previously only reachable via a League() kwarg, not exposed on any CLI script).

Changed

  • League(fit_matchup=True) is now the default (#24): matchup weights are ridge-fitted via walk-forward least squares on the prior season instead of using the hand-tuned defaults. A 2024 full-season walk-forward backtest showed V2_fitted beats the hand-tuned V2_default on QB/WR/K and overall RMSE.

Removed

  • V1 projection engine (#24): projections/engine.py deleted after a walk-forward bake-off confirmed V2 beats V1 by 5.7% overall MAE on the 2024 season (4.317 vs 4.576), consistent across 2023 as well. Bake-off script preserved at commit 894b6b4.

[0.5.0] — 2026-05-24

Best ball support across the draft and simulation stack (#31), and cost-plus-N keeper support across the salary cap V2 stack (#29).

Added

  • Best ball season simulation (#32): SeasonSimulator.simulate_season(best_ball=True) auto-fills optimal weekly lineups; select_optimal_lineup greedily fills fixed slots then flex; compute_best_ball_team_projections Monte Carlos per-player projections into team-level (avg, stdev).
  • Best ball cockpit views (#32): view_bestball and view_nearestbestball rank available players by upside-weighted VORP (points_rate + 0.5 × points_stdev − replacement_rate); bestball / nearestbestball wired into the snake draft pick loop.
  • Cost-plus-N keeper pricing (#33): new --keeper-surcharge flag on salary-cap-draft (default 5); keeper price = last year's salary + surcharge.
  • Keepers in salary cap V2 stack (#33): build_board accepts a keepers DataFrame and excludes keepers from the dollar pool (VORP still computed on full pool); MockSalaryCapDraft accepts a keepers DataFrame to pre-apply keepers in mock drafts; backtest_salary_values gains a keeper_names parameter to strip pre-negotiated picks from surplus / overpay calculations.
  • Per-team keeper budget validation (#33): teams whose keeper commitments exceed the cap have their keepers dropped with a warning before the draft starts. Resume-safe: keepers already present in an --inprogress file are skipped when --keepers is also passed.

Changed

  • Draft cockpits default to real Yahoo team names instead of generic placeholders (#33).
  • Keepers CSV fantasy_team column accepts the user's actual Yahoo team name (#33).

Fixed

  • _simulate_playoffs returns None early for unsupported bracket sizes (anything other than 4 or 6 playoff teams), covering DraftKings / Underdog best-ball formats with no traditional playoff bracket (#32).
  • build_board no longer raises KeyError: 'fantasy_team' when called on a bare projection pool (#33).

[0.4.0] — 2026-05-21

Salary cap draft V2 (#11). Rebuilds salary-cap-draft on top of a tested valuation layer and cockpit views, with snake-parity ergonomics and a mock salary cap draft simulator.

Added

  • Valuation primitives in drafts.tools (#25): compute_salary_values (VORP-proportional, money-conserving) and max_bid (budget-constraint helper).
  • Salary cap cockpit views (#26): build_board, compute_inflation, view_best, view_nominate, view_what_if, view_lookup, view_roster, view_budget_status.
  • Salary cap CLI rewrite (#27): argparse-based salary-cap-draft with snake-parity commands (best, nominate, whatif, lookup, roster, budgets, exclude, sim, go back, help, exit), tab completion + readline history, and inflated-value / max_my_bid surfaced in the player lookup output.
  • Mock salary cap draft simulator and backtest harness (#28): MockSalaryCapDraft (Vickrey-style bidding with value / aggressive / conservative strategies), backtest_salary_values for V1-vs-V2 surplus comparison, and simulate_nomination for in-cockpit use.
  • random / random til full commands in salary-cap-draft for auto-piloting nominations and full-draft fills.
  • Resume support for legacy V1 draft progress CSVs — --inprogress accepts the old salary column and renames it transparently.

Changed

  • Standings sorted by earnings, then wins_avg, then points_avg (#23).
  • move_analyzer refactored to list-accumulate + single concat (#22).

Fixed

  • pyarrow fallback for nflverse parquet files that polars rejects as invalid UTF-8; bad-byte string columns are sanitized before the Arrow → pandas conversion (#27, #30).
  • player_id cast to str when filling the player_id_sr fallback, fixing an arrow-string dtype crash in map_player_ids (#30).
  • _clean_schedule team/score swap uses tuple assignment to avoid dtype-coupled arrow-string crashes (#30).

Removed

  • V1 best_combos cartesian-product optimizer (replaced by view_best).
  • V1 --starterpct / --limit knobs (replaced by need scaling in view_best).
  • V1 possible_adds Monte Carlo bench loop.
  • V1 inline name_corrections HTTP fetch.
  • optparse usage in salary_cap.py (replaced by argparse).

[0.3.0] — 2026-05-11

First PyPI release.

Added

  • Projection engine V2 with Vegas-backed matchup factors and walk-forward weight fitting.
  • draft-prep CLI for pre-draft tiers, VORP, ADP value, and mock-draft sims; traps subcommand for overdrafted-player avoid lists.
  • Snake-draft cockpit V2 with VORP/tier/ADP board, tab completion, input history, and auto-pilot (random, random til me) commands.
  • --season flag for pre-draft runs targeting upcoming seasons.
  • Backtest harness comparing V1 vs V2 projections.

Changed

  • Repository restructured as src/fantasyfb/ for PyPI packaging.
  • CLIs standardized on --team flag for team identifier.
  • Projection engine wired to V2 by default; V2 diagnostic columns preserved through League.get_rates().
  • MatchupModel.apply_factors is now idempotent across repeated calls.

Fixed

  • Duplicate player rows in League.get_rates() (#7).
  • Season sim no longer double-counts runners_up from RangeIndex collision; completed-week scores are now locked.
  • Yahoo API calls clamped when --week is past end_week.
  • as_of week always treated as start-of-week in schedule.
  • Negative projections from MatchupModel factors clipped at zero.
  • Stale references to ESPN and Pro Football Reference removed.

Removed

  • --email option from send-spreadsheet.
  • Many Mile feature from season simulator.