Spaces:
Sleeping
Sleeping
| 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" | |
| 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", | |
| } | |
| 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() | |
| 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() | |