wnba-projections / src /streamlit_app.py
btsully82's picture
changed odds threshold behaviour to go to 0 if outside of predefined range
fa17476
Raw
History Blame Contribute Delete
80.9 kB
import streamlit as st
import numpy as np
import pandas as pd
import arviz as az
from scipy.special import softmax, expit
from huggingface_hub import hf_hub_download
import time
import os
st.set_page_config(layout = "wide")
def _check_password():
expected = os.environ.get("APP_PASSWORD")
if not expected:
return
if st.session_state.get("auth_ok"):
return
pw = st.text_input("Enter access password", type = "password", key = "auth_pw_input")
if pw:
if pw == expected:
st.session_state["auth_ok"] = True
st.rerun()
else:
st.error("Incorrect password")
st.stop()
_check_password()
st.markdown("""
<style>
.balanced-line {
text-align: center;
font-size: 1.3rem;
font-weight: 600;
padding: 0.25rem 0;
}
div[class*="st-key-home_blwrap_"] .balanced-line,
div[class*="st-key-away_blwrap_"] .balanced-line {
display: none;
}
div[class*="st-key-home_minspacer_"] button,
div[class*="st-key-away_minspacer_"] button {
visibility: hidden !important;
}
div[class*="st-key-home_blwrap_"] button,
div[class*="st-key-away_blwrap_"] button,
div[class*="st-key-home_rebwrap_"] button,
div[class*="st-key-away_rebwrap_"] button,
div[class*="st-key-home_astwrap_"] button,
div[class*="st-key-away_astwrap_"] button,
div[class*="st-key-home_tpmwrap_"] button,
div[class*="st-key-away_tpmwrap_"] button,
div[class*="st-key-home_prwrap_"] button,
div[class*="st-key-away_prwrap_"] button,
div[class*="st-key-home_pawrap_"] button,
div[class*="st-key-away_pawrap_"] button,
div[class*="st-key-home_rawrap_"] button,
div[class*="st-key-away_rawrap_"] button,
div[class*="st-key-home_prawrap_"] button,
div[class*="st-key-away_prawrap_"] button,
div[class*="st-key-home_ddwrap_"] button,
div[class*="st-key-away_ddwrap_"] button,
div[class*="st-key-home_tdwrap_"] button,
div[class*="st-key-away_tdwrap_"] button {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: inherit !important;
font-size: 1.5rem !important;
font-weight: 600 !important;
text-align: center !important;
width: 100% !important;
padding: 0.25rem 0 !important;
cursor: pointer !important;
}
div[class*="st-key-home_blwrap_"] button:hover,
div[class*="st-key-away_blwrap_"] button:hover,
div[class*="st-key-home_rebwrap_"] button:hover,
div[class*="st-key-away_rebwrap_"] button:hover,
div[class*="st-key-home_astwrap_"] button:hover,
div[class*="st-key-away_astwrap_"] button:hover,
div[class*="st-key-home_tpmwrap_"] button:hover,
div[class*="st-key-away_tpmwrap_"] button:hover,
div[class*="st-key-home_prwrap_"] button:hover,
div[class*="st-key-away_prwrap_"] button:hover,
div[class*="st-key-home_pawrap_"] button:hover,
div[class*="st-key-away_pawrap_"] button:hover,
div[class*="st-key-home_rawrap_"] button:hover,
div[class*="st-key-away_rawrap_"] button:hover,
div[class*="st-key-home_prawrap_"] button:hover,
div[class*="st-key-away_prawrap_"] button:hover,
div[class*="st-key-home_ddwrap_"] button:hover,
div[class*="st-key-away_ddwrap_"] button:hover,
div[class*="st-key-home_tdwrap_"] button:hover,
div[class*="st-key-away_tdwrap_"] button:hover {
opacity: 0.7 !important;
background: transparent !important;
}
</style>
""", unsafe_allow_html = True)
st.title("WNBA Player Projections (Live)")
SCORE_DIFF_STD = 8.61
is_any_stale = st.session_state.get('home_lines_stale', False) or st.session_state.get('away_lines_stale', False)
HF_DATASET_REPO = "btsully82/wnba-posteriors"
@st.cache_resource
def load_data():
files = [
'prob_playing_posterior_PREMATCH.nc',
'expected_minutes_posterior.nc',
'shots_attempted_posterior.nc',
'make_rate_posterior.nc',
'rebounds_posterior_2.nc',
'assists_posterior.nc',
'player_position_mapping.csv',
]
local_paths = {}
for f in files:
local_paths[f] = hf_hub_download(repo_id = HF_DATASET_REPO, filename = f, repo_type = "dataset")
playing_post = az.from_netcdf(local_paths['prob_playing_posterior_PREMATCH.nc'])
minutes_post = az.from_netcdf(local_paths['expected_minutes_posterior.nc'])
shots_post = az.from_netcdf(local_paths['shots_attempted_posterior.nc'])
rate_post = az.from_netcdf(local_paths['make_rate_posterior.nc'])
reb_post = az.from_netcdf(local_paths['rebounds_posterior_2.nc'])
ast_post = az.from_netcdf(local_paths['assists_posterior.nc'])
position_map = pd.read_csv(local_paths['player_position_mapping.csv']).set_index('player')['position'].to_dict()
return playing_post, minutes_post, shots_post, rate_post, reb_post, ast_post, position_map
playing_post, minutes_post, shots_post, rate_post, reb_post, ast_post, position_map = load_data()
teams = sorted(rate_post.posterior['twos_opponent_dev'].coords['opponent'].values.tolist())
all_players_minutes = minutes_post.posterior['player_dev'].coords['player'].values.tolist()
all_players_shots = shots_post.posterior['threes_player_dev'].coords['player'].values.tolist()
all_players_reb = reb_post.posterior['player_dev'].coords['player'].values.tolist()
all_players_ast = ast_post.posterior['player_dev'].coords['player'].values.tolist()
available_players = sorted(set(all_players_minutes) & set(all_players_shots) & set(all_players_reb) & set(all_players_ast))
team_players_list = [p for p in available_players if p in position_map]
rng = np.random.default_rng()
N_PRIOR_SAMPLES = 10000
def _draw_prior(sigma_var, posterior, n=N_PRIOR_SAMPLES):
sigma_draws = posterior[sigma_var].values.flatten()
idx = rng.choice(len(sigma_draws), size=n, replace=True)
return rng.normal(0, sigma_draws[idx])
def generate_custom_player_effects(player_name, position):
play_post = playing_post.posterior
min_post = minutes_post.posterior
shots = shots_post.posterior
rates = rate_post.posterior
reb = reb_post.posterior
ast = ast_post.posterior
effects = {
'playing_player_dev': _draw_prior('player_sigma', play_post),
'minutes_player_dev': _draw_prior('player_sigma', min_post),
'ft_player_dev': _draw_prior('ft_player_sigma', shots),
'twos_player_dev': _draw_prior('twos_player_sigma', shots),
'threes_player_dev': _draw_prior('threes_player_sigma', shots),
'ft_make_player_dev': _draw_prior('ft_player_sigma', rates),
'twos_make_player_dev': _draw_prior('twos_player_sigma', rates),
'threes_make_player_dev': _draw_prior('threes_player_sigma', rates),
'reb_player_dev': _draw_prior('player_sigma', reb),
'ast_player_dev': _draw_prior('player_sigma', ast),
}
return effects
if 'custom_player_effects' not in st.session_state:
st.session_state['custom_player_effects'] = {}
## simulation settings
with st.sidebar:
st.markdown("[Documentation](https://huggingface.co/spaces/btsully82/wnba-projections/blob/main/DOCUMENTATION.md)")
st.markdown("[How to use CSV Import/Export](https://huggingface.co/spaces/btsully82/wnba-projections/blob/main/DOCUMENTATION.md#exporting-odds-to-boss)")
st.divider()
st.subheader("Recalculate")
if is_any_stale:
st.warning("Inputs have changed. Click Recalculate Lines to update.")
if st.button("Recalculate Lines", key = "global_recalc_btn", use_container_width = True):
st.session_state['trigger_recalc'] = True
st.divider()
st.subheader("Export Odds")
boss_uploaded = st.file_uploader("Upload BOSS market CSV", type = ["csv"], key = "boss_upload")
boss_export_slot = st.empty()
st.divider()
st.subheader("Matchup")
team = st.selectbox("Team", options = teams, index = None, placeholder = "Select a team...")
opp_options = [t for t in teams if t != team] if team else teams
opponent = st.selectbox("Opponent", options = opp_options, index = None, placeholder = "Select an opponent...")
st.divider()
def _on_mm_change():
if st.session_state.get('use_minutes_model', False):
st.session_state['use_minutes_model'] = False
st.session_state['_mm_dialog_open'] = True
use_minutes_model = st.checkbox("Use Minutes Model", value = False, key = "use_minutes_model", on_change = _on_mm_change)
st.divider()
st.subheader("Game State")
quarter = st.selectbox("Quarter", options = [1, 2, 3, 4], index = 0)
clock_min = st.number_input("Clock Min", min_value = 0, max_value = 10, value = 10, step = 1)
clock_sec = st.number_input("Clock Sec", min_value = 0, max_value = 59, value = 0, step = 1)
home_score = st.number_input(f"{team} Score" if team else "Team Score", min_value = 0, max_value = 300, value = 0, step = 1)
away_score = st.number_input(f"{opponent} Score" if opponent else "Opponent Score", min_value = 0, max_value = 300, value = 0, step = 1)
st.divider()
st.subheader("Game Type")
ot_periods = st.number_input("Overtime Periods", min_value = 0, max_value = 5, step = 1, value = 0)
season_type = st.radio("Season Type", options = ["Regular", "Preseason", "Postseason"], index = 0, horizontal = True)
st.divider()
with st.expander("Simulation Settings", expanded = False):
N_DRAWS_MINUTES = st.number_input("Minutes draws", min_value = 100, max_value = 10000, value = 2000, step = 100)
N_DRAWS_POINTS = st.number_input("Points draws", min_value = 100, max_value = 10000, value = 2000, step = 100)
with st.expander("Pace", expanded = False):
POSS_PER_GAME = st.number_input("Total possessions (both teams)", min_value = 100, max_value = 250, value = 160, step = 5)
with st.expander("MOPS Margin", expanded = False):
MOPS_MAX_OR = st.number_input("Max OR", min_value = 0.0, max_value = 0.2, value = 0.04, step = 0.001, format = "%.3f")
MOPS_MIN_OR = st.number_input("Min OR", min_value = 0.0, max_value = 0.05, value = 0.005, step = 0.001, format = "%.3f")
MOPS_PEAK = st.number_input("Peak probability", min_value = 0.01, max_value = 0.99, value = 0.50, step = 0.01, format = "%.2f")
with st.expander("Odds Filter", expanded = False):
MAX_MINUS_ODDS = st.number_input("Shortest odds (e.g. -500)", min_value = -9900, max_value = -100, value = -500, step = 50)
MAX_PLUS_ODDS = st.number_input("Longest odds (e.g. +6000)", min_value = 100, max_value = 50000, value = 6000, step = 100)
st.divider()
st.subheader("Extra Columns")
ALL_EXTRAS = ['3PM', 'P+R', 'P+A', 'R+A', 'P+R+A', 'DD', 'TD']
visible_extras = st.multiselect("Visible columns", ALL_EXTRAS, default = ALL_EXTRAS, key = "visible_extras")
show_threes = '3PM' in visible_extras
visible_combos = [c for c in visible_extras if c != '3PM']
seconds_remaining = (4 - quarter) * 600 + clock_min * 60 + clock_sec
poss_per_second = POSS_PER_GAME / 2400
is_preseason = season_type == "Preseason"
is_postseason = season_type == "Postseason"
def mops_margin(prob, max_or, min_or, peak):
peak_val = peak * (1 - peak)
raw = prob * (1 - prob)
scale = raw / peak_val
scale = min(scale, 1.0)
margin = min_or + (max_or - min_or) * scale
return prob + margin
def prob_to_american(prob, apply_margin = True):
if prob <= 0 or prob >= 1:
return "-"
if apply_margin:
prob = mops_margin(prob, MOPS_MAX_OR, MOPS_MIN_OR, MOPS_PEAK)
prob = min(prob, 0.99)
if prob <= 0.5:
return f"+{round(((1 / prob) - 1) * 100)}"
else:
return f"{round(((1 / (1 - prob)) - 1) * -100)}"
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",
}
@st.dialog("Using Minutes Model")
def show_minutes_model_ack():
st.warning("Using the Minutes Model requires every available player to be added to the roster. If any active players are missing, their minutes will be over-allocated to the players you did add, skewing projections.")
st.markdown("If you only want to project a subset of players, keep this checkbox **off** and enter expected minutes manually.")
if st.button("Continue", use_container_width = True, type = "primary"):
st.session_state['use_minutes_model'] = True
st.session_state['_mm_dialog_open'] = False
st.rerun()
if st.session_state.get('_mm_dialog_open', False):
show_minutes_model_ack()
@st.dialog("Milestones")
def show_milestones(player_name, draws, market = 'pts'):
st.markdown(f"### {player_name}")
if market in ('DD', 'TD'):
p_yes = float(np.mean(draws >= 1))
col1, col2, col3 = st.columns(3)
col1.markdown("**Selection**")
col2.markdown("**Odds**")
col3.markdown("**Prob**")
c1, c2, c3 = st.columns(3)
c1.write("Yes")
c2.write(prob_to_american(p_yes))
c3.write(f"{p_yes:.1%}")
else:
mk = f'dialog_milestones_{market}_{player_name}'
sk = f'stored_milestones_{market}_{player_name}'
default = DEFAULT_MILESTONES.get(market, "10, 15, 20, 25, 30")
if mk not in st.session_state:
st.session_state[mk] = st.session_state.get(sk, default)
def _save_milestones(pname = player_name, m = market):
st.session_state[f'stored_milestones_{m}_{pname}'] = st.session_state[f'dialog_milestones_{m}_{pname}']
milestones_input = st.text_input("Enter milestones (comma-separated)", key = mk, on_change = _save_milestones)
_save_milestones()
milestones = sorted([int(x.strip()) for x in milestones_input.split(",") if x.strip().isdigit()])
col1, col2, col3 = st.columns(3)
col1.markdown("**Milestone**")
col2.markdown("**Odds**")
col3.markdown("**Prob**")
for m in milestones:
p_over_m = float(np.mean(draws >= m))
odds = prob_to_american(p_over_m)
c1, c2, c3 = st.columns(3)
c1.write(f"{m}+")
c2.write(odds)
c3.write(f"{p_over_m:.1%}")
if st.button("Done", use_container_width = True, type = "primary"):
st.rerun()
def find_balanced_line(draws):
median = np.median(draws)
low = np.floor(median) - 0.5
high = low + 1.0
p_over_low = np.mean(draws > low)
p_over_high = np.mean(draws > high)
if abs(p_over_low - 0.5) <= abs(p_over_high - 0.5):
return low
return high
def simulate_points_for_player(player_name, position, opp_name, remaining_minutes,
player_adj = 0.0, threes_adj = 0.0, current_points = 0, current_threes = 0):
shots = shots_post.posterior
rates = rate_post.posterior
n = len(remaining_minutes)
shots_idx = rng.choice(shots['ft_intercept'].values.flatten().shape[0], size = n, replace = True)
rate_idx = rng.choice(rates['ft_player_sigma'].values.flatten().shape[0], size = n, replace = True)
total_points = np.zeros(n)
threes_made = np.zeros(n)
is_custom = player_name in st.session_state['custom_player_effects']
for shot_type, point_value, has_opponent in [('threes', 3, True), ('twos', 2, True), ('ft', 1, False)]:
s_intercept = shots[f'{shot_type}_intercept'].values.flatten()[shots_idx]
if is_custom:
s_player_dev = st.session_state['custom_player_effects'][player_name][f'{shot_type}_player_dev'][shots_idx]
else:
s_player_dev = shots[f'{shot_type}_player_dev'].sel(player = player_name).values.flatten()[shots_idx]
s_alpha = shots[f'{shot_type}_alpha'].sel(position = position).values.flatten()[shots_idx]
shot_extra = threes_adj if shot_type == 'threes' else 0.0
mu = np.exp(s_intercept + s_player_dev + player_adj + shot_extra) * remaining_minutes
mu = np.clip(mu, 1e-6, None)
attempts = rng.negative_binomial(s_alpha, s_alpha / (s_alpha + mu))
r_intercept = rates[f'{shot_type}_intercept'].sel(position = position).values.flatten()[rate_idx]
if is_custom:
r_player = st.session_state['custom_player_effects'][player_name][f'{shot_type}_make_player_dev'][rate_idx]
else:
r_player = rates[f'{shot_type}_player_dev'].sel(player = player_name).values.flatten()[rate_idx]
if has_opponent:
r_opp = rates[f'{shot_type}_opponent_dev'].sel(opponent = opp_name).values.flatten()[rate_idx]
make_prob = expit(r_intercept + r_player - r_opp)
else:
make_prob = expit(r_intercept + r_player)
made = rng.binomial(attempts, make_prob)
total_points += point_value * made
if shot_type == 'threes':
threes_made = made
total_points += current_points
threes_made = threes_made + current_threes
return total_points, threes_made
def simulate_rebounds_for_player(player_name, remaining_minutes, reb_adj = 0.0, current_rebounds = 0):
post = reb_post.posterior
n = len(remaining_minutes)
idx = rng.choice(post['intercept'].values.flatten().shape[0], size = n, replace = True)
intercept = post['intercept'].values.flatten()[idx]
is_custom = player_name in st.session_state['custom_player_effects']
if is_custom:
player_effect = st.session_state['custom_player_effects'][player_name]['reb_player_dev'][idx]
else:
player_effect = post['player_dev'].sel(player = player_name).values.flatten()[idx]
alpha = post['alpha'].values.flatten()[idx]
mu = np.exp(intercept + player_effect + reb_adj) * remaining_minutes
mu = np.clip(mu, 1e-6, None)
rebounds = rng.negative_binomial(alpha, alpha / (alpha + mu))
return rebounds + current_rebounds
def simulate_assists_for_player(player_name, opp_name, remaining_minutes, ast_adj = 0.0, current_assists = 0):
post = ast_post.posterior
n = len(remaining_minutes)
idx = rng.choice(post['intercept'].values.flatten().shape[0], size = n, replace = True)
intercept = post['intercept'].values.flatten()[idx]
is_custom = player_name in st.session_state['custom_player_effects']
if is_custom:
player_effect = st.session_state['custom_player_effects'][player_name]['ast_player_dev'][idx]
else:
player_effect = post['player_dev'].sel(player = player_name).values.flatten()[idx]
opp_effect = post['opp_dev'].sel(opponent = opp_name).values.flatten()[idx]
alpha = post['alpha'].values.flatten()[idx]
mu = np.exp(intercept + player_effect - opp_effect + ast_adj) * remaining_minutes
mu = np.clip(mu, 1e-6, None)
assists = rng.negative_binomial(alpha, alpha / (alpha + mu))
return assists + current_assists
def run_simulation(players_dict, player_positions, opp_name,
seconds_remaining, score_diff_val, poss_per_second,
is_preseason, is_postseason, ot_periods, player_adjustments = None,
current_minutes_dict = None, current_points_dict = None,
current_rebounds_dict = None, current_assists_dict = None,
current_threes_dict = None,
hidden_players = None, fixed_minutes_dict = None,
use_minutes_model = True):
timings = {}
def sample_idx(total, n):
return rng.choice(total, size = n, replace = True)
total_seconds = 12000
if ot_periods:
total_seconds += ot_periods * 1500
total_minutes_target = total_seconds / 60
if not use_minutes_model:
t0 = time.time()
player_names = list(players_dict.keys())
xminutes = {}
for p in player_names:
val = (fixed_minutes_dict or {}).get(p, 30.0)
xminutes[p] = np.full(N_DRAWS_MINUTES, val)
timings['P(Play)'] = 0.0
timings['Minutes'] = time.time() - t0
else:
## prob of playing (pre-match model)
t0 = time.time()
play_post = playing_post.posterior
play_idx = sample_idx(play_post['intercept'].values.flatten().shape[0], N_DRAWS_MINUTES)
intercept = play_post['intercept'].values.flatten()[play_idx]
beta_preseason = play_post['beta_preseason'].values.flatten()[play_idx]
beta_postseason = play_post['beta_postseason'].values.flatten()[play_idx]
cur_mins = current_minutes_dict or {}
posterior_players = set(play_post['player_dev'].coords['player'].values.tolist())
prob_results = {}
for player_name, is_starter in players_dict.items():
if is_starter or cur_mins.get(player_name, 0.0) > 0:
prob_results[player_name] = np.ones(N_DRAWS_MINUTES)
else:
if player_name in st.session_state['custom_player_effects']:
player_effect = st.session_state['custom_player_effects'][player_name]['playing_player_dev'][play_idx]
elif player_name in posterior_players:
player_effect = play_post['player_dev'].sel(player = player_name).values.flatten()[play_idx]
else:
player_effect = _draw_prior('player_sigma', play_post, N_PRIOR_SAMPLES)[play_idx]
prob_results[player_name] = expit(
intercept +
player_effect +
(beta_preseason * int(is_preseason)) +
(beta_postseason * int(is_postseason))
)
timings['P(Play)'] = time.time() - t0
## expected minutes
t0 = time.time()
min_post = minutes_post.posterior
min_idx = sample_idx(min_post['beta_starter_dev'].values.flatten().shape[0], N_DRAWS_MINUTES)
beta_starter = min_post['beta_starter_dev'].values.flatten()[min_idx]
beta_ot = min_post['beta_ot_dev'].values.flatten()[min_idx]
beta_pre_min = min_post['beta_preseason_dev'].values.flatten()[min_idx]
beta_post_min = min_post['beta_postseason_dev'].values.flatten()[min_idx]
kappa = min_post['kappa'].values.flatten()[min_idx]
is_ot = int(ot_periods > 0)
lp_raw = []
for player_name, is_starter in players_dict.items():
if player_name in st.session_state['custom_player_effects']:
player_effect = st.session_state['custom_player_effects'][player_name]['minutes_player_dev'][min_idx]
else:
player_effect = min_post['player_dev'].sel(player = player_name).values.flatten()[min_idx]
lp_raw.append(
player_effect +
(beta_starter * is_starter) +
(beta_ot * is_ot * is_starter) +
(beta_pre_min * is_preseason * is_starter) +
(beta_post_min * is_postseason * is_starter)
)
lp_matrix = np.stack(lp_raw, axis = 1)
phi_samples = softmax(lp_matrix, axis = 1)
alpha_samples = kappa[:, None] * phi_samples
simulated_seconds = np.array([
rng.multinomial(total_seconds, rng.dirichlet(alpha.flatten()))
for alpha in alpha_samples
])
player_names = list(players_dict.keys())
prob_matrix = np.stack([prob_results[p] for p in player_names])
mins_matrix = np.stack([simulated_seconds[:, i] / 60 for i in range(len(player_names))])
xmin_matrix = prob_matrix * mins_matrix
xmin_normalized = xmin_matrix / xmin_matrix.sum(axis = 0, keepdims = True) * total_minutes_target
xminutes = {player_names[i]: xmin_normalized[i, :] for i in range(len(player_names))}
if fixed_minutes_dict:
for p, fixed_val in fixed_minutes_dict.items():
if p in xminutes:
xminutes[p] = np.full(N_DRAWS_MINUTES, fixed_val)
timings['Minutes'] = time.time() - t0
## points, rebounds, assists (shared minutes resample per player)
t0 = time.time()
hidden = hidden_players or set()
reb_adjustments = {p: st.session_state.get(f'home_rebadj_{p}', st.session_state.get(f'away_rebadj_{p}', 0.0)) for p in player_names}
ast_adjustments = {p: st.session_state.get(f'home_astadj_{p}', st.session_state.get(f'away_astadj_{p}', 0.0)) for p in player_names}
tpm_adjustments = {p: st.session_state.get(f'home_tpmadj_{p}', st.session_state.get(f'away_tpmadj_{p}', 0.0)) for p in player_names}
points_results = {}
threes_results = {}
reb_results = {}
ast_results = {}
for player_name in player_names:
if player_name in hidden:
points_results[player_name] = np.zeros(N_DRAWS_POINTS)
threes_results[player_name] = np.zeros(N_DRAWS_POINTS)
reb_results[player_name] = np.zeros(N_DRAWS_POINTS)
ast_results[player_name] = np.zeros(N_DRAWS_POINTS)
continue
cur_min = current_minutes_dict.get(player_name, 0.0) if current_minutes_dict else 0.0
minutes_resampled = xminutes[player_name][rng.choice(len(xminutes[player_name]), size = N_DRAWS_POINTS, replace = True)]
remaining_minutes = np.clip(minutes_resampled - cur_min, 0.1, None)
position = player_positions[player_name]
pts_adj = player_adjustments.get(player_name, 0.0) if player_adjustments else 0.0
cur_pts = current_points_dict.get(player_name, 0) if current_points_dict else 0
cur_3pm = current_threes_dict.get(player_name, 0) if current_threes_dict else 0
tpm_adj = tpm_adjustments.get(player_name, 0.0)
pts, tpm = simulate_points_for_player(
player_name, position, opp_name, remaining_minutes,
player_adj = pts_adj, threes_adj = tpm_adj,
current_points = cur_pts, current_threes = cur_3pm
)
points_results[player_name] = pts
threes_results[player_name] = tpm
reb_adj = reb_adjustments.get(player_name, 0.0)
cur_reb = current_rebounds_dict.get(player_name, 0) if current_rebounds_dict else 0
reb_results[player_name] = simulate_rebounds_for_player(
player_name, remaining_minutes,
reb_adj = reb_adj, current_rebounds = cur_reb
)
ast_adj = ast_adjustments.get(player_name, 0.0)
cur_ast = current_assists_dict.get(player_name, 0) if current_assists_dict else 0
ast_results[player_name] = simulate_assists_for_player(
player_name, opp_name, remaining_minutes,
ast_adj = ast_adj, current_assists = cur_ast
)
timings['Stats'] = time.time() - t0
return xminutes, points_results, threes_results, reb_results, ast_results, total_minutes_target, timings
def render_team_tab(side, team_name, opp_name, score_diff_val,
seconds_remaining, poss_per_second,
is_preseason, is_postseason, ot_periods, default_players = None):
st.subheader(f"{team_name} Players")
st.caption("Add all of the available players")
selected = st.multiselect(
"Select players",
options = team_players_list,
default = default_players if default_players else [],
key = f"{side}_players",
max_selections = 15,
placeholder = "Search and select players..."
)
players_dict = {}
player_positions_dict = {}
if selected:
cols = st.columns(2)
for i, player in enumerate(selected):
with cols[i % 2]:
is_auto_starter = i < 5
starter = st.toggle(
f"{player} — Starter",
key = f"{side}_st_{player}",
value = is_auto_starter
)
players_dict[player] = starter
player_positions_dict[player] = position_map.get(player, 'Guard')
with st.expander("Add Custom Player"):
cp_col1, cp_col2, cp_col3 = st.columns([2, 1, 1])
cp_name = cp_col1.text_input("Name", key = f"{side}_cp_name", placeholder = "e.g. Jane Doe")
cp_position = cp_col2.selectbox("Position", options = ['Guard', 'Forward', 'Center'], key = f"{side}_cp_pos")
cp_starter = cp_col3.toggle("Starter", key = f"{side}_cp_starter", value = False)
if st.button("Add", key = f"{side}_cp_add", disabled = not cp_name.strip()):
name = cp_name.strip()
if name not in st.session_state['custom_player_effects']:
st.session_state['custom_player_effects'][name] = generate_custom_player_effects(name, cp_position)
if name not in position_map:
position_map[name] = cp_position
if name not in [s['name'] for s in (st.session_state.get(f'{side}_custom_players') or [])]:
existing = st.session_state.get(f'{side}_custom_players', [])
existing.append({'name': name, 'position': cp_position, 'starter': cp_starter})
st.session_state[f'{side}_custom_players'] = existing
st.rerun()
custom_list = st.session_state.get(f'{side}_custom_players', [])
if custom_list:
st.markdown("**Custom Players**")
to_remove = []
for i, cp in enumerate(custom_list):
cc1, cc2, cc3 = st.columns([3, 1, 1])
cc1.write(f"{cp['name']} ({cp['position']})")
cp['starter'] = cc2.toggle("Starter", key = f"{side}_cpst_{cp['name']}", value = cp.get('starter', False))
if cc3.button("Remove", key = f"{side}_cprm_{cp['name']}"):
to_remove.append(i)
if to_remove:
for idx in sorted(to_remove, reverse = True):
custom_list.pop(idx)
st.session_state[f'{side}_custom_players'] = custom_list
st.rerun()
for cp in custom_list:
if cp['name'] not in st.session_state['custom_player_effects']:
st.session_state['custom_player_effects'][cp['name']] = generate_custom_player_effects(cp['name'], cp['position'])
if cp['name'] not in position_map:
position_map[cp['name']] = cp['position']
players_dict[cp['name']] = cp['starter']
player_positions_dict[cp['name']] = cp['position']
if st.button("Generate Projections", disabled = len(players_dict) == 0, key = f"{side}_btn"):
st.session_state[f'{side}_confirm_generate'] = True
if st.session_state.get(f'{side}_confirm_generate', False):
st.warning("This will reset all adjustments, fixed minutes, and live inputs.")
cc1, cc2 = st.columns(2)
if cc1.button("Confirm", key = f"{side}_confirm_yes", type = "primary"):
st.session_state[f'{side}_confirm_generate'] = False
with st.spinner("Running simulations..."):
for p in players_dict:
st.session_state[f'{side}_adj_{p}'] = 0.0
st.session_state[f'{side}_rebadj_{p}'] = 0.0
st.session_state[f'{side}_astadj_{p}'] = 0.0
st.session_state[f'{side}_curmin_{p}'] = 0.0
st.session_state[f'{side}_curpts_{p}'] = 0
st.session_state[f'{side}_curreb_{p}'] = 0
st.session_state[f'{side}_curast_{p}'] = 0
st.session_state[f'{side}_cur3pm_{p}'] = 0
st.session_state[f'{side}_tpmadj_{p}'] = 0.0
st.session_state[f'{side}_fix_{p}'] = False
st.session_state[f'{side}_hide_{p}'] = False
hidden_set = set()
mm_on = st.session_state.get('use_minutes_model', False)
initial_fixed = None if mm_on else {p: 30.0 for p in players_dict}
xminutes, points_results, threes_results, reb_results, ast_results, total_minutes_target, timings = run_simulation(
players_dict, player_positions_dict, opp_name,
seconds_remaining, score_diff_val, poss_per_second,
is_preseason, is_postseason, ot_periods,
player_adjustments = {p: 0.0 for p in players_dict},
current_minutes_dict = {p: 0.0 for p in players_dict},
current_points_dict = {p: 0 for p in players_dict},
current_rebounds_dict = {p: 0 for p in players_dict},
current_assists_dict = {p: 0 for p in players_dict},
current_threes_dict = {p: 0 for p in players_dict},
hidden_players = hidden_set,
fixed_minutes_dict = initial_fixed,
use_minutes_model = mm_on
)
player_names = list(players_dict.keys())
adj_mins = {p: round(float(np.mean(xminutes[p])), 1) for p in player_names}
st.session_state[f'{side}_player_names'] = player_names
st.session_state[f'{side}_players_dict'] = players_dict
st.session_state[f'{side}_player_positions'] = player_positions_dict
st.session_state[f'{side}_opp_name'] = opp_name
st.session_state[f'{side}_points_results'] = points_results
st.session_state[f'{side}_threes_results'] = threes_results
st.session_state[f'{side}_reb_results'] = reb_results
st.session_state[f'{side}_ast_results'] = ast_results
st.session_state[f'{side}_xminutes'] = xminutes
st.session_state[f'{side}_adj_mins'] = adj_mins
st.session_state[f'{side}_orig_mins'] = {p: round(float(np.mean(xminutes[p])), 1) for p in player_names}
st.session_state[f'{side}_target_total'] = total_minutes_target
st.session_state[f'{side}_lines_stale'] = False
st.session_state[f'{side}_player_adjustments'] = {p: 0.0 for p in players_dict}
for p, m in adj_mins.items():
st.session_state[f'{side}_widget_{p}'] = m
st.caption(f"Timings — P(Play): {timings['P(Play)']:.2f}s | Minutes: {timings['Minutes']:.2f}s | Stats: {timings['Stats']:.2f}s | Total: {sum(timings.values()):.2f}s")
st.rerun()
if cc2.button("Cancel", key = f"{side}_confirm_no"):
st.session_state[f'{side}_confirm_generate'] = False
st.rerun()
if f'{side}_player_names' in st.session_state:
player_names = st.session_state[f'{side}_player_names']
player_positions_dict = st.session_state[f'{side}_player_positions']
opp = st.session_state[f'{side}_opp_name']
points_results = st.session_state[f'{side}_points_results']
threes_results = st.session_state.get(f'{side}_threes_results', {})
reb_results = st.session_state.get(f'{side}_reb_results', {})
ast_results = st.session_state.get(f'{side}_ast_results', {})
xminutes = st.session_state[f'{side}_xminutes']
adj_mins = st.session_state[f'{side}_adj_mins']
orig_mins = st.session_state[f'{side}_orig_mins']
target_total = st.session_state[f'{side}_target_total']
if st.session_state.get('trigger_recalc', False):
t0 = time.time()
stored_players_dict = st.session_state.get(f'{side}_players_dict', {})
hidden_set = {p for p in player_names if st.session_state.get(f'{side}_hide_{p}', False)}
mm_on = st.session_state.get('use_minutes_model', False)
fixed_mins = {
p: st.session_state.get(f'{side}_widget_{p}', adj_mins.get(p, 0.0))
for p in player_names
}
xminutes, points_results, threes_results, reb_results, ast_results, total_minutes_target, timings = run_simulation(
stored_players_dict, player_positions_dict, opp,
seconds_remaining, score_diff_val, poss_per_second,
is_preseason, is_postseason, ot_periods,
player_adjustments = {p: st.session_state.get(f'{side}_adj_{p}', 0.0) for p in player_names},
current_minutes_dict = {p: st.session_state.get(f'{side}_curmin_{p}', 0.0) for p in player_names},
current_points_dict = {p: st.session_state.get(f'{side}_curpts_{p}', 0) for p in player_names},
current_rebounds_dict = {p: st.session_state.get(f'{side}_curreb_{p}', 0) for p in player_names},
current_assists_dict = {p: st.session_state.get(f'{side}_curast_{p}', 0) for p in player_names},
current_threes_dict = {p: st.session_state.get(f'{side}_cur3pm_{p}', 0) for p in player_names},
hidden_players = hidden_set,
fixed_minutes_dict = fixed_mins,
use_minutes_model = mm_on
)
for p in player_names:
adj_mins[p] = st.session_state.get(f'{side}_widget_{p}', adj_mins.get(p, 0.0))
st.session_state[f'{side}_xminutes'] = xminutes
st.session_state[f'{side}_points_results'] = points_results
st.session_state[f'{side}_threes_results'] = threes_results
st.session_state[f'{side}_reb_results'] = reb_results
st.session_state[f'{side}_ast_results'] = ast_results
st.session_state[f'{side}_adj_mins'] = adj_mins
st.session_state[f'{side}_orig_mins'] = dict(adj_mins)
st.session_state[f'{side}_target_total'] = total_minutes_target
st.session_state[f'{side}_lines_stale'] = False
max_player_minutes = 40.0 + 5.0 * ot_periods
def make_callback(changed_player):
def callback():
new_val = min(st.session_state[f'{side}_widget_{changed_player}'], max_player_minutes)
st.session_state[f'{side}_widget_{changed_player}'] = new_val
adj_mins[changed_player] = new_val
st.session_state[f'{side}_lines_stale'] = True
if not st.session_state.get('use_minutes_model', False):
return
fixed = [p for p in player_names if st.session_state.get(f'{side}_fix_{p}', False)]
free = [p for p in player_names if p != changed_player and p not in fixed]
fixed_total = sum(adj_mins[p] for p in fixed)
remaining = max(0.0, target_total - new_val - fixed_total)
if not free:
return
free_total = sum(adj_mins[p] for p in free)
proportions = (
[adj_mins[p] / free_total for p in free]
if free_total > 0
else [1 / len(free)] * len(free)
)
new_vals = [round(remaining * prop, 1) for prop in proportions]
new_vals[-1] = max(0.0, round(remaining - sum(new_vals[:-1]), 1))
for p, v in zip(free, new_vals):
adj_mins[p] = min(round(v, 1), max_player_minutes)
st.session_state[f'{side}_widget_{p}'] = adj_mins[p]
return callback
st.subheader("Results")
def move_player(idx, direction):
new_idx = idx + direction
if 0 <= new_idx < len(player_names):
player_names[idx], player_names[new_idx] = player_names[new_idx], player_names[idx]
st.session_state[f'{side}_player_names'] = player_names
shown_players = [p for p in player_names if not st.session_state.get(f'{side}_hide_{p}', False)]
hidden_players = [p for p in player_names if st.session_state.get(f'{side}_hide_{p}', False)]
for p in hidden_players:
if f'{side}_widget_{p}' not in st.session_state:
st.session_state[f'{side}_widget_{p}'] = adj_mins.get(p, 0.0)
for player in shown_players:
pts = points_results[player]
tpm = threes_results.get(player, np.zeros(N_DRAWS_POINTS))
reb = reb_results.get(player, np.zeros(N_DRAWS_POINTS))
ast = ast_results.get(player, np.zeros(N_DRAWS_POINTS))
balanced_line = find_balanced_line(pts)
p_over = np.mean(pts > balanced_line)
p_under = np.mean(pts <= balanced_line)
tpm_line = find_balanced_line(tpm)
tpm_over = np.mean(tpm > tpm_line)
tpm_under = np.mean(tpm <= tpm_line)
reb_line = find_balanced_line(reb)
reb_over = np.mean(reb > reb_line)
reb_under = np.mean(reb <= reb_line)
ast_line = find_balanced_line(ast)
ast_over = np.mean(ast > ast_line)
ast_under = np.mean(ast <= ast_line)
stored_starters = st.session_state.get(f'{side}_players_dict', {})
star = " \\*" if stored_starters.get(player, False) else ""
st.markdown(f"**{player}**{star}")
cur_pts = st.session_state.get(f'{side}_curpts_{player}', 0)
cur_reb = st.session_state.get(f'{side}_curreb_{player}', 0)
cur_ast = st.session_state.get(f'{side}_curast_{player}', 0)
n_combo = min(len(pts), len(reb), len(ast))
pts_c = pts[:n_combo]
reb_c = reb[:n_combo]
ast_c = ast[:n_combo]
pr_draws = pts_c + reb_c
pa_draws = pts_c + ast_c
ra_draws = reb_c + ast_c
pra_draws = pts_c + reb_c + ast_c
dd_cats = np.array([pts_c >= 10, reb_c >= 10, ast_c >= 10])
dd_draws = dd_cats.sum(axis = 0).astype(float)
combo_info = {
'P+R': pr_draws,
'P+A': pa_draws,
'R+A': ra_draws,
'P+R+A': pra_draws,
'DD': dd_draws,
'TD': dd_draws,
}
col_widths = [0.3, 0.7, 0.7, 0.7, 0.7] + ([0.7] if show_threes else []) + [0.7] * len(visible_combos)
row = st.columns(col_widths)
with row[0]:
player_idx_in_list = player_names.index(player)
up_col, down_col = st.columns(2)
if up_col.button("^", key = f"{side}_up_{player}", disabled = player_idx_in_list == 0):
move_player(player_idx_in_list, -1)
st.rerun()
if down_col.button("v", key = f"{side}_down_{player}", disabled = player_idx_in_list == len(player_names) - 1):
move_player(player_idx_in_list, 1)
st.rerun()
st.checkbox("Fix", key = f"{side}_fix_{player}", value = st.session_state.get(f'{side}_fix_{player}', False))
st.checkbox("Hide", key = f"{side}_hide_{player}", value = False)
st.checkbox("Freeze", key = f"{side}_freeze_{player}", value = st.session_state.get(f'{side}_freeze_{player}', False))
with row[1]:
curmin_kwargs = {"label": "Current Minutes", "key": f"{side}_curmin_{player}", "step": 0.5, "min_value": 0.0, "format": "%.1f", "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_curmin_{player}' not in st.session_state:
curmin_kwargs["value"] = 0.0
st.number_input(**curmin_kwargs)
with st.container(key = f"{side}_minspacer_{player}"):
st.button(".", key = f"{side}_minspacer_btn_{player}", use_container_width = True, disabled = True)
xmin_kwargs = {"label": "Expected Minutes", "key": f"{side}_widget_{player}", "step": 0.5, "min_value": 0.0, "max_value": max_player_minutes, "on_change": make_callback(player)}
if f'{side}_widget_{player}' not in st.session_state:
xmin_kwargs["value"] = adj_mins.get(player, 0.0)
st.number_input(**xmin_kwargs)
with row[2]:
curpts_kwargs = {"label": "Current Points", "key": f"{side}_curpts_{player}", "step": 1, "min_value": 0, "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_curpts_{player}' not in st.session_state:
curpts_kwargs["value"] = 0
st.number_input(**curpts_kwargs)
with st.container(key = f"{side}_blwrap_{player}"):
if st.button(f"{balanced_line} · O {prob_to_american(p_over)} | U {prob_to_american(p_under)}", key = f"{side}_milestone_btn_{player}", use_container_width = True):
show_milestones(player, pts)
ptsadj_kwargs = {"label": "Points Adjustment", "key": f"{side}_adj_{player}", "step": 0.01, "format": "%.2f", "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_adj_{player}' not in st.session_state:
ptsadj_kwargs["value"] = 0.0
st.number_input(**ptsadj_kwargs)
st.checkbox("Freeze Pts", key = f"{side}_freezepts_{player}", value = st.session_state.get(f'{side}_freezepts_{player}', False))
with row[3]:
curreb_kwargs = {"label": "Current Rebounds", "key": f"{side}_curreb_{player}", "step": 1, "min_value": 0, "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_curreb_{player}' not in st.session_state:
curreb_kwargs["value"] = 0
st.number_input(**curreb_kwargs)
with st.container(key = f"{side}_rebwrap_{player}"):
if st.button(f"{reb_line} · O {prob_to_american(reb_over)} | U {prob_to_american(reb_under)}", key = f"{side}_reb_milestone_btn_{player}", use_container_width = True):
show_milestones(player, reb, 'reb')
rebadj_kwargs = {"label": "Rebounds Adjustment", "key": f"{side}_rebadj_{player}", "step": 0.01, "format": "%.2f", "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_rebadj_{player}' not in st.session_state:
rebadj_kwargs["value"] = 0.0
st.number_input(**rebadj_kwargs)
st.checkbox("Freeze Reb", key = f"{side}_freezereb_{player}", value = st.session_state.get(f'{side}_freezereb_{player}', False))
with row[4]:
curast_kwargs = {"label": "Current Assists", "key": f"{side}_curast_{player}", "step": 1, "min_value": 0, "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_curast_{player}' not in st.session_state:
curast_kwargs["value"] = 0
st.number_input(**curast_kwargs)
with st.container(key = f"{side}_astwrap_{player}"):
if st.button(f"{ast_line} · O {prob_to_american(ast_over)} | U {prob_to_american(ast_under)}", key = f"{side}_ast_milestone_btn_{player}", use_container_width = True):
show_milestones(player, ast, 'ast')
astadj_kwargs = {"label": "Assists Adjustment", "key": f"{side}_astadj_{player}", "step": 0.01, "format": "%.2f", "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_astadj_{player}' not in st.session_state:
astadj_kwargs["value"] = 0.0
st.number_input(**astadj_kwargs)
st.checkbox("Freeze Ast", key = f"{side}_freezeast_{player}", value = st.session_state.get(f'{side}_freezeast_{player}', False))
next_col = 5
if show_threes:
with row[next_col]:
cur3pm_kwargs = {"label": "Current 3PM", "key": f"{side}_cur3pm_{player}", "step": 1, "min_value": 0, "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_cur3pm_{player}' not in st.session_state:
cur3pm_kwargs["value"] = 0
st.number_input(**cur3pm_kwargs)
with st.container(key = f"{side}_tpmwrap_{player}"):
if st.button(f"{tpm_line} · O {prob_to_american(tpm_over)} | U {prob_to_american(tpm_under)}", key = f"{side}_tpm_milestone_btn_{player}", use_container_width = True):
show_milestones(player, tpm, '3pm')
tpmadj_kwargs = {"label": "3PM Adjustment", "key": f"{side}_tpmadj_{player}", "step": 0.01, "format": "%.2f", "on_change": lambda s = side: st.session_state.update({f'{s}_lines_stale': True})}
if f'{side}_tpmadj_{player}' not in st.session_state:
tpmadj_kwargs["value"] = 0.0
st.number_input(**tpmadj_kwargs)
st.checkbox("Freeze 3PM", key = f"{side}_freeze3pm_{player}", value = st.session_state.get(f'{side}_freeze3pm_{player}', False))
next_col += 1
safe_key = {'P+R': 'pr', 'P+A': 'pa', 'R+A': 'ra', 'P+R+A': 'pra', 'DD': 'dd', 'TD': 'td'}
for i, combo_name in enumerate(visible_combos):
col_idx = next_col + i
draws = combo_info[combo_name]
with row[col_idx]:
sk = safe_key[combo_name]
if combo_name == 'DD':
cur_dd = int((cur_pts >= 10) + (cur_reb >= 10) + (cur_ast >= 10) >= 2)
p_dd = float(np.mean(draws >= 2))
st.session_state[f"{side}_curdd_{player}"] = cur_dd
st.number_input("Current DD", disabled = True, step = 1, key = f"{side}_curdd_{player}")
with st.container(key = f"{side}_ddwrap_{player}"):
if st.button(f"Yes · {prob_to_american(p_dd)}", key = f"{side}_dd_btn_{player}", use_container_width = True):
show_milestones(player, (draws >= 2).astype(float), 'DD')
st.number_input("DD Adjustment", value = 0.0, disabled = True, format = "%.2f", key = f"{side}_ddadj_{player}")
elif combo_name == 'TD':
cur_td = int((cur_pts >= 10) + (cur_reb >= 10) + (cur_ast >= 10) >= 3)
p_td = float(np.mean(draws >= 3))
st.session_state[f"{side}_curtd_{player}"] = cur_td
st.number_input("Current TD", disabled = True, step = 1, key = f"{side}_curtd_{player}")
with st.container(key = f"{side}_tdwrap_{player}"):
if st.button(f"Yes · {prob_to_american(p_td)}", key = f"{side}_td_btn_{player}", use_container_width = True):
show_milestones(player, (draws >= 3).astype(float), 'TD')
st.number_input("TD Adjustment", value = 0.0, disabled = True, format = "%.2f", key = f"{side}_tdadj_{player}")
else:
cur_combo = cur_pts + cur_reb + cur_ast
if combo_name == 'P+R':
cur_combo = cur_pts + cur_reb
elif combo_name == 'P+A':
cur_combo = cur_pts + cur_ast
elif combo_name == 'R+A':
cur_combo = cur_reb + cur_ast
st.session_state[f"{side}_cur{sk}_{player}"] = int(cur_combo)
st.number_input(f"Current {combo_name}", disabled = True, step = 1, key = f"{side}_cur{sk}_{player}")
c_line = find_balanced_line(draws)
c_over = np.mean(draws > c_line)
c_under = np.mean(draws <= c_line)
with st.container(key = f"{side}_{sk}wrap_{player}"):
if st.button(f"{c_line} · O {prob_to_american(c_over)} | U {prob_to_american(c_under)}", key = f"{side}_{sk}_btn_{player}", use_container_width = True):
show_milestones(player, draws, combo_name)
st.number_input(f"{combo_name} Adjustment", value = 0.0, disabled = True, format = "%.2f", key = f"{side}_{sk}adj_{player}")
st.checkbox(f"Freeze {combo_name}", key = f"{side}_freeze{sk}_{player}", value = st.session_state.get(f'{side}_freeze{sk}_{player}', False))
st.divider()
if hidden_players:
st.subheader("Hidden Players")
for player in hidden_players:
if f'{side}_widget_{player}' not in st.session_state and player in adj_mins:
st.session_state[f'{side}_widget_{player}'] = adj_mins[player]
hcol1, hcol2, hcol3 = st.columns([5, 1, 1])
hcol1.write(f"{player} — xMin: {adj_mins.get(player, 0.0)}")
hcol2.checkbox("Hide", key = f"{side}_hide_{player}", value = True)
hcol3.checkbox("Fix", key = f"{side}_fix_{player}", value = st.session_state.get(f'{side}_fix_{player}', False))
total = sum(adj_mins[p] for p in player_names)
st.caption(f"Total minutes: {total:.1f}")
## offerings dataframe
st.subheader("Offerings")
offerings_rows = []
for player in player_names:
is_hidden = st.session_state.get(f'{side}_hide_{player}', False)
player_frozen = st.session_state.get(f'{side}_freeze_{player}', False) or is_hidden
if not player_frozen:
def _get_milestones(mkt, p=player):
s = st.session_state.get(f'stored_milestones_{mkt}_{p}', DEFAULT_MILESTONES.get(mkt, "10, 15, 20, 25, 30"))
return sorted([int(x.strip()) for x in s.split(",") if x.strip().isdigit()])
if not st.session_state.get(f'{side}_freezepts_{player}', False):
pts = points_results[player]
for m in _get_milestones('pts'):
p_over_m = float(np.mean(pts >= m))
if 0 < p_over_m < 1:
offerings_rows.append({
'player': player,
'market': 'pts',
'milestone': f"{m}+",
'odds': prob_to_american(p_over_m),
})
if show_threes and not st.session_state.get(f'{side}_freeze3pm_{player}', False):
tpm_d = threes_results.get(player, np.zeros(N_DRAWS_POINTS))
for m in _get_milestones('3pm'):
p_over_m = float(np.mean(tpm_d >= m))
if 0 < p_over_m < 1:
offerings_rows.append({
'player': player,
'market': '3pm',
'milestone': f"{m}+",
'odds': prob_to_american(p_over_m),
})
if not st.session_state.get(f'{side}_freezereb_{player}', False):
reb_d = reb_results.get(player, np.zeros(N_DRAWS_POINTS))
for m in _get_milestones('reb'):
p_over_m = float(np.mean(reb_d >= m))
if 0 < p_over_m < 1:
offerings_rows.append({
'player': player,
'market': 'reb',
'milestone': f"{m}+",
'odds': prob_to_american(p_over_m),
})
if not st.session_state.get(f'{side}_freezeast_{player}', False):
ast_d = ast_results.get(player, np.zeros(N_DRAWS_POINTS))
for m in _get_milestones('ast'):
p_over_m = float(np.mean(ast_d >= m))
if 0 < p_over_m < 1:
offerings_rows.append({
'player': player,
'market': 'ast',
'milestone': f"{m}+",
'odds': prob_to_american(p_over_m),
})
pts = points_results[player]
reb_o = reb_results.get(player, np.zeros(N_DRAWS_POINTS))
ast_o = ast_results.get(player, np.zeros(N_DRAWS_POINTS))
n_o = min(len(pts), len(reb_o), len(ast_o))
pts_o = pts[:n_o]
reb_oc = reb_o[:n_o]
ast_oc = ast_o[:n_o]
combo_draws_map = {
'P+R': pts_o + reb_oc,
'P+A': pts_o + ast_oc,
'R+A': reb_oc + ast_oc,
'P+R+A': pts_o + reb_oc + ast_oc,
}
dd_cats = np.array([pts_o >= 10, reb_oc >= 10, ast_oc >= 10])
dd_draws_v = dd_cats.sum(axis=0).astype(float)
combo_safe = {'P+R': 'pr', 'P+A': 'pa', 'R+A': 'ra', 'P+R+A': 'pra', 'DD': 'dd', 'TD': 'td'}
for cn in visible_combos:
sk = combo_safe[cn]
if st.session_state.get(f'{side}_freeze{sk}_{player}', False):
continue
if cn == 'DD':
p_dd = float(np.mean(dd_draws_v >= 2))
if 0 < p_dd < 1:
offerings_rows.append({
'player': player,
'market': 'DD',
'milestone': 'O 0.5',
'odds': prob_to_american(p_dd),
})
elif cn == 'TD':
p_td = float(np.mean(dd_draws_v >= 3))
if 0 < p_td < 1:
offerings_rows.append({
'player': player,
'market': 'TD',
'milestone': 'O 0.5',
'odds': prob_to_american(p_td),
})
else:
draws = combo_draws_map[cn]
for m in _get_milestones(cn):
p_over_m = float(np.mean(draws >= m))
if 0 < p_over_m < 1:
offerings_rows.append({
'player': player,
'market': cn,
'milestone': f"{m}+",
'odds': prob_to_american(p_over_m),
})
def _odds_in_range(odds_str):
if odds_str == '-':
return False
v = int(odds_str)
if v < 0:
return v >= MAX_MINUS_ODDS
return v <= MAX_PLUS_ODDS
offerings_rows = [r for r in offerings_rows if _odds_in_range(r['odds'])]
offerings_df = pd.DataFrame(offerings_rows)
st.dataframe(offerings_df, hide_index = True, width = "stretch")
team_label_caption = team if team else "—"
st.caption(f"Seconds remaining: {seconds_remaining} | Score diff ({team_label_caption}): {home_score - away_score} | Poss/sec: {poss_per_second:.4f} | Est. poss remaining: {seconds_remaining * poss_per_second:.0f}")
if not team or not opponent:
st.info("Select a Team and Opponent in the sidebar to begin.")
st.stop()
## BOSS odds export
def _american_to_decimal(odds_str):
if odds_str == '-' or odds_str is None:
return None
v = int(odds_str)
if v >= 0:
return 1 + v / 100
return 1 + 100 / abs(v)
def _collect_all_offerings():
rows = []
for side in ('home', 'away'):
player_names = st.session_state.get(f'{side}_player_names', [])
if not player_names:
continue
points_results = st.session_state.get(f'{side}_points_results', {})
threes_results = st.session_state.get(f'{side}_threes_results', {})
reb_results = st.session_state.get(f'{side}_reb_results', {})
ast_results = st.session_state.get(f'{side}_ast_results', {})
for player in player_names:
if st.session_state.get(f'{side}_hide_{player}', False):
continue
if st.session_state.get(f'{side}_freeze_{player}', False):
continue
def _get_milestones(mkt, p = player):
s = st.session_state.get(f'stored_milestones_{mkt}_{p}', DEFAULT_MILESTONES.get(mkt, "10, 15, 20, 25, 30"))
return sorted([int(x.strip()) for x in s.split(",") if x.strip().isdigit()])
pts = points_results.get(player)
tpm = threes_results.get(player)
reb = reb_results.get(player)
ast = ast_results.get(player)
if pts is not None and not st.session_state.get(f'{side}_freezepts_{player}', False):
for m in _get_milestones('pts'):
p_over = float(np.mean(pts >= m))
if 0 < p_over < 1:
rows.append((player, 'pts', f"{m}+", prob_to_american(p_over)))
if tpm is not None and not st.session_state.get(f'{side}_freeze3pm_{player}', False):
for m in _get_milestones('3pm'):
p_over = float(np.mean(tpm >= m))
if 0 < p_over < 1:
rows.append((player, '3pm', f"{m}+", prob_to_american(p_over)))
if reb is not None and not st.session_state.get(f'{side}_freezereb_{player}', False):
for m in _get_milestones('reb'):
p_over = float(np.mean(reb >= m))
if 0 < p_over < 1:
rows.append((player, 'reb', f"{m}+", prob_to_american(p_over)))
if ast is not None and not st.session_state.get(f'{side}_freezeast_{player}', False):
for m in _get_milestones('ast'):
p_over = float(np.mean(ast >= m))
if 0 < p_over < 1:
rows.append((player, 'ast', f"{m}+", prob_to_american(p_over)))
if pts is not None and reb is not None and ast is not None:
n_o = min(len(pts), len(reb), len(ast))
pts_o = pts[:n_o]
reb_oc = reb[:n_o]
ast_oc = ast[:n_o]
combo_draws = {
'P+R': pts_o + reb_oc,
'P+A': pts_o + ast_oc,
'R+A': reb_oc + ast_oc,
'P+R+A': pts_o + reb_oc + ast_oc,
}
dd_cats = np.array([pts_o >= 10, reb_oc >= 10, ast_oc >= 10])
dd_counts = dd_cats.sum(axis = 0).astype(float)
combo_safe = {'P+R': 'pr', 'P+A': 'pa', 'R+A': 'ra', 'P+R+A': 'pra'}
for cn, sk in combo_safe.items():
if st.session_state.get(f'{side}_freeze{sk}_{player}', False):
continue
draws = combo_draws[cn]
for m in _get_milestones(cn):
p_over = float(np.mean(draws >= m))
if 0 < p_over < 1:
rows.append((player, cn, f"{m}+", prob_to_american(p_over)))
if not st.session_state.get(f'{side}_freezedd_{player}', False):
p_dd = float(np.mean(dd_counts >= 2))
if 0 < p_dd < 1:
rows.append((player, 'DD', 'Yes', prob_to_american(p_dd)))
if not st.session_state.get(f'{side}_freezetd_{player}', False):
p_td = float(np.mean(dd_counts >= 3))
if 0 < p_td < 1:
rows.append((player, 'TD', 'Yes', prob_to_american(p_td)))
return rows
MARKET_SUFFIX_MAP = {
'Points': 'pts',
'Rebounds': 'reb',
'Assists': 'ast',
'Threes Made': '3pm',
'Pts+Reb': 'P+R',
'Pts+Ast': 'P+A',
'Reb+Ast': 'R+A',
'Pts+Reb+Ast': 'P+R+A',
}
def _parse_player_market(market_name, known_players):
for suffix, mkt in MARKET_SUFFIX_MAP.items():
needle = f" {suffix}"
if market_name.endswith(needle):
candidate = market_name[:-len(needle)].strip()
if candidate in known_players:
return candidate, mkt
return None, None
def _odds_within_range(odds_str):
if odds_str == '-' or odds_str is None:
return False
v = int(odds_str)
if v < 0:
return v >= MAX_MINUS_ODDS
return v <= MAX_PLUS_ODDS
if boss_uploaded is not None:
try:
boss_df = pd.read_csv(boss_uploaded, sep = None, engine = 'python')
except Exception as e:
st.sidebar.error(f"Could not read CSV: {e}")
boss_df = None
if boss_df is not None:
offerings = _collect_all_offerings()
by_player_market = {}
for player, mkt, sel, odds in offerings:
by_player_market.setdefault((player, mkt), []).append((sel, odds))
known_players = set()
for side in ('home', 'away'):
known_players.update(st.session_state.get(f'{side}_player_names', []))
output_rows = []
unmatched_markets = []
if 'MarketId' in boss_df.columns:
template_df = boss_df.drop_duplicates(subset = ['MarketId'], keep = 'first')
else:
template_df = boss_df.drop_duplicates(subset = ['MarketName'], keep = 'first')
for _, row in template_df.iterrows():
market_name = str(row.get('MarketName', '')).strip()
market_type = str(row.get('MarketTypeName', '')).strip()
if market_name == 'To Record a Double-Double':
matched = False
for player in sorted(known_players):
entries = by_player_market.get((player, 'DD'), [])
for sel, odds in entries:
dec = _american_to_decimal(odds)
if dec is None:
continue
if not _odds_within_range(odds):
dec = 0
new_row = row.copy()
new_row['SelectionName'] = player
new_row['SelectionOdds'] = dec
output_rows.append(new_row)
matched = True
if not matched:
unmatched_markets.append(market_name)
continue
if market_name == 'To Record a Triple-Double':
matched = False
for player in sorted(known_players):
entries = by_player_market.get((player, 'TD'), [])
for sel, odds in entries:
dec = _american_to_decimal(odds)
if dec is None:
continue
if not _odds_within_range(odds):
dec = 0
new_row = row.copy()
new_row['SelectionName'] = player
new_row['SelectionOdds'] = dec
output_rows.append(new_row)
matched = True
if not matched:
unmatched_markets.append(market_name)
continue
player, mkt = _parse_player_market(market_name, known_players)
if player is None:
unmatched_markets.append(market_name)
continue
entries = by_player_market.get((player, mkt), [])
if not entries:
unmatched_markets.append(market_name)
continue
for sel, odds in entries:
dec = _american_to_decimal(odds)
if dec is None:
continue
if not _odds_within_range(odds):
dec = 0
new_row = row.copy()
new_row['SelectionName'] = sel
new_row['SelectionOdds'] = dec
output_rows.append(new_row)
if output_rows:
out_df = pd.DataFrame(output_rows, columns = boss_df.columns)
csv_bytes = out_df.to_csv(index = False).encode('utf-8')
boss_export_slot.download_button(
"Download Export CSV",
data = csv_bytes,
file_name = "boss_odds_export.csv",
mime = "text/csv",
use_container_width = True,
key = "boss_download_btn"
)
else:
boss_export_slot.info("No matching offerings found.")
if unmatched_markets:
unique_unmatched = sorted(set(unmatched_markets))
st.sidebar.warning(f"Skipped {len(unique_unmatched)} market(s) with no matching offerings: " + ", ".join(unique_unmatched[:5]) + (" ..." if len(unique_unmatched) > 5 else ""))
## tabs
tab_team, tab_opp, tab_combined, tab_teamstats = st.tabs([team, opponent, "Combined Markets", "Team Stats"])
with tab_team:
render_team_tab("home", team, opponent,
score_diff_val = home_score - away_score,
seconds_remaining = seconds_remaining,
poss_per_second = poss_per_second,
is_preseason = is_preseason, is_postseason = is_postseason,
ot_periods = ot_periods)
with tab_opp:
render_team_tab("away", opponent, team,
score_diff_val = away_score - home_score,
seconds_remaining = seconds_remaining,
poss_per_second = poss_per_second,
is_preseason = is_preseason, is_postseason = is_postseason,
ot_periods = ot_periods)
with tab_combined:
st.subheader("Combined Markets")
st.caption("Price the sum of a stat across multiple players (e.g., P(Wilson Pts + Clark Pts >= x)).")
home_players = st.session_state.get('home_player_names', [])
away_players = st.session_state.get('away_player_names', [])
all_simulated = [(p, 'home') for p in home_players] + [(p, 'away') for p in away_players]
all_simulated_labels = [f"{p} ({team})" if side == 'home' else f"{p} ({opponent})" for p, side in all_simulated]
if not all_simulated:
st.info("Generate projections for at least one team first.")
else:
COMBINED_STATS = {'Points': 'points_results', 'Rebounds': 'reb_results', 'Assists': 'ast_results', '3PM': 'threes_results'}
COMBINED_STAT_CUR_KEY = {'Points': 'curpts', 'Rebounds': 'curreb', 'Assists': 'curast', '3PM': 'cur3pm'}
combos_list = st.session_state.get('combined_combos', [])
with st.expander("Add Combined Market"):
cm_stat = st.selectbox("Stat", options = list(COMBINED_STATS.keys()), key = "cm_stat")
cm_selected = st.multiselect(
"Select players",
options = all_simulated_labels,
key = "cm_players",
placeholder = "Pick 2 or more players..."
)
cm_milestones = st.text_input("Milestones (comma-separated)", value = "30, 40, 50, 60", key = "cm_milestones")
if st.button("Add", key = "cm_add", disabled = len(cm_selected) < 2):
combo = {
'stat': cm_stat,
'players': cm_selected,
'milestones': cm_milestones,
}
combos_list.append(combo)
st.session_state['combined_combos'] = combos_list
st.rerun()
if combos_list:
to_remove = []
for ci, combo in enumerate(combos_list):
stat_key = COMBINED_STATS[combo['stat']]
player_labels = combo['players']
player_sides = []
draw_arrays = []
valid = True
for label in player_labels:
idx = all_simulated_labels.index(label) if label in all_simulated_labels else -1
if idx < 0:
valid = False
break
p_name, side = all_simulated[idx]
results = st.session_state.get(f'{side}_{stat_key}', {})
if p_name not in results:
valid = False
break
draw_arrays.append(results[p_name])
player_sides.append((p_name, side))
if not valid:
st.warning(f"Missing data for combo {ci + 1}. Recalculate projections.")
continue
player_names_short = [label.split(" (")[0] for label in player_labels]
combo_label = " + ".join(player_names_short)
st.markdown(f"### {combo_label}{combo['stat']}")
cur_stat_abbrev = COMBINED_STAT_CUR_KEY[combo['stat']]
n_players = len(player_sides)
min_cols = st.columns(n_players)
for pi, (p_name, side) in enumerate(player_sides):
with min_cols[pi]:
src_key = f"{side}_curmin_{p_name}"
cm_key = f"cm_curmin_{ci}_{p_name}"
if cm_key not in st.session_state:
st.session_state[cm_key] = st.session_state.get(src_key, 0.0)
def _sync_curmin(src = src_key, cm = cm_key, s = side):
st.session_state[src] = st.session_state[cm]
st.session_state[f'{s}_lines_stale'] = True
st.number_input(f"{p_name} Current Minutes", key = cm_key, step = 0.5, min_value = 0.0, format = "%.1f", on_change = _sync_curmin)
stat_cols = st.columns(n_players)
for pi, (p_name, side) in enumerate(player_sides):
with stat_cols[pi]:
stat_src_key = f"{side}_{cur_stat_abbrev}_{p_name}"
stat_cm_key = f"cm_{cur_stat_abbrev}_{ci}_{p_name}"
if stat_cm_key not in st.session_state:
st.session_state[stat_cm_key] = st.session_state.get(stat_src_key, 0)
def _sync_curstat(src = stat_src_key, cm = stat_cm_key, s = side):
st.session_state[src] = st.session_state[cm]
st.session_state[f'{s}_lines_stale'] = True
st.number_input(f"{p_name} Current {combo['stat']}", key = stat_cm_key, step = 1, min_value = 0, on_change = _sync_curstat)
adj_key = f"cm_adj_{ci}"
if adj_key not in st.session_state:
st.session_state[adj_key] = 0.0
strength_adj = st.number_input("Strength Adjustment", key = adj_key, step = 0.01, format = "%.2f")
n = min(len(d) for d in draw_arrays)
combined_draws = sum(d[:n] for d in draw_arrays)
if strength_adj != 0.0:
combined_draws = np.round(combined_draws * np.exp(strength_adj)).astype(int)
line = find_balanced_line(combined_draws)
p_over = float(np.mean(combined_draws > line))
p_under = float(np.mean(combined_draws <= line))
st.markdown(f"**Balanced Line:** {line} · O {prob_to_american(p_over)} | U {prob_to_american(p_under)}")
ms_key = f"cm_milestones_{ci}"
if ms_key not in st.session_state:
st.session_state[ms_key] = combo['milestones']
def _save_ms(idx = ci, k = ms_key):
st.session_state['combined_combos'][idx]['milestones'] = st.session_state[k]
milestones_input = st.text_input("Milestones (comma-separated)", key = ms_key, on_change = _save_ms)
_save_ms()
milestones = sorted([int(x.strip()) for x in milestones_input.split(",") if x.strip().isdigit()])
col1, col2, col3 = st.columns(3)
col1.markdown("**Milestone**")
col2.markdown("**Odds**")
col3.markdown("**Prob**")
for m in milestones:
p_over_m = float(np.mean(combined_draws >= m))
odds = prob_to_american(p_over_m)
c1, c2, c3 = st.columns(3)
c1.write(f"{m}+")
c2.write(odds)
c3.write(f"{p_over_m:.1%}")
if st.button("Remove", key = f"cm_remove_{ci}"):
to_remove.append(ci)
st.divider()
if to_remove:
for idx in sorted(to_remove, reverse = True):
combos_list.pop(idx)
st.session_state['combined_combos'] = combos_list
st.rerun()
with tab_teamstats:
st.subheader("Team Stats")
if not st.session_state.get('use_minutes_model', False):
st.warning("Minutes Model is OFF. Team Stats sums over only the players you've entered — any players not on the roster contribute nothing. Turn on Use Minutes Model (and enter every available player) for accurate team totals.")
TEAM_STAT_DEFAULT_MS = {'3PM': "6, 8, 10, 12, 14", 'Points': "80, 90, 100, 110", 'Rebounds': "30, 35, 40, 45", 'Assists': "18, 22, 26, 30"}
ts_stat = st.selectbox("Stat", options = ['3PM', 'Points', 'Rebounds', 'Assists'], key = "ts_stat")
total_game_seconds = 2400 + ot_periods * 300
fraction_remaining = seconds_remaining / total_game_seconds if total_game_seconds > 0 else 1.0
for ts_side, ts_label, ts_opp_label in [('home', team, opponent), ('away', opponent, team)]:
st.markdown(f"### {ts_label}")
ts_players = st.session_state.get(f'{ts_side}_player_names', [])
ts_adj_mins = st.session_state.get(f'{ts_side}_adj_mins', {})
ts_positions = st.session_state.get(f'{ts_side}_player_positions', {})
ts_opp = st.session_state.get(f'{ts_side}_opp_name', ts_opp_label)
if not ts_players or not ts_adj_mins:
st.info(f"Generate projections for {ts_label} first.")
continue
ts_cur_key = f"ts_cur_total_{ts_side}_{ts_stat}"
if ts_cur_key not in st.session_state:
st.session_state[ts_cur_key] = 0
st.number_input(f"Current Team {ts_stat}", key = ts_cur_key, step = 1, min_value = 0)
cur_team_total = st.session_state.get(ts_cur_key, 0)
ts_adj_key = f"ts_adj_{ts_side}_{ts_stat}"
if ts_adj_key not in st.session_state:
st.session_state[ts_adj_key] = 0.0
ts_adj = st.number_input("Strength Adjustment", key = ts_adj_key, step = 0.01, format = "%.2f")
n_ts_draws = N_DRAWS_POINTS
team_draws = np.zeros(n_ts_draws)
for p_name in ts_players:
p_remaining = np.full(n_ts_draws, max(ts_adj_mins.get(p_name, 0.0) * fraction_remaining, 0.1))
position = ts_positions.get(p_name, 'Guard')
if ts_stat == 'Points':
pts, _ = simulate_points_for_player(p_name, position, ts_opp, p_remaining, player_adj = ts_adj)
team_draws += pts
elif ts_stat == '3PM':
_, tpm = simulate_points_for_player(p_name, position, ts_opp, p_remaining, player_adj = ts_adj)
team_draws += tpm
elif ts_stat == 'Rebounds':
team_draws += simulate_rebounds_for_player(p_name, p_remaining, reb_adj = ts_adj)
elif ts_stat == 'Assists':
team_draws += simulate_assists_for_player(p_name, ts_opp, p_remaining, ast_adj = ts_adj)
team_draws = team_draws + cur_team_total
line = find_balanced_line(team_draws)
p_over = float(np.mean(team_draws > line))
p_under = float(np.mean(team_draws <= line))
st.markdown(f"**Balanced Line:** {line} · O {prob_to_american(p_over)} | U {prob_to_american(p_under)}")
ts_ms_key = f"ts_milestones_{ts_side}_{ts_stat}"
if ts_ms_key not in st.session_state:
st.session_state[ts_ms_key] = TEAM_STAT_DEFAULT_MS.get(ts_stat, "30, 40, 50, 60")
milestones_input = st.text_input("Milestones (comma-separated)", key = ts_ms_key)
milestones = sorted([int(x.strip()) for x in milestones_input.split(",") if x.strip().isdigit()])
col1, col2, col3 = st.columns(3)
col1.markdown("**Milestone**")
col2.markdown("**Odds**")
col3.markdown("**Prob**")
for m in milestones:
p_over_m = float(np.mean(team_draws >= m))
odds = prob_to_american(p_over_m)
c1, c2, c3 = st.columns(3)
c1.write(f"{m}+")
c2.write(odds)
c3.write(f"{p_over_m:.1%}")
st.divider()
if st.session_state.get('trigger_recalc', False):
st.session_state['trigger_recalc'] = False
st.rerun()