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
¶
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
¶
Roster
dataclass
¶
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
¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Starting slots + bench. Used by salary value math to count the total picks each team will make.
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 ¶
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 ¶
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.
round_direction ¶
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 ¶
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 ¶
Pick the user's player according to the chosen strategy.
_can_fit ¶
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 pickstotal_value: what the engine said those picks were worthsurplus:total_value - total_spent(positive = bought below engine valuation, negative = overpaid)picks_overpaid,picks_value: counts on each sideavg_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
¶
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')
Roster
dataclass
¶
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
¶
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 ¶
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 ¶
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 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 ¶
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.
_position_sort_key ¶
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 ¶
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 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 ¶
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 (fromcompute_salary_values).winning_bid: NaN for undrafted players, the winning price once a player is drafted. Combined withfantasy_teamthis 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
¶
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
¶
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
¶
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 ¶
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.
_position_sort_key ¶
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 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.
_roster_spec_to_dict ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.