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(""" """, 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()