Skip to content

Drafts

Pre-draft analytics, mock-draft simulation, and the snake cockpit's pure view helpers. The CLI entry points (draft-prep, snake-draft) all build on these.

Tools

VORP, tiers, mock-draft simulator.

fantasyfb.drafts.tools

Pre-draft analytics built on top of ProjectionEngineV2 output.

The League object exposes one row per (player, position) with the V2 columns points_rate, points_stdev, num_games, volume_rate, and efficiency_rate. Those rates answer "how good is the player" -- the draft loop also needs answers to "how scarce is this player at his position", "where does he go relative to the field", and "what does my roster look like if I keep picking this way". This module provides those second-order analytics as pure functions / a small simulator class so the draft CLIs can stay thin and the logic stays testable without Yahoo creds.

Public API

compute_replacement_levels(projections, roster_spec, num_teams) compute_flex_replacement_levels(projections, roster_spec, num_teams) compute_vorp(projections, roster_spec, num_teams) compute_salary_values(projections, roster_spec, num_teams, salary_cap=...) max_bid(remaining_budget, open_slots) assign_tiers(projections, ...) load_adp_csv(path, ...) merge_adp(projections, adp_df, num_teams) Roster, FLEX_ELIGIBILITY # roster-state tracking + flex eligibility MockDraft(...) # snake-draft simulator MockSalaryCapDraft(...) # salary cap auction simulator backtest_salary_values(...) # historical auction evaluation

_BASE_POSITIONS module-attribute

_BASE_POSITIONS: tuple[str, ...] = ('QB', 'RB', 'WR', 'TE', 'K', 'DEF')

_SEASON_GAMES module-attribute

_SEASON_GAMES: int = 17

FLEX_ELIGIBILITY module-attribute

FLEX_ELIGIBILITY: Dict[str, tuple[str, ...]] = {'W/T': ('WR', 'TE'), 'W/R/T': ('WR', 'RB', 'TE'), 'Q/W/R/T': ('QB', 'WR', 'RB', 'TE')}

_USER_STRATEGY_MULT module-attribute

_USER_STRATEGY_MULT: Dict[str, float] = {'value': 1.0, 'aggressive': 1.15, 'conservative': 0.85}

Roster dataclass

Roster(starting_slots: Dict[str, int], bench_slots: int, picks: List[dict] = list())

Tracks one team's roster during a mock draft.

slots mirrors the league roster_spec for starting positions; each pick decrements the most specific eligible slot first, falling back to flex, then to bench. We never block a position outright -- a team that has filled all its starting RB slots can still draft an RB to bench, just at a positional-need penalty.

from_spec classmethod

from_spec(roster_spec) -> 'Roster'

Build an empty Roster from a roster_spec (DataFrame with position/count columns or {slot: count} dict). Bench count comes from the BN row/key if present, 0 otherwise.

need_score

need_score(position: str) -> float

Higher = more urgent need at this position. Used both by opponent ADP-noise pickers (to filter absurd selections like a 4th QB in round 3) and by the user's 'need' strategy.

MockDraft

MockDraft(projections: DataFrame, roster_spec, num_teams: int, *, my_pick: int = 1, snake: bool = True, noise_slope: float = 0.1, noise_floor: float = 1.0, my_strategy: str = 'vorp', reversal_round: int = 0)

Simulate snake (or linear) drafts to evaluate strategies and pick positions.

Opponents pick by sampling near their ADP with gaussian noise; the user picks by my_strategy ('bpa' / 'vorp' / 'need'). The projection pool must already contain points_rate, position, vorp_per_game (call compute_vorp first), and adp (call merge_adp first).

Parameters

projections : pd.DataFrame Fantasy-relevant players only -- defenses/kickers/etc. that the league rosters. The simulator never invents new players. roster_spec : Yahoo-style roster_spots DataFrame or {slot: count} dict. num_teams : int my_pick : int 1-indexed draft slot for the user's team. snake : bool If True, picks reverse direction every round. noise_slope : float Per-pick growth rate of the gaussian stdev that scrambles opponent picks around their ADP. The effective stdev at pick N is max(noise_floor, noise_slope * N). Default 0.1 roughly matches FantasyPros ADP stdev shape -- pick 10 has stdev ~1, pick 100 has stdev ~10, which is what real drafts look like. noise_floor : float Minimum gaussian stdev, applied at the top of the draft where noise_slope * N would otherwise be tiny. Default 1.0 keeps the math from degenerating without making pick #1 a coin flip. my_strategy : str One of 'bpa', 'vorp', 'need'. reversal_round : int Round number (1-indexed) to apply Third-Round Reversal at, matching Sleeper's draft reversal_round setting. Default 0 = disabled (plain alternating snake). Ignored when snake is False.

simulate

simulate(*, seed: Optional[int] = None) -> pd.DataFrame

Run one mock draft. Returns a DataFrame of picks ordered by overall pick number, with columns: pick, round, team, name, position, points_rate, vorp_per_game, adp.

simulate_many

simulate_many(n: int, *, seed: Optional[int] = None) -> pd.DataFrame

Run n mock drafts; return a tall DataFrame with a sim column identifying each. Useful for measuring how often a given player is available at a given pick number.

availability

availability(runs: DataFrame, pick_number: int) -> pd.DataFrame

For a tall sim DataFrame from simulate_many, compute the fraction of sims in which each player was still available when pick_number came around. Returns name, position, available_pct, avg_pick_taken.

MockSalaryCapDraft

MockSalaryCapDraft(projections: DataFrame, roster_spec, num_teams: int, *, salary_cap: int = 200, min_bid: int = 1, my_team_idx: int = 1, noise_floor: float = 2.0, noise_slope: float = 0.1, my_strategy: str = 'value', keepers: Optional[DataFrame] = None)

Simulate full salary cap mock auctions for strategy testing.

Mechanics: round-robin nominations -- each team in turn nominates the highest-salary_value player they can still afford to bid min_bid on. Every team then privately computes a reservation bid for the nominated player: need_factor * salary_value (scaled by my_strategy for the user's team) plus gaussian noise, then clipped to their max_bid budget constraint.

The winner is the highest reservation; the price is the second-highest reservation + min_bid, which mirrors the English-auction dynamic where bids tick up by the minimum increment until only one bidder remains.

Parameters

projections : pd.DataFrame Must contain name, position, salary_value, and vorp_per_game. Run compute_vorp then compute_salary_values first. roster_spec : DataFrame or dict Same shape as everywhere else in the module. num_teams : int salary_cap : int Per-team budget (default 200). min_bid : int Minimum bid per player (default 1). my_team_idx : int 1-indexed slot for the user's team. Mostly cosmetic since salary cap has no fixed turn order, but it labels which team gets the my_strategy reservation multiplier. noise_floor : float Minimum stdev for the gaussian noise applied to opponent reservations. Default 2.0 means even cheap players have at least $2 of bid-to-bid variation. noise_slope : float Per-dollar growth rate of the reservation noise. Effective stdev for a $X-value player is max(noise_floor, noise_slope * X). Default 0.1: a $50 player has stdev ~$5, a $5 player has stdev ~$2 (floored). my_strategy : str One of 'value', 'aggressive', 'conservative'. keepers : optional DataFrame Pre-assigned keeper players with columns name, team_idx (1-indexed), and winning_bid. At the start of each simulated auction the keeper prices are deducted from the owning team's budget, the keeper fills a roster slot for that team, and the player is removed from the available pool. This lets simulate_many model keeper leagues without re-drafting players who are already off the board.

simulate

simulate(*, seed: Optional[int] = None) -> pd.DataFrame

Run one mock auction. Returns a DataFrame of picks in nomination order, with columns: nomination, nominator, team, is_user, name, position, salary_value, vorp_per_game, winning_bid.

simulate_many

simulate_many(n: int, *, seed: Optional[int] = None) -> pd.DataFrame

Run n mock auctions; return a tall DataFrame with a sim column identifying each. Useful for measuring how often a player goes for X dollars or which strategies tend to land which tiers.

_normalize_name_key

_normalize_name_key(names: Series) -> pd.Series

Build a join-key copy of a name column: trailing generational suffix stripped, whitespace collapsed, lowercased. Never overwrites the display name -- callers merge on this and keep name as-is.

_roster_spec_to_dict

_roster_spec_to_dict(roster_spec) -> Dict[str, int]

Accept either a DataFrame (position/count columns, as produced by fantasyfb.League.roster_spots) or a plain dict and return a cleaned {slot: count} dict with bench/IR stripped out.

_expected_starters_per_position

_expected_starters_per_position(spec: Dict[str, int], num_teams: int) -> Dict[str, float]

Total starting slots across the league that will be filled at each base position, including a flex share.

Hard slots (QB/RB/WR/TE/K/DEF) count fully. Flex slots are split evenly across their eligible base positions -- a defensible default that doesn't require an opinion on which position "tends to" win flex (in practice RB/WR dominate, but that's already reflected in the projection-driven greedy fill done by VORP itself).

compute_replacement_levels

compute_replacement_levels(projections: DataFrame, roster_spec, num_teams: int) -> Dict[str, float]

Replacement-level points_rate per position.

Definition: for position P, sort the projection pool descending by points_rate; the replacement level is the rate of the player ranked at index expected_starters[P] (0-indexed). Conceptually this is "the best player at P who won't be a starter anywhere in the league" -- the natural baseline for VORP.

If fewer than expected_starters[P] + 1 players exist at the position, the replacement level falls back to the rate of the weakest available player at that position (so VORP stays defined, just degenerate).

compute_flex_replacement_levels

compute_flex_replacement_levels(projections: DataFrame, roster_spec, num_teams: int) -> Dict[str, float]

Replacement-level points_rate for each position measured against the combined flex pool it competes in.

For a position that is eligible for one or more flex slots, the flex replacement level is the rate of the Nth-best player across all positions eligible for the deepest flex slot that includes this position, where N = total dedicated starters (non-flex) at that position + total flex slots of that type, summed across all teams.

This answers: "what would I have gotten if I hadn't drafted this player and instead took the best remaining flex-eligible option?" In a superflex league that baseline is a WR/RB/TE, not a QB, which correctly captures that QBs are less scarce than their position-only VORP suggests.

For positions with no flex slot (K, DEF) this falls back to the same value as compute_replacement_levels -- there is no flex competition, so position-only VORP is the correct measure.

compute_vorp

compute_vorp(projections: DataFrame, roster_spec, num_teams: int, season_games: int = _SEASON_GAMES) -> pd.DataFrame

Add VORP columns to a projection DataFrame.

Returns a copy with four new columns

replacement_rate: position-specific replacement level vorp_per_game: points_rate - replacement_rate vorp_season: vorp_per_game * season_games flex_replacement_rate: replacement level vs. combined flex pool (same as replacement_rate for K/DEF) vorp_flex_per_game: points_rate - flex_replacement_rate vorp_flex_season: vorp_flex_per_game * season_games

vorp_flex_per_game is the economically correct value signal for draft decisions in flex and superflex leagues: it measures edge over the best player you could have taken instead across the entire eligible flex pool. For non-flex positions (K, DEF) it equals vorp_per_game. For QB in a superflex league it will be lower than vorp_per_game, correctly reflecting that the superflex slot would otherwise be filled by a WR/RB/TE rather than a QB.

Players at positions that don't appear in the league get NaN VORP.

_total_roster_size

_total_roster_size(spec: Dict[str, int], bench_slots: int) -> int

Starting slots + bench. Used by salary value math to count the total picks each team will make.

_bench_slots_from_spec

_bench_slots_from_spec(roster_spec) -> int

compute_salary_values

compute_salary_values(projections: DataFrame, roster_spec, num_teams: int, *, salary_cap: int, min_bid: int = 1) -> pd.DataFrame

Convert season VORP into per-player salary cap dollar values.

Standard valuation: the league has num_teams * salary_cap total dollars to distribute across num_teams * roster_size picks. Every pick reserves min_bid (so a $1 minimum is always honored), and the remaining "above-min" pool is allocated among the top-N starter pool (N = num_teams * starting_slots_per_team) proportionally to each player's positive season VORP. Players outside the starter pool or with non-positive VORP get min_bid.

Why VORP-proportional rather than raw-points-proportional: the market sets prices based on edge over replacement, not absolute output. A QB1 outscores an RB1 in raw points but the QB1's edge over a free-agent QB is much smaller than the RB1's edge over a free-agent RB, so the RB1 commands more dollars. VORP already encodes that asymmetry.

Requires vorp_season on the input (run compute_vorp first). Returns a copy with a new salary_value float column.

Raises ValueError if the salary cap is too small to reserve min_bid for every pick.

max_bid

max_bid(remaining_budget: int, open_slots: int, *, min_bid: int = 1) -> int

Maximum legal bid given remaining budget and open roster slots.

max_bid = remaining_budget - (open_slots - 1) * min_bid

This is the standard salary cap constraint: after winning one player at this bid, every remaining slot must still be fillable at min_bid each. Assumes open_slots includes the slot we'd fill by winning this bid (i.e. it's the count before the win).

Returns 0 if the roster is already full or the budget can't even cover min_bid for the current slot.

_enforce_max_per_tier

_enforce_max_per_tier(tiers: ndarray, rates: ndarray, max_per_tier: int, max_tiers: int) -> np.ndarray

Iteratively split any tier with more than max_per_tier members at its largest internal gap, until all tiers fit or we'd hit max_tiers.

Two safety rails: we never split if it would push us over the tier cap, and we don't split a tier whose largest internal gap isn't at least 1.5x the tier's median internal gap. The second rule keeps uniformly-spaced players from fragmenting into singletons -- if every gap inside a fat tier looks the same, there's no defensible place to break, so we leave it alone and let the user dial max_per_tier higher if they don't mind.

assign_tiers

assign_tiers(projections: DataFrame, *, by: str = 'points_rate', positions: Optional[Iterable[str]] = None, top_n: int = 30, max_tiers: int = 12, max_per_tier: int = 12, min_gap_z: float = 1.0) -> pd.DataFrame

Group players within each position into tiers separated by statistically significant gaps in projected output.

Two-pass algorithm:

Pass 1 (gap-driven): for each position, sort descending by by, take the top top_n players, and look at consecutive-player gaps among that group. Compute median+MAD of just the upper half of those gaps -- the half-most-significant breaks -- and declare a tier boundary wherever a gap exceeds median + min_gap_z * MAD.

Pass 2 (size cap): any resulting tier with more than max_per_tier members is split at its largest internal gap. Repeats until all tiers are size-bounded or max_tiers is reached. This is what keeps positions like WR -- where the top 3 tiers fall out cleanly but the rest is a 20-player smooth descent -- from collapsing everything-after-tier-3 into a single unreadable blob.

Knobs: - min_gap_z controls Pass 1 strictness. Higher = fewer natural breaks recognized. - max_per_tier controls Pass 2 strictness. Lower = more aggressive forced splits inside otherwise-flat regions. - max_tiers is the hard cap on either pass.

Players beyond top_n at each position get tier = NaN -- they're bench/depth and the tier abstraction doesn't apply to them.

Returns a copy of the input with a new tier integer column.

load_adp_csv

load_adp_csv(path: str, *, name_col: str = 'Player', adp_col: str = 'AVG', position_col: str = 'POS', team_col: Optional[str] = 'Team') -> pd.DataFrame

Load an ADP CSV (FantasyPros / Sleeper / Yahoo-style export) into a normalized DataFrame.

Returns: name, position (cleaned to QB/RB/WR/TE/K/DEF), adp, and (optionally) team.

merge_adp

merge_adp(projections: DataFrame, adp_df: DataFrame, num_teams: int) -> pd.DataFrame

Join ADP onto projections and compute value-vs-ADP.

Adds

adp: average draft pick across sources adp_round: ceil(adp / num_teams) proj_rank: overall rank by points_rate (diagnostic only) vorp_rank: overall rank by vorp_per_game; only set when vorp_per_game is present on the input adp_value: adp - vorp_rank when VORP is available, else adp - proj_rank. Positive = market under-rates the player relative to your projection.

Why VORP rank rather than points rank: ranking by absolute fantasy points puts QBs at the top of the overall list (passing yards + TDs accumulate fast), but the actual draft market knows QB is a shallow position and waits on it. That mismatch made every late- round QB look like a "value" under a points-based rank. VORP centers each position on its own replacement level, so cross- position ranks reflect real draft value.

Players the projection knows about but ADP doesn't get NaN ADP and NaN adp_value -- they're presumably undraftable depth.

_build_rosters

_build_rosters(roster_spec, num_teams: int) -> List[Roster]

round_direction

round_direction(round_num: int, reversal_round: int = 0) -> int

0 (forward, slot 0..n-1) or 1 (reverse, slot n-1..0) for a 1-indexed round.

Standard snake alternates every round. Third-Round Reversal (TRR) -- a Sleeper draft setting, reversal_round in its draft settings -- repeats the previous round's direction at reversal_round instead of flipping, then resumes normal alternation from there (which permanently flips the parity of every round after it). reversal_round = 0 (Sleeper's convention for "disabled") always falls through to plain alternating snake regardless of round number.

_snake_pick_owner

_snake_pick_owner(pick_index: int, num_teams: int, snake: bool, reversal_round: int = 0) -> int

Return the 0-indexed team owning pick pick_index (0-indexed).

_opponent_pick

_opponent_pick(available: DataFrame, pick_overall: int, roster: Roster, noise_slope: float, noise_floor: float, rng: Generator) -> int

Choose an index from available for an opponent's pick.

Selection probability is gaussian in the distance from the player's ADP to the current overall pick number, with the gaussian's stdev scaled by pick number: sd = max(noise_floor, noise_slope * pick_overall). Constant-stdev noise was wildly too generous at the top of the draft -- in real drafts the top ~3 picks have ADP stdev ~0.5 (the elite RBs / WRs always go in the top 3 in some order), but pick 100 has stdev ~10. Linear-with-floor matches that shape: tight at the top, looser deep in the draft.

Players without ADP get a synthetic late-draft ADP so they only get picked once everyone with real ADP is gone.

_user_pick

_user_pick(available: DataFrame, roster: Roster, strategy: str) -> int

Pick the user's player according to the chosen strategy.

_open_slots

_open_slots(roster: 'Roster') -> int

_can_fit

_can_fit(roster: 'Roster', position: str) -> bool

Whether position can be added to roster -- a base-position slot is open, a flex slot is open and the position is eligible for it, or the bench has room. False means a pick at this position would be a no-op on the roster (slots stay full), so the simulator should not let the team bid on it.

backtest_salary_values

backtest_salary_values(history: DataFrame, values: DataFrame, *, value_col: str = 'salary_value', bid_col: str = 'winning_bid', keeper_names: Optional[Iterable[str]] = None) -> pd.DataFrame

Compare a salary valuation engine's outputs to a real auction history, returning per-team metrics.

history is the realized auction: one row per pick with name, fantasy_team, and the bid_col paid. values is the engine's pre-draft valuation: one row per player with name and the value_col it would have recommended. The function joins them and computes:

  • total_spent: what the team actually paid across all picks
  • total_value: what the engine said those picks were worth
  • surplus: total_value - total_spent (positive = bought below engine valuation, negative = overpaid)
  • picks_overpaid, picks_value: counts on each side
  • avg_overpay_pct: mean of (bid - value) / max(value, 1) across picks, useful for spotting systematic over/under-pricing by the engine relative to the market

Sorted by surplus descending so the teams the engine thinks "got the best deals" sit at the top. Run on V1 and V2 valuations side by side to see which engine's surplus rankings better predict actual season finish.

optional collection of player names to exclude from

the surplus calculation. Keeper prices are pre-negotiated and don't reflect competitive auction dynamics, so including them skews the per-team overpay metrics.

Snake cockpit views

Pure-DataFrame functions that produce the board / best / lookup / roster tables the CLI renders.

fantasyfb.drafts.snake_cockpit

Live-draft cockpit helpers built on draft_tools.

The interactive CLIs (snake_draft.py, eventually salary_cap_draft.py) own the input loop and Yahoo-credentialed pieces. Everything that can be expressed as a pure function on a draft "board" -- a DataFrame combining the projection pool with VORP, tiers, and ADP value -- lives here so it can be unit-tested offline.

Public API

build_board(players, roster_spec, num_teams, adp_csv_path, ...) view_best(board, ...) view_nearest(board, ...) view_lookup(board, name) view_roster(board, team_name) DEFAULT_DISPLAY_COLS

_POSITION_ORDER module-attribute

_POSITION_ORDER: tuple[str, ...] = ('QB', 'RB', 'WR', 'TE', 'K', 'DEF')

DEFAULT_DISPLAY_COLS module-attribute

DEFAULT_DISPLAY_COLS: tuple[str, ...] = ('name', 'position', 'current_team', 'tier', 'vorp_adjusted', 'need_factor', 'vorp_flex_per_game', 'vorp_per_game', 'points_rate', 'adp', 'adp_round', 'adp_value')

_BB_DISPLAY_COLS module-attribute

_BB_DISPLAY_COLS: tuple[str, ...] = ('name', 'position', 'current_team', 'tier', 'vorp_adjusted', 'need_factor', 'bb_vorp', 'points_rate', 'points_stdev', 'adp', 'adp_round', 'adp_value')

_BB_UPSIDE_FACTOR module-attribute

_BB_UPSIDE_FACTOR: float = 0.5

Roster dataclass

Roster(starting_slots: Dict[str, int], bench_slots: int, picks: List[dict] = list())

Tracks one team's roster during a mock draft.

slots mirrors the league roster_spec for starting positions; each pick decrements the most specific eligible slot first, falling back to flex, then to bench. We never block a position outright -- a team that has filled all its starting RB slots can still draft an RB to bench, just at a positional-need penalty.

from_spec classmethod

from_spec(roster_spec) -> 'Roster'

Build an empty Roster from a roster_spec (DataFrame with position/count columns or {slot: count} dict). Bench count comes from the BN row/key if present, 0 otherwise.

need_score

need_score(position: str) -> float

Higher = more urgent need at this position. Used both by opponent ADP-noise pickers (to filter absurd selections like a 4th QB in round 3) and by the user's 'need' strategy.

assign_tiers

assign_tiers(projections: DataFrame, *, by: str = 'points_rate', positions: Optional[Iterable[str]] = None, top_n: int = 30, max_tiers: int = 12, max_per_tier: int = 12, min_gap_z: float = 1.0) -> pd.DataFrame

Group players within each position into tiers separated by statistically significant gaps in projected output.

Two-pass algorithm:

Pass 1 (gap-driven): for each position, sort descending by by, take the top top_n players, and look at consecutive-player gaps among that group. Compute median+MAD of just the upper half of those gaps -- the half-most-significant breaks -- and declare a tier boundary wherever a gap exceeds median + min_gap_z * MAD.

Pass 2 (size cap): any resulting tier with more than max_per_tier members is split at its largest internal gap. Repeats until all tiers are size-bounded or max_tiers is reached. This is what keeps positions like WR -- where the top 3 tiers fall out cleanly but the rest is a 20-player smooth descent -- from collapsing everything-after-tier-3 into a single unreadable blob.

Knobs: - min_gap_z controls Pass 1 strictness. Higher = fewer natural breaks recognized. - max_per_tier controls Pass 2 strictness. Lower = more aggressive forced splits inside otherwise-flat regions. - max_tiers is the hard cap on either pass.

Players beyond top_n at each position get tier = NaN -- they're bench/depth and the tier abstraction doesn't apply to them.

Returns a copy of the input with a new tier integer column.

compute_vorp

compute_vorp(projections: DataFrame, roster_spec, num_teams: int, season_games: int = _SEASON_GAMES) -> pd.DataFrame

Add VORP columns to a projection DataFrame.

Returns a copy with four new columns

replacement_rate: position-specific replacement level vorp_per_game: points_rate - replacement_rate vorp_season: vorp_per_game * season_games flex_replacement_rate: replacement level vs. combined flex pool (same as replacement_rate for K/DEF) vorp_flex_per_game: points_rate - flex_replacement_rate vorp_flex_season: vorp_flex_per_game * season_games

vorp_flex_per_game is the economically correct value signal for draft decisions in flex and superflex leagues: it measures edge over the best player you could have taken instead across the entire eligible flex pool. For non-flex positions (K, DEF) it equals vorp_per_game. For QB in a superflex league it will be lower than vorp_per_game, correctly reflecting that the superflex slot would otherwise be filled by a WR/RB/TE rather than a QB.

Players at positions that don't appear in the league get NaN VORP.

load_adp_csv

load_adp_csv(path: str, *, name_col: str = 'Player', adp_col: str = 'AVG', position_col: str = 'POS', team_col: Optional[str] = 'Team') -> pd.DataFrame

Load an ADP CSV (FantasyPros / Sleeper / Yahoo-style export) into a normalized DataFrame.

Returns: name, position (cleaned to QB/RB/WR/TE/K/DEF), adp, and (optionally) team.

merge_adp

merge_adp(projections: DataFrame, adp_df: DataFrame, num_teams: int) -> pd.DataFrame

Join ADP onto projections and compute value-vs-ADP.

Adds

adp: average draft pick across sources adp_round: ceil(adp / num_teams) proj_rank: overall rank by points_rate (diagnostic only) vorp_rank: overall rank by vorp_per_game; only set when vorp_per_game is present on the input adp_value: adp - vorp_rank when VORP is available, else adp - proj_rank. Positive = market under-rates the player relative to your projection.

Why VORP rank rather than points rank: ranking by absolute fantasy points puts QBs at the top of the overall list (passing yards + TDs accumulate fast), but the actual draft market knows QB is a shallow position and waits on it. That mismatch made every late- round QB look like a "value" under a points-based rank. VORP centers each position on its own replacement level, so cross- position ranks reflect real draft value.

Players the projection knows about but ADP doesn't get NaN ADP and NaN adp_value -- they're presumably undraftable depth.

build_board

build_board(players: DataFrame, roster_spec, num_teams: int, adp_csv_path: str, *, name_col: str = 'Player', adp_col: str = 'AVG', position_col: str = 'POS', team_col: Optional[str] = 'Team', gap_z: float = 1.0, top_n: int = 30, max_per_tier: int = 12) -> pd.DataFrame

Build the snapshot draft board.

Drops the synthetic avg_* rows that League.get_rates injects for sim purposes, dedupes by player_id_sr, and layers VORP / tier / ADP columns onto the result.

The board is a snapshot: fantasy_team reflects whatever was on players at call time (so keepers / in-progress picks survive). The cockpit shell mutates fantasy_team on the returned board as picks come in.

build_my_roster

build_my_roster(board: DataFrame, team_name: str, roster_spec) -> Roster

Build a Roster reflecting the picks team_name already has on the board. Used by the cockpit views to scale VORP by positional need -- a 4th RB after you've filled your starting RB slots is worth less than a starting WR you don't have yet, even if their raw VORPs match.

Pick order doesn't matter for the resulting need_score: greedy slot allocation (most-specific first, then flex, then bench) yields the same final slot occupancy regardless of the order picks were added.

_apply_need_scaling

_apply_need_scaling(avail: DataFrame, my_roster: Optional[Roster]) -> tuple[pd.DataFrame, str]

Add need_factor and vorp_adjusted columns when a roster is provided, and return the column to sort by. With no roster, sorts by raw vorp_flex_per_game (or vorp_per_game as fallback) -- the original cross-position behavior.

vorp_adjusted uses vorp_flex_per_game as its base when available: flex VORP measures edge over the true alternative (the best flex-eligible player you could have taken), which is the economically correct signal for draft decisions in flex/superflex leagues. For K/DEF -- positions with no flex slot -- flex VORP equals position VORP, so there is no difference for those players.

_available

_available(board: DataFrame, exclude: Iterable[str] = ()) -> pd.DataFrame

_ordered_columns

_ordered_columns(board: DataFrame, cols: Iterable[str]) -> list[str]

_position_sort_key

_position_sort_key(position: Series) -> pd.Series

Sort key that puts QB/RB/WR/TE/K/DEF in conventional order, with anything else (flex labels, garbage) sorted to the end alphabetically.

view_best

view_best(board: DataFrame, *, exclude: Iterable[str] = (), limit_per_position: int = 5, positions: Optional[Iterable[str]] = None, my_roster: Optional[Roster] = None) -> pd.DataFrame

Top-N available players per position, ranked by (need-adjusted) VORP.

Per-position cap (limit_per_position) keeps a deep WR pool from drowning out other positions, but the final list is ordered globally so the row at the top is the player with the highest cross-position value.

When my_roster is passed, sort key becomes vorp_per_game * need_score(position) and the output gains need_factor and vorp_adjusted columns. need_score is 1.0 for positions where the user still has a starting slot open, ~0.7 if the position fits an open flex slot, and 0.05-0.2 if the user has only bench room left -- so a 4th RB after the starting RBs are filled gets penalized relative to a starting-eligible WR with similar raw VORP.

Within a position the need_factor is constant, so internal ordering matches raw VORP either way -- the adjustment only changes the cross-position ranking.

Replaces the original possible_adds-backed "best" view that ran a 10k-sim season per candidate (unusable on the clock).

view_nearest

view_nearest(board: DataFrame, *, pick_overall: int, num_teams: int, exclude: Iterable[str] = (), window_rounds: int = 2, my_roster: Optional[Roster] = None) -> pd.DataFrame

Available players whose ADP falls within the next window_rounds of the current overall pick number, sorted by (need-adjusted) VORP.

This is the planning view: "given that the draft will turn around to me in N picks, which players the market thinks are about to go are worth grabbing now?" Players with no ADP are excluded -- by definition the market hasn't placed them in any window.

Same need-scaling behavior as view_best when my_roster is passed: the adjusted VORP penalizes positions you've already filled.

view_lookup

view_lookup(board: DataFrame, name: str) -> pd.DataFrame

Row(s) for a single player, available or not.

Includes fantasy_team so the user can see who already drafted them if the player is off the board.

random_pick

random_pick(board: DataFrame, *, team_name: str, roster_spec, exclude: Iterable[str] = (), pool_size: int = 8, pick_overall: Optional[int] = None, num_teams: Optional[int] = None, window_rounds: int = 2, rng: Optional[Generator] = None) -> str

Auto-draft a sensible pick for team_name from the available pool.

Used by the cockpit's random command so a user running a mock draft doesn't have to type out names for every opponent (or for their own picks when they're fast-forwarding through "boring" rounds). Returns the picked player's name -- the caller applies the pick the same way it would for a typed name.

The candidate pool is ADP-windowed first, need-adjusted-VORP second -- this is what keeps auto-picked opponents from taking players well ahead of their real-world ADP (e.g. a round-1 model-favorite the market has going in round 3). When pick_overall and num_teams are both given, the pool is restricted to available players whose ADP falls within window_rounds rounds of the current overall pick (the same window view_nearest uses for its planning view), and the top pool_size of those by need-adjusted VORP form the pool. Players missing ADP entirely (deep depth) are always included in the window so boards with thin ADP coverage don't run dry -- need-adjusted VORP and the sampling weights below still keep them in check.

Without pick_overall/num_teams (or when the window is empty), falls back to the top pool_size available players by adjusted VORP across the whole board -- the original cross-position behavior, kept for callers that don't track pick position and for --fresh-draft style boards with sparse ADP.

The on-the-clock team's roster is rebuilt from the board so positions they've already filled get demoted before either pool is built -- this keeps the pool sensible (e.g. never proposes a 3rd QB) regardless of market ADP.

Within the pool, sampling weight blends the model's opinion (VORP) with the market's (ADP): weight = vorp_adjusted * (1 / adp), so a player needs to look good by both signals to get picked often. A model-favorite the market has cooled on, or a market darling the model doesn't love, both still get picked sometimes -- just less often than a player both signals agree on. Players missing ADP (not in the ADP source at all -- deep depth) fall back to the pool's worst real ADP plus a penalty, so they're not mistaken for a market consensus top pick.

The top option is heavily favored but not deterministic, so re-running the same draft with the auto-pilot produces different (but always plausible) outcomes.

Negative VORP values are clipped to a small epsilon so a fully- saturated roster (everyone's adjusted VORP near zero) still picks something rather than crashing on a zero-weight sample.

_add_bb_vorp

_add_bb_vorp(df: DataFrame, upside_factor: float = _BB_UPSIDE_FACTOR) -> pd.DataFrame

Add bb_vorp column: upside-weighted value over replacement.

bb_vorp = (points_rate + upside_factor * points_stdev) - replacement_rate

Falls back gracefully when points_stdev or replacement_rate are absent (e.g. a board built without those columns).

view_bestball

view_bestball(board: DataFrame, *, exclude: Iterable[str] = (), limit_per_position: int = 5, positions: Optional[Iterable[str]] = None, my_roster: Optional[Roster] = None, upside_factor: float = _BB_UPSIDE_FACTOR) -> pd.DataFrame

Top-N available players per position ranked by best-ball adjusted VORP.

Like view_best but rewards upside: bb_vorp adds a fraction of each player's points_stdev to their projected rate before computing value over replacement. In best ball there are no weekly lineup decisions — a player's boom week always counts — so variance is an asset rather than a risk and high-ceiling players deserve more credit.

upside_factor (default 0.5) controls how heavily stdev is weighted. Need-scaling (via my_roster) works identically to view_best.

view_nearestbestball

view_nearestbestball(board: DataFrame, *, pick_overall: int, num_teams: int, exclude: Iterable[str] = (), window_rounds: int = 2, my_roster: Optional[Roster] = None, upside_factor: float = _BB_UPSIDE_FACTOR) -> pd.DataFrame

Available players in the next ADP window, ranked by best-ball VORP.

Like view_nearest but the sort metric is bb_vorp (upside-weighted value over replacement) rather than standard VORP. Use this during a best-ball draft when you want to see which near-ADP players offer the most ceiling, not just the safest floor.

view_roster

view_roster(board: DataFrame, team_name: str) -> pd.DataFrame

Players drafted by team_name, ordered by conventional position sequence then by VORP within position.

Salary-cap cockpit views

Salary-cap analogue of the snake cockpit. Adds salary_value and winning_bid columns to the board; every view derives per-team budget and slot state from the board itself, so picks are applied with two cell writes and no external bookkeeping.

fantasyfb.drafts.salary_cap_cockpit

Live salary cap draft cockpit helpers built on draft_tools.

Mirrors snake_cockpit for salary cap drafts. Where the snake side plans around "pick number and ADP", the salary cap side plans around "remaining budget and inflated dollar value". The valuation math itself lives in tools (compute_salary_values, max_bid); this module renders it through a board-shaped DataFrame that picks mutate as they happen.

A board for salary cap purposes is the snake board plus two columns:

  • salary_value: the pre-draft baseline dollar value (from compute_salary_values).
  • winning_bid: NaN for undrafted players, the winning price once a player is drafted. Combined with fantasy_team this lets every view derive per-team spent / remaining budget without any external state.
Public API

build_board(players, roster_spec, num_teams, salary_cap, ...) compute_inflation(board, salary_cap, num_teams) view_best(board, ...) view_nominate(board, ...) view_what_if(board, name, bid, winning_team, ...) view_lookup(board, name) view_roster(board, team_name, ...) view_budget_status(board, team_names, salary_cap, roster_spec) simulate_nomination(board, ...) # auto-pick for the 'random' command DEFAULT_DISPLAY_COLS

_USER_STRATEGY_MULT module-attribute

_USER_STRATEGY_MULT: Dict[str, float] = {'value': 1.0, 'aggressive': 1.15, 'conservative': 0.85}

DEFAULT_DISPLAY_COLS module-attribute

DEFAULT_DISPLAY_COLS: tuple[str, ...] = ('name', 'position', 'current_team', 'tier', 'vorp_adjusted', 'need_factor', 'vorp_per_game', 'points_rate', 'salary_value', 'inflated_value', 'max_my_bid')

Roster dataclass

Roster(starting_slots: Dict[str, int], bench_slots: int, picks: List[dict] = list())

Tracks one team's roster during a mock draft.

slots mirrors the league roster_spec for starting positions; each pick decrements the most specific eligible slot first, falling back to flex, then to bench. We never block a position outright -- a team that has filled all its starting RB slots can still draft an RB to bench, just at a positional-need penalty.

from_spec classmethod

from_spec(roster_spec) -> 'Roster'

Build an empty Roster from a roster_spec (DataFrame with position/count columns or {slot: count} dict). Bench count comes from the BN row/key if present, 0 otherwise.

need_score

need_score(position: str) -> float

Higher = more urgent need at this position. Used both by opponent ADP-noise pickers (to filter absurd selections like a 4th QB in round 3) and by the user's 'need' strategy.

_available

_available(board: DataFrame, exclude: Iterable[str] = ()) -> pd.DataFrame

_ordered_columns

_ordered_columns(board: DataFrame, cols: Iterable[str]) -> list[str]

_position_sort_key

_position_sort_key(position: Series) -> pd.Series

Sort key that puts QB/RB/WR/TE/K/DEF in conventional order, with anything else (flex labels, garbage) sorted to the end alphabetically.

build_my_roster

build_my_roster(board: DataFrame, team_name: str, roster_spec) -> Roster

Build a Roster reflecting the picks team_name already has on the board. Used by the cockpit views to scale VORP by positional need -- a 4th RB after you've filled your starting RB slots is worth less than a starting WR you don't have yet, even if their raw VORPs match.

Pick order doesn't matter for the resulting need_score: greedy slot allocation (most-specific first, then flex, then bench) yields the same final slot occupancy regardless of the order picks were added.

_bench_slots_from_spec

_bench_slots_from_spec(roster_spec) -> int

_roster_spec_to_dict

_roster_spec_to_dict(roster_spec) -> Dict[str, int]

Accept either a DataFrame (position/count columns, as produced by fantasyfb.League.roster_spots) or a plain dict and return a cleaned {slot: count} dict with bench/IR stripped out.

assign_tiers

assign_tiers(projections: DataFrame, *, by: str = 'points_rate', positions: Optional[Iterable[str]] = None, top_n: int = 30, max_tiers: int = 12, max_per_tier: int = 12, min_gap_z: float = 1.0) -> pd.DataFrame

Group players within each position into tiers separated by statistically significant gaps in projected output.

Two-pass algorithm:

Pass 1 (gap-driven): for each position, sort descending by by, take the top top_n players, and look at consecutive-player gaps among that group. Compute median+MAD of just the upper half of those gaps -- the half-most-significant breaks -- and declare a tier boundary wherever a gap exceeds median + min_gap_z * MAD.

Pass 2 (size cap): any resulting tier with more than max_per_tier members is split at its largest internal gap. Repeats until all tiers are size-bounded or max_tiers is reached. This is what keeps positions like WR -- where the top 3 tiers fall out cleanly but the rest is a 20-player smooth descent -- from collapsing everything-after-tier-3 into a single unreadable blob.

Knobs: - min_gap_z controls Pass 1 strictness. Higher = fewer natural breaks recognized. - max_per_tier controls Pass 2 strictness. Lower = more aggressive forced splits inside otherwise-flat regions. - max_tiers is the hard cap on either pass.

Players beyond top_n at each position get tier = NaN -- they're bench/depth and the tier abstraction doesn't apply to them.

Returns a copy of the input with a new tier integer column.

compute_salary_values

compute_salary_values(projections: DataFrame, roster_spec, num_teams: int, *, salary_cap: int, min_bid: int = 1) -> pd.DataFrame

Convert season VORP into per-player salary cap dollar values.

Standard valuation: the league has num_teams * salary_cap total dollars to distribute across num_teams * roster_size picks. Every pick reserves min_bid (so a $1 minimum is always honored), and the remaining "above-min" pool is allocated among the top-N starter pool (N = num_teams * starting_slots_per_team) proportionally to each player's positive season VORP. Players outside the starter pool or with non-positive VORP get min_bid.

Why VORP-proportional rather than raw-points-proportional: the market sets prices based on edge over replacement, not absolute output. A QB1 outscores an RB1 in raw points but the QB1's edge over a free-agent QB is much smaller than the RB1's edge over a free-agent RB, so the RB1 commands more dollars. VORP already encodes that asymmetry.

Requires vorp_season on the input (run compute_vorp first). Returns a copy with a new salary_value float column.

Raises ValueError if the salary cap is too small to reserve min_bid for every pick.

compute_vorp

compute_vorp(projections: DataFrame, roster_spec, num_teams: int, season_games: int = _SEASON_GAMES) -> pd.DataFrame

Add VORP columns to a projection DataFrame.

Returns a copy with four new columns

replacement_rate: position-specific replacement level vorp_per_game: points_rate - replacement_rate vorp_season: vorp_per_game * season_games flex_replacement_rate: replacement level vs. combined flex pool (same as replacement_rate for K/DEF) vorp_flex_per_game: points_rate - flex_replacement_rate vorp_flex_season: vorp_flex_per_game * season_games

vorp_flex_per_game is the economically correct value signal for draft decisions in flex and superflex leagues: it measures edge over the best player you could have taken instead across the entire eligible flex pool. For non-flex positions (K, DEF) it equals vorp_per_game. For QB in a superflex league it will be lower than vorp_per_game, correctly reflecting that the superflex slot would otherwise be filled by a WR/RB/TE rather than a QB.

Players at positions that don't appear in the league get NaN VORP.

max_bid

max_bid(remaining_budget: int, open_slots: int, *, min_bid: int = 1) -> int

Maximum legal bid given remaining budget and open roster slots.

max_bid = remaining_budget - (open_slots - 1) * min_bid

This is the standard salary cap constraint: after winning one player at this bid, every remaining slot must still be fillable at min_bid each. Assumes open_slots includes the slot we'd fill by winning this bid (i.e. it's the count before the win).

Returns 0 if the roster is already full or the budget can't even cover min_bid for the current slot.

build_board

build_board(players: DataFrame, roster_spec, num_teams: int, *, salary_cap: int, min_bid: int = 1, keepers: Optional[DataFrame] = None, gap_z: float = 1.0, top_n: int = 30, max_per_tier: int = 12) -> pd.DataFrame

Build the snapshot salary cap draft board.

Drops the synthetic avg_* rows League.get_rates injects for simulation, dedupes by player_id_sr, and layers VORP, tiers, and salary_value onto the result. Adds an empty winning_bid column so picks can be recorded in place.

Unlike the snake board there's no ADP merge -- salary cap drafts are bid-driven, not pick-order-driven. The cockpit shell can still layer ADP on if it wants nomination-order analytics.

optional DataFrame with columns name, fantasy_team,

winning_bid. When provided, keeper players are excluded from the salary-value computation pool so values reflect the competitive auction pool only. VORP is still computed on the full pool so replacement levels are correct. Keepers are seeded onto the board as already-drafted rows (winning_bid pre-set, salary_value taken from the full-pool computation for display/reference).

compute_inflation

compute_inflation(board: DataFrame, salary_cap: int, num_teams: int) -> float

Market inflation factor for remaining players.

Defined as (total league $ minus already-spent $) / (sum of salary_value across undrafted players). Equals 1.0 at draft start; rises above 1.0 if owners have underspent so far (more money chasing fewer dollars of value) and falls below 1.0 if owners have overpaid early.

Returns 1.0 when no undrafted value remains (degenerate end-state).

_team_remaining

_team_remaining(board: DataFrame, team_name: str, salary_cap: int, roster_size: int) -> tuple[int, int]

Return (remaining_budget, open_slots) for team_name derived from the board. Open slots assume one pick per drafted row.

view_best

view_best(board: DataFrame, *, my_team: str, salary_cap: int, num_teams: int, roster_spec, exclude: Iterable[str] = (), limit_per_position: int = 5, positions: Optional[Iterable[str]] = None, min_bid: int = 1) -> pd.DataFrame

Top recommended targets per position for my_team.

Ranked by need-adjusted VORP (same idea as the snake view), but the output also carries the three dollar columns a salary cap user actually decides on: salary_value (pre-draft baseline), inflated_value (after market inflation), and max_my_bid (the bid cap the user's remaining budget allows). The cockpit shell renders these so the user can see at a glance "should I chase this player and for how much."

Internal ordering within a position matches raw VORP since the need_factor is constant; the need-scaling only matters for cross-position ranking.

view_nominate

view_nominate(board: DataFrame, *, my_team: str, salary_cap: int, num_teams: int, roster_spec, exclude: Iterable[str] = (), limit: int = 10) -> pd.DataFrame

Players worth nominating to drain opponents' budgets.

"Drain score" = inflated_value * (1 - my_need_factor): high market price, low fit for the user's current roster. Nominating these forces opponents into bidding wars on players the user didn't want anyway, eroding their cap space for the targets the user does want (use view_best for that side of the strategy).

Players where the user still has a starting slot open get deprioritized -- a top WR in round 2 of an auction isn't a "drainer" if the user actually needs WRs, even if the market price is high.

view_what_if

view_what_if(board: DataFrame, *, name: str, bid: int, winning_team: str, my_team: str, salary_cap: int, num_teams: int, roster_spec, limit_per_position: int = 5, min_bid: int = 1) -> pd.DataFrame

Re-rank targets assuming winning_team lands name at bid.

Useful for "if Jefferson goes for $60 to team X, who's my plan B?" Returns the updated view_best over the hypothetical board. The real board is not mutated.

view_lookup

view_lookup(board: DataFrame, name: str, *, my_team: str | None = None, salary_cap: int | None = None, num_teams: int | None = None, roster_spec=None, min_bid: int = 1) -> pd.DataFrame

Row(s) for a single player, drafted or not.

Includes fantasy_team and winning_bid so the user can see who paid what if the player is off the board. When my_team and the league shape (salary_cap, num_teams, roster_spec) are supplied, also computes inflated_value (market price after accounting for inflation) and max_my_bid (the user's spending ceiling on this player given their remaining budget and open slots) — useful before deciding what to bid.

view_roster

view_roster(board: DataFrame, team_name: str) -> pd.DataFrame

Players drafted by team_name with the winning bid for each, ordered by conventional position sequence then by VORP within position. Use view_budget_status for the team's spent/remaining summary.

_build_team_roster

_build_team_roster(board: DataFrame, team_name: str, roster_spec) -> Roster

Reconstruct a Roster from a team's picks on the board so we can score positional need without an external state dict.

simulate_nomination

simulate_nomination(board: DataFrame, *, team_names: Iterable[str], salary_cap: int, roster_spec, min_bid: int = 1, noise_floor: float = 2.0, noise_slope: float = 0.1, user_team: str = 'My Team', user_strategy: str = 'value', rng: Optional[Generator] = None) -> tuple[str, str, int]

Auto-simulate one nomination + Vickrey auction on the current board. Returns (player_name, winning_team, winning_bid).

A random team with open slots nominates the highest-salary_value player they can still afford. Every team then privately computes a reservation = need_factor * salary_value + N(0, sd), scaled by _USER_STRATEGY_MULT[user_strategy] for the user's team and clipped to max_bid for each team. The winner is the highest reservation; the price is the second-highest reservation + min_bid (the English-auction floor when bids tick up by the minimum increment).

If no team is willing to bid at least min_bid, the nominator is forced to take the player at min_bid -- a degenerate case that only happens late in the draft when budgets are exhausted.

Raises ValueError if every team has a full roster or no undrafted players remain.

view_budget_status

view_budget_status(board: DataFrame, team_names: Iterable[str], *, salary_cap: int, roster_spec, min_bid: int = 1) -> pd.DataFrame

Per-team cap snapshot: spent, remaining, slots filled and open, and max legal bid given current state.

Sorted by remaining budget descending so the teams with the most spending power left sit at the top -- that's typically who you're competing with on the next nomination.