Spaces:
Sleeping
WNBA Player Projections App
Live in-game player prop projection tool for the WNBA. Generates over/under lines and American odds for points, rebounds, assists, threes made, combo markets, double-doubles, and triple-doubles using a suite of Bayesian models fit with PyMC and served through a Streamlit UI.
Table of Contents
- Architecture Overview
- Models
- Simulation Pipeline
- Points, Threes, Rebounds, Assists
- Combo Markets & DD/TD
- Odds & Margin
- App UI
- Combined Markets Tab
- Team Stats Tab
- Sidebar Settings
- Files
- Usage Examples
Architecture Overview
Training scripts (offline) Posteriors (.nc files) Streamlit app (live)
βββββββββββββββββββββββββ βββββββββββββββββββββ ββββββββββββββββββββ
player_played_probaility.py prob_playing_posterior_PREMATCH.nc
minutes_model.py expected_minutes_posterior.nc
wnba_shots_attempted.py shots_attempted_posterior.nc app2.py / streamlit_app.py
make_rate_model.py make_rate_posterior.nc (loads all 6 posteriors,
rebounds_model.py rebounds_posterior.nc runs Monte Carlo sim,
assists_model.py assists_posterior.nc renders projections)
Each training script queries Sportradar WNBA data from Snowflake, fits a PyMC model, and saves the posterior as a NetCDF file. The app loads all six posteriors at startup, then draws from them to simulate stat lines on demand.
Models
1. Probability of Playing
File: player_played_probaility.py | Posterior: prob_playing_posterior_PREMATCH.nc
Pre-match Bernoulli model for whether a player appears in the game at all. Not a live in-game model β it doesn't consume clock or score differential.
Structure:
logit(p) = intercept + player_dev + beta_preseason * (is_pre * is_bench)
+ beta_postseason * (is_post * is_bench)
player_dev ~ ZeroSumNormal(sigma = player_sigma)β individual player tendency to see playing time- Preseason/postseason effects only apply to bench players (starters play in all game types roughly equally)
- Starters and players with current minutes > 0 get P(play) = 1.0 at inference
- Rationale: this is intentionally a pre-match model. Mixing a live hazard P(play) with a pre-match
E[mins | plays]creates an inconsistency β the conditional expectation doesn't depend on clock, so multiplying by a clock-dependent play probability produces a distorted minutes distribution. Late-game "will they enter" decisions are better handled manually (set bench player minutes to 0 or Hide them).
Likelihood: Bernoulli (one row per player per game, observed = did they play)
2. Expected Minutes
File: minutes_model.py | Posterior: expected_minutes_posterior.nc
Dirichlet-Multinomial model that allocates total team seconds across the roster. Models the share of total team-seconds each player receives.
Structure:
lp_i = player_dev_i + beta_starter * starter_i + beta_ot * (OT * starter_i)
+ beta_preseason * (pre * starter_i) + beta_postseason * (post * starter_i)
player_dev ~ ZeroSumNormal(sigma = player_sigma)β constrained to sum to zerophi = softmax(lp)β share vector across rosteralpha = kappa * phiβ concentration parameterskappa ~ Gamma(5, 0.2)β controls overdispersion- Padded + masked to handle variable roster sizes (set
lp = -100for padding slots)
Likelihood: DirichletMultinomial(a = alpha, n = total_seconds, observed = seconds_per_player)
At inference, the app computes xminutes = P(play) * DirichletMultinomial_draw, then normalizes the team total to the game's expected total minutes (200 for regulation, +25 per OT).
3. Shot Attempts
File: wnba_shots_attempted.py | Posterior: shots_attempted_posterior.nc
Three separate NegativeBinomial models (FT, 2P, 3P) for shot attempts per game, with minutes as an exposure.
Structure (each shot type):
mu = exp(intercept + player_dev + log(minutes))
player_devuses non-centered parameterization:player_dev = sigma * zalpha(overdispersion) varies by position (Center/Forward/Guard)- For threes: includes an additional
position_dev ~ ZeroSumNormalnested insideplayer_dev
Likelihood: NegativeBinomial(mu, alpha) for each shot type
All three posteriors are merged into a single shots_attempted_posterior.nc via xarray.
4. Make Rates
File: make_rate_model.py | Posterior: make_rate_posterior.nc
Three Binomial models for FT%, 2P%, and 3P%, estimating the probability of making each attempted shot.
Structure:
p = invlogit(intercept[position] + player_dev - opponent_dev) # 2P, 3P
p = invlogit(intercept[position] + player_dev) # FT (no opponent)
interceptis position-specific (Guards/Forwards/Centers shoot at different baseline rates)player_devis non-centered:sigma * zopponent_devis non-centered:sigma * z(for 2P and 3P only; FT has no opponent effect)- Opponent effect is subtracted β higher
opp_dev= harder to score against
Likelihood: Binomial(n = attempts, p = make_prob)
All three posteriors merged into make_rate_posterior.nc.
5. Rebounds
File: rebounds_model.py | Posterior: rebounds_posterior.nc
NegativeBinomial model for rebounds per game with minutes exposure. Player-only effects (no opponent).
Structure:
mu = exp(intercept + player_dev + log(minutes))
intercept ~ Normal(-1.7, 0.5)player_dev ~ ZeroSumNormal(sigma = player_sigma)alpha ~ HalfNormal(1.25)β scalar (not position-specific)
Likelihood: NegativeBinomial(mu, alpha)
6. Assists
File: assists_model.py | Posterior: assists_posterior.nc
NegativeBinomial model for assists per game with minutes exposure and opponent defensive effect.
Structure:
mu = exp(intercept + player_dev - opp_dev + log(minutes))
intercept ~ Normal(-2.3, 0.5)player_dev ~ ZeroSumNormal(sigma = player_sigma)opp_devis non-centered:opp_sigma * opp_dev_zβ higher opp_dev = fewer assists allowedalpha ~ HalfNormal(1.25)β scalar
Likelihood: NegativeBinomial(mu, alpha)
Simulation Pipeline
The simulation runs in run_simulation() and proceeds in three stages. The Use Minutes Model toggle in the sidebar controls whether Stages 1 and 2 execute.
Stage 1: P(Play) (skipped when Use Minutes Model is off)
For each non-starter with zero current minutes, draw from the pre-match P(Play) posterior and evaluate logit(p) = intercept + player_dev + beta_preseason * is_pre + beta_postseason * is_post. Starters and anyone with current minutes > 0 get P = 1.0.
Stage 2: Minutes Allocation (skipped when Use Minutes Model is off)
Draw roster-wide minute shares from the DirichletMultinomial posterior, then multiply by P(play) to get expected minutes (xminutes). The result is normalized so the team total equals the game's expected total minutes. Players can have their minutes "fixed" (locked to a specific value) by the user.
When Use Minutes Model is off: Stages 1 and 2 are entirely skipped. Each player's expected minutes are set to whatever the user entered in the widget (defaulting to 30 on first Generate). P(Play) is implicitly 1.0 for everyone. Subsequent Recalculate Lines presses never re-allocate minutes β the user is fully in charge of the minutes input.
Stage 3: Stat Simulation (Shared Minutes)
For each player, a single set of minutes draws is resampled from xminutes. The same remaining_minutes vector (clipped at 0.1 after subtracting current minutes played) is passed to all three stat simulators:
- Points β loops over (threes, twos, FT): draws attempts from NegBin, makes from Binomial, sums
point_value * made. Threes made are tracked separately for the 3PM market. - Rebounds β draws from NegBin given the same
remaining_minutes. - Assists β draws from NegBin with opponent effect, given the same
remaining_minutes.
Sharing minutes draws across stats is critical for DD/TD accuracy, since a player who draws high minutes in one sample will have more of all stats in that sample, correctly capturing the positive correlation.
Stat Simulation
Points
For each of three shot types (threes, twos, free throws):
attempts ~ NegBin(mu = exp(intercept + player_dev + adj) * remaining_minutes, alpha)
made ~ Binomial(attempts, invlogit(intercept + player_dev - opp_dev))
points += point_value * made
3PM is a byproduct β no separate model needed. The threes_made count from the points sim is used directly.
Rebounds
rebounds ~ NegBin(mu = exp(intercept + player_dev + adj) * remaining_minutes, alpha)
Assists
assists ~ NegBin(mu = exp(intercept + player_dev - opp_dev + adj) * remaining_minutes, alpha)
All stat functions add current_stat (live game accumulation) to the simulated remaining-game output.
Combo Markets
Computed from the joint (pts, reb, ast) draw vectors:
| Market | Formula |
|---|---|
| P+R | pts + reb |
| P+A | pts + ast |
| R+A | reb + ast |
| P+R+A | pts + reb + ast |
| DD | count of (pts >= 10, reb >= 10, ast >= 10) >= 2 |
| TD | count of (pts >= 10, reb >= 10, ast >= 10) >= 3 |
DD and TD are offered as "Yes" at the implied odds (no Under side displayed, since independent margin application on both sides can create arbitrage at extreme probabilities).
Odds & Margin
MOPS Margin
Raw model probabilities are converted to American odds with a margin applied via the MOPS (Maximum Overround Per Selection) function:
margin = min_or + (max_or - min_or) * scale
where scale = prob * (1 - prob) / (peak * (1 - peak)), capped at 1.0. This produces the highest margin at the peak probability and lower margins as probabilities approach 0 or 1.
Default parameters:
max_or = 0.04(4% max overround)min_or = 0.005(0.5% min overround)peak = 0.50(max margin at 50/50)
Balanced Lines
For each stat, the app finds the half-integer line closest to the median that is nearest to a 50/50 split:
low = floor(median) - 0.5
high = low + 1.0
# pick whichever has P(over) closer to 0.5
American Odds Conversion
if prob <= 0.5: odds = +((1/prob - 1) * 100)
if prob > 0.5: odds = -((1/(1-prob) - 1) * 100)
App UI
Layout
The app is organized with most controls in the sidebar and projections per team in the main area.
Sidebar:
- Links to Documentation and the CSV Import/Export guide
- Recalculate Lines button β re-simulates stats using current inputs; does not re-allocate minutes
- Export Odds β upload a BOSS market CSV, download a populated export CSV
- Matchup β team + opponent dropdowns
- Use Minutes Model checkbox (see section below)
- Game State β quarter, clock, scores
- Game Type β OT periods, season type (Regular/Preseason/Postseason)
- Collapsible settings: Simulation Settings, Pace, MOPS Margin, Odds Filter
- Extra Columns multiselect
Main area:
- Team tabs β one tab per team, each containing:
- Player multiselect + starter toggles
- Custom player entry (for players not in the posterior)
- "Generate Projections" button β runs the minutes model (if enabled) and seeds all per-player state
- Per-player rows with columns for:
- Controls (reorder, fix minutes, hide, freeze)
- Minutes (current + expected, adjustable)
- Points (current, balanced line + odds, adjustment, freeze)
- Rebounds (current, balanced line + odds, adjustment, freeze)
- Assists (current, balanced line + odds, adjustment, freeze)
- Optional: 3PM, combos (P+R, P+A, R+A, P+R+A), DD, TD
- Hidden players section
- Total minutes readout
- Offerings dataframe
- Combined Markets tab β price the sum of a stat across 2+ players
- Team Stats tab β team totals for 3PM, Points, Rebounds, Assists
Generate Projections vs. Recalculate Lines
- Generate Projections (per team, inside each team tab): full reset. Runs the minutes model (if enabled), resets all per-player adjustments and live inputs, populates initial minutes and stat projections. Use this when setting up a new game or when you want to start fresh.
- Recalculate Lines (global, in sidebar): re-runs stat simulations using the current widget state. Minutes do not move β whatever value is in each player's expected-minutes widget stays there. Use this after entering live game inputs or adjustments.
Use Minutes Model
Controlled by a checkbox in the sidebar, defaulted off. A dialog prompts for acknowledgment the first time you turn it on.
When off (default):
- Generate Projections seeds every player to 30.0 expected minutes.
- The P(Play) and Minutes Allocation stages are skipped entirely.
- Editing one player's minutes widget does not redistribute to others β each widget stands alone.
- Recalculate Lines never changes minutes.
- Intended workflow: user is responsible for entering expected minutes for each player they care about. Good for offering a subset of players without worrying about unentered bench players distorting the allocation.
When on:
- Generate Projections runs the full minutes model (P(Play) + DirichletMultinomial).
- Editing one player's minutes triggers proportional redistribution to other non-fixed players so the team total stays at 200 (or 200 + 25 per OT).
- Recalculate Lines still does not re-run the minutes model β only Generate does.
- Requires every available player to be on the roster. The Dirichlet allocation splits total minutes across the entire selected roster, so any player you haven't added implicitly has their minutes reassigned to everyone else.
Per-Player Controls
| Control | Effect |
|---|---|
| Fix | Locks expected minutes to the current slider value during recalculation |
| Hide | Removes player from display and freezes all offerings so they don't appear in the Offerings table |
| Freeze | Excludes the player's offerings from the Offerings table |
| Freeze Pts/Reb/Ast/3PM | Excludes that specific market from Offerings |
| Adjustment sliders | Log-scale shift applied to the rate parameter (e.g., +0.1 means ~10% more) |
Custom Players
Players not in the posterior can be added manually. Their effects are drawn from the prior distribution (Normal(0, sigma) using the posterior's sigma draws), giving them league-average priors with appropriate uncertainty.
Milestone Dialogs
Clicking a balanced line opens a dialog with customizable milestones. Each milestone shows the over probability and American odds. DD/TD dialogs show a single "Yes" row. Milestones persist in session state and default per-market:
| Market | Default Milestones |
|---|---|
| pts | 10, 15, 20, 25, 30 |
| reb | 4, 6, 8, 10, 12 |
| ast | 4, 6, 8, 10, 12 |
| 3pm | 2, 3, 4, 5, 6 |
| P+R | 20, 25, 30, 35, 40 |
| P+A | 15, 20, 25, 30, 35 |
| R+A | 8, 10, 12, 15, 18 |
| P+R+A | 25, 30, 35, 40, 45 |
Offerings Table
A filterable dataframe at the bottom of each team tab listing all active offerings (player, market, milestone, odds). Respects freeze flags and odds range filters.
Combined Markets Tab
The Combined Markets tab lets you price the sum of a stat across two or more players from either team. This is distinct from the per-player combo columns (P+R, P+A, etc.) β those combine stats for a single player, while combined markets combine the same stat across multiple players.
For example, you can price P(Wilson Pts + Clark Pts >= 50), which is the probability that their combined point total hits 50 or more. This is not the product of their individual over probabilities β it's the distribution of their actual sum.
How It Works
- Select a stat (Points, Rebounds, Assists, 3PM) and pick 2+ players from either team
- Enter milestones and click Add to create a combo card
- The app sums each player's simulation draw vectors element-wise to produce the combined distribution
- Same-team combos naturally capture shared-minutes correlation (those draws came from the same simulation run). Cross-team combos are independent, which is correct since their minutes aren't linked.
Per-Combo Controls
| Control | Description |
|---|---|
| Current Minutes (per player) | Synced with the main team tabs β editing here updates the main tab and vice versa. Flags lines as stale for recalculation. |
| Current Stat (per player) | Synced with the main team tabs. Allows live game stat entry without switching tabs. |
| Strength Adjustment | Log-scale multiplier applied to the combined draw vector. Positive values scale the sum up (shorter odds), negative values scale it down. |
| Milestones | Editable text input β add or remove milestones at any time without recreating the combo. |
| Remove | Deletes the combo card. |
Combined market draws update automatically when you hit Recalculate Lines, since they pull from the same simulation results stored in session state.
Team Stats Tab
The Team Stats tab prices a stat total across an entire team's roster. The primary use case is team threes (e.g., O 10.5 team 3PM), but it supports Points, Rebounds, and Assists as well.
How It Works
Unlike the main team tabs which use pre-computed simulation draws, the Team Stats tab draws fresh from the posterior on each render, using time-proportioned remaining minutes:
- Takes each player's expected minutes from the main tab (respects fixed minutes, hide, etc.)
- Multiplies by the fraction of the game remaining (e.g., 0.25 at the start of Q4)
- Draws fresh from the posterior for each player using those proportioned remaining minutes
- Sums all player draws to get the team distribution
- Adds the current team total on top
This approach correctly captures the NegativeBinomial variance for shorter time windows β scaling full-game draws down would produce the wrong spread.
Controls
| Control | Description |
|---|---|
| Stat selector | Choose 3PM (default), Points, Rebounds, or Assists |
| Current Team Stat | The team's current total for the selected stat. Added on top of the projected remaining-game draws. |
| Strength Adjustment | Log-scale shift applied to every player's per-minute rate. Positive values boost the entire team's rate. |
| Milestones | Editable per-team, per-stat. Defaults: 3PM (6, 8, 10, 12, 14), Points (80, 90, 100, 110), Rebounds (30, 35, 40, 45), Assists (18, 22, 26, 30). |
Important Notes
- The game clock and score at the top of the page drive the time-remaining fraction. Always update those before reading team stats.
- You do not need to update individual player minutes on the main tabs for team stats to work β it uses expected minutes proportioned by time remaining.
- If you have updated individual player minutes and recalculated on the main tabs, the
adj_minsvalues will reflect those changes and the team stats tab will use them. - Fresh posterior draws means results will vary slightly on each page render. This is expected β the variance is small with 2000 draws.
Sidebar Settings
| Setting | Default | Description |
|---|---|---|
| Use Minutes Model | off | When on, Generate runs the full minutes model; when off, minutes default to 30 and never auto-change |
| Minutes draws | 2000 | Number of Monte Carlo draws for minutes allocation |
| Points draws | 2000 | Number of draws for stat simulation |
| Total possessions | 160 | Both teams combined; legacy input, no longer used in scoring (kept for the info caption) |
| MOPS Max OR | 0.040 | Maximum overround at peak probability |
| MOPS Min OR | 0.005 | Minimum overround at extreme probabilities |
| MOPS Peak | 0.50 | Probability where maximum margin is applied |
| Shortest odds | -500 | Minimum negative American odds for Offerings filter |
| Longest odds | +6000 | Maximum positive American odds for Offerings filter |
| Visible columns | All | Toggle 3PM, P+R, P+A, R+A, P+R+A, DD, TD |
The Quarter / Clock inputs feed the Team Stats tab (they drive the remaining-game fraction). Scores are no longer consumed by any model now that P(Play) is pre-match.
Files
| File | Purpose |
|---|---|
app2.py |
Local Streamlit application (loads posteriors from working directory) |
src/streamlit_app.py |
HF Space version (downloads posteriors from the HF dataset) |
player_played_probaility.py |
Train pre-match Bernoulli P(play) model |
minutes_model.py |
Train DirichletMultinomial minutes model |
wnba_shots_attempted.py |
Train NegBin shot attempt models (FT, 2P, 3P) |
make_rate_model.py |
Train Binomial make rate models (FT%, 2P%, 3P%) |
rebounds_model.py |
Train NegBin rebounds model |
assists_model.py |
Train NegBin assists model (with opponent effect) |
player_position_mapping.csv |
Player-to-position lookup (Guard/Forward/Center) |
*.nc |
Saved ArviZ/NetCDF posteriors loaded by the app |
Usage Examples
Pregame Setup
Select both teams, set the game state to Q1 10:00 with scores 0-0, choose the season type, and select each team's active roster (up to 15 players). You must add all active players β the minutes model allocates total team minutes across the entire selected roster, so missing a player means their minutes get incorrectly distributed to everyone else. If you add a player who is injured or inactive, you need to fix their minutes to 0, otherwise the model will allocate them minutes and everyone else's projections will be too low. The simplest approach is to only select players who are actually available for the game.
The first five selected are auto-toggled as starters. Hit "Generate Projections" to run the initial simulation. This gives you a full set of pregame lines and odds.
Adjusting a Player's Minutes Expectation
Scenario: You hear pregame that a player is on a minutes restriction (say 24 minutes max), but the model has them projected for 30.
What to do: Drag their expected minutes slider down to 24, then check the "Fix" checkbox to lock it. Hit "Recalculate Lines." The freed-up 6 minutes get redistributed proportionally to all other non-fixed players on the roster.
Effect: That player's stat projections (pts, reb, ast, combos) all shift down because the simulation uses fewer remaining minutes. Other players' lines shift slightly up since they absorb the extra minutes.
Updating Live Game State
Scenario: It's the 3rd quarter, 4:30 left, score is 62-55. A star player has 18 points, 4 rebounds, 3 assists, and 12 minutes played.
What to do: Update the game state (Q3, 4:30, scores). For the player, enter their current minutes (12), current points (18), current rebounds (4), and current assists (3). Hit "Recalculate Lines."
Effect: The simulation now only projects the remaining portion of the game. It subtracts 12 from the player's expected total minutes, then simulates stats over that remainder and adds back the current totals. The lines shift to reflect what the player is likely to finish with, not what they'd get from scratch. Note that bench player P(Play) does not update with the clock β the P(Play) model is pre-match, so bench player availability is the same regardless of time remaining. Manually set bench minutes to 0 or Hide the player if they clearly aren't going to check in.
Freezing a Market You've Already Priced
Scenario: You've already posted a player's points line to your book and don't want it to change when you recalculate for other updates. But you still want their rebounds and assists lines to update.
What to do: Check "Freeze Pts" for that player.
Effect: On the next recalculate, points lines and odds still update visually (the simulation re-runs), but the points market for that player is excluded from the Offerings table. This prevents you from accidentally re-posting a stale or conflicting line. Rebounds and assists continue to appear in Offerings normally.
Freezing an Entire Player
Scenario: A player fouls out with 5 minutes left in the game. They played 22 minutes. You've already settled their props.
What to do: Check "Freeze" for that player. Also set their expected minutes to 22 (their actual minutes played), check "Fix," and recalculate. This ensures the minutes they were projected to play are redistributed to the remaining active players.
Effect: All of their offerings (every market) are excluded from the Offerings table. Their lines still display in the UI for reference, but nothing for that player appears in the exportable Offerings dataframe. Fixing their minutes to what they actually played ensures the rest of the roster's projections stay accurate.
Hiding a Player Entirely
Scenario: A player is injured and out for the game, or you simply don't want to see a deep bench player cluttering the UI.
What to do: Check "Hide" for that player.
Effect: The player is moved to a collapsed "Hidden Players" section at the bottom. Their stat draws are zeroed out and all their offerings are excluded from the Offerings table. Their expected minutes still count toward the team total. If the player got injured mid-game, you should also fix their minutes to their current minutes played and recalculate so the remaining minutes are redistributed to the rest of the roster. If you're just hiding a bench player to reduce UI clutter, leave their minutes as-is β the model still needs to account for them in the minutes allocation. If a player is unavailable before the game starts, simply don't add them to the roster.
Applying a Player Adjustment
Scenario: You have reason to believe a player's scoring rate is higher than what the model estimates β maybe they've been on a hot streak that the posterior hasn't fully captured yet, or the opponent's rim protector is out.
What to do: Increase the Points Adjustment slider (e.g., to +0.10).
Effect: The adjustment is added directly to the log-rate in the NegBin model. Since it's on the log scale, +0.10 corresponds to roughly a 10% multiplicative increase in the per-minute scoring rate (exp(0.10) β 1.105). This shifts the entire distribution to the right β the balanced line may move up, and the odds on existing milestones will shorten. The same logic applies to the rebounds and assists adjustment sliders.
Narrowing the Odds Filter
Scenario: You only want to offer props where the odds fall between -300 and +2000. Anything shorter or longer is outside your risk appetite.
What to do: In the sidebar, set "Shortest odds" to -300 and "Longest odds" to +2000.
Effect: The Offerings table filters out any row with odds shorter than -300 or longer than +2000. This doesn't affect the displayed lines or the milestone dialogs β just what shows up in the final Offerings dataframe. Useful for removing extreme favorites (e.g., a star's O 10.5 pts at -900) and extreme longshots (e.g., a bench player's O 30.5 pts at +15000) in one pass.
Tightening or Loosening the Margin
Scenario: You want to increase your edge on close-to-even markets but keep longshot odds relatively clean.
What to do: Increase "MOPS Max OR" in the sidebar (e.g., from 0.04 to 0.06). Leave "MOPS Min OR" and "Peak" as-is.
Effect: Markets near 50/50 probability now carry 6% overround instead of 4%. A true 50% probability that previously priced at -108 will now price at -112. Markets at extreme probabilities (e.g., 5% or 95%) are barely affected because the MOPS curve scales down the margin as you move away from the peak. This lets you charge more where bettors are least price-sensitive (coin-flip markets) without making longshots uncompetitively juiced.
Adding a Custom Player
Scenario: A team just signed a player who isn't in any of the posteriors yet. You still want to project them.
What to do: Expand "Add Custom Player," enter their name, select their position, toggle starter if applicable, and click Add.
Effect: The app draws all of their model effects from the prior distribution (Normal(0, sigma) using the posterior's sigma draws). This gives them league-average rates for their position with full posterior uncertainty. Their projections will be reasonable but generic β they'll look like a typical player at that position. As the posteriors are retrained with their actual game data, they'll develop their own learned effects.
Handling Overtime
Scenario: The game has gone to overtime.
What to do: Set "Overtime Periods" to 1 (or 2, etc.) in the Game Type section. Recalculate.
Effect: Two things change. First, total team minutes increases from 200 to 225 (each OT adds 5 minutes x 5 players = 25 minutes). The DirichletMultinomial allocates these extra minutes, and the OT beta effect in the minutes model adjusts how starters vs. bench players absorb the extra time (starters tend to play a larger share of OT). Second, the maximum per-player minutes cap increases from 40 to 45. Stat projections increase across the board since there's more game left to play.
Projecting a Preseason Game
Scenario: It's a preseason game and you expect rotations to be more spread out.
What to do: Set season type to "Preseason."
Effect: The minutes model includes a beta_preseason effect that adjusts the starter/bench split. In preseason games, starters typically play fewer minutes (coaches experiment with lineups), so the starter effect is dampened. This redistributes expected minutes more evenly across the roster, which lowers star player projections and raises bench projections compared to the same roster in a regular season game.
Pregame Calibration Against the Official Model
Scenario: Before the game starts, you want to align this live model with what the official prematch model is offering on site, so that when you go live you're starting from the right baseline.
What to do: For each player you plan to offer, open their milestone dialog and compare the odds at each line to what's on site. For example, say A'ja Wilson's points milestones look like this:
| Milestone | Prematch (on site) | Live Model |
|---|---|---|
| 25+ | -155 | -137 |
| 30+ | +130 | +155 |
| 35+ | +250 | +291 |
| 40+ | +450 | +516 |
The live model is longer across the board β it thinks she's slightly less likely to hit each milestone. You should increase the Points Adjustment slider to boost her per-minute scoring rate, which shortens those odds until they roughly match the prematch prices. Conversely, if the live model is shorter than the site, decrease the adjustment. Repeat for rebounds, assists, and any other markets you're offering.
Effect: The adjustment shifts the player's per-minute rate on the log scale, moving the entire distribution. This doesn't change the model's structure β it just anchors the starting point to match the official prematch prices. Once the game begins and you start entering live game state (current stats, minutes, score), the model will update projections from this calibrated baseline rather than from its own raw posterior. This is the recommended workflow for live traders: calibrate pregame, then let the model handle in-game updates.
Using Milestone Dialogs for Custom Lines
Scenario: You want to offer a player's points at 22.5 instead of the model's balanced line of 19.5.
What to do: Click the balanced line button for that player's points column to open the milestone dialog. Edit the milestones to include 23 (since "O 22.5" is equivalent to "23+"). The dialog shows you the exact probability and American odds for that milestone.
Effect: You can see the model's implied probability for any threshold you want, not just the balanced line. This is useful for matching a competitor's line or finding value at specific numbers.
Exporting Odds to BOSS
The "Export Odds" section in the sidebar lets you push the tool's priced odds into BOSS via BOSS's CSV Import/Export flow. The tool matches your priced offerings against the markets you've created in BOSS and outputs a ready-to-import CSV with every selection and its decimal odds.
Step-by-step:
Create the markets in BOSS. In BOSS, create a manual market for every player-market you plan on offering, and name each market correctly. For example, if you want to trade NaLyssa Smith points, create a Live Manual Points Milestones market and name it
NaLyssa Smith Points. You do not need to create any selections β just the market itself.Repeat for every player-market. The market name must match the player's name in this tool exactly. If the name doesn't match (e.g., the player isn't in the underlying posteriors), you must first add them as a Custom Player in this tool using the same name.
Freeze everything you do not plan on offering. A few ways to control what gets exported:
- If you don't want to offer a market type at all, remove it from "Visible columns" in the sidebar.
- If you plan on offering a market for just a few players, use the per-player Freeze checkboxes (Freeze Pts / Reb / Ast / 3PM / combo) to exclude individual players from that market.
- The whole-player "Freeze" and "Hide" checkboxes exclude every market for that player.
Download the BOSS template. In BOSS, go to Markets Management β CSV Import / Export. Select all of the markets you just created. In the top right, click the download button to export the CSV template. This file contains the
MarketId,MarketName,MarketTypeName, and other metadata for each market you selected.Upload the template to the tool. In the tool's sidebar, under "Export Odds," use the file uploader to upload the CSV you just downloaded from BOSS.
Download the export CSV. A "Download Export CSV" button appears directly below the uploader within a few seconds. The downloaded file preserves every column from the original (MarketId, MarketName, MarketTypeId, MarketTypeName, StartSuspensionDate, FirstOdds, LastOdds, AnyOdds) and overwrites only
SelectionName(e.g.,10+,15+, or the player's name for DD/TD markets) andSelectionOdds(decimal format, after MOPS margin, filtered by the sidebar odds filter).Upload back to BOSS. Go back to Markets Management β CSV Import / Export in BOSS and click "Update Selection". Upload the CSV the tool just produced. BOSS will populate every selection on every market with the matched odds automatically.
Notes:
- DD and TD markets (
To Record a Double-Double,To Record a Triple-Double) are single markets in BOSS with one selection per eligible player. The tool handles this correctly β uploading one template row for the market expands into one output row per simulated player whose DD/TD odds fall within the odds filter. - Unmatched rows are skipped with a sidebar warning. This typically happens when a market's player name doesn't match anyone in the simulation, or when all of that player-market's milestones are frozen or filtered out.
- Odds are always decimal and always include the MOPS margin. They're the same odds that appear in the Offerings table, just converted from American to decimal.
- The export stays live. The CSV refreshes automatically as you update current minutes, points, rebounds, assists, adjustments, or any other input. You do not need to re-upload the BOSS template β just click "Download Export CSV" again after each recalculation and upload the new file to BOSS to push the updated odds.
- Adding a new market mid-session. If you decide partway through the game to offer an additional market (new player, new stat, etc.), follow the same workflow from the start: create the new market in BOSS, go to Markets Management β CSV Import / Export, select all markets you're currently offering (every existing one plus the new one), click export to download the updated template, then re-upload it to this tool. The tool will regenerate the export CSV with the expanded market list.
Data Source
All training data comes from Sportradar WNBA tables in Snowflake (sportradar.dbo.wnba_gamesummary, sportradar.dbo.wnba_gamesummary_players, sportradar.dbo.wnba_playbyplay_possessions). Toyota Antelopes games are excluded from all models.
Environment
Models are trained with conda environment pymc (PyMC, ArviZ, NumPy, Pandas, Snowpark). The Streamlit app requires the same environment plus streamlit and scipy.