Syntrex Claude Sonnet 4.6 commited on
Commit
b3ac245
·
1 Parent(s): c884cb3

Feedback tab + Batch 13 debug refinements + rebrand to Kasper

Browse files

- Add Feedback nav tab (CockroachDB-backed submit + display)
- Add missing snap_after_family_zone_* and snap_after_pulled_contact_* checkpoints to simulator
- Add arsenal_drift_applied_scale to drift model output and simulator passthrough
- Expand debug ladder to 12 steps with HR/Hit/TB2P toggle
- Rebrand dashboard header to Kasper with updated product description
- Add arsenal_drift_model.py and rolling_form_model.py (Batch 13 models)
- Add debug_page.py (Batch 13 full debug dashboard)
- Opportunity model and pitcher adjustment updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -127,6 +127,8 @@ from visualization.pitcher import create_pitch_movement_chart
127
  from visualization.props_page import render_props
128
  from visualization.simulation import create_hr_distribution, create_total_bases_distribution
129
  from visualization.game_cards import render_game_card
 
 
130
 
131
  st.set_page_config(
132
  page_title=APP_TITLE,
@@ -504,10 +506,11 @@ def load_weather(venue_name: str) -> pd.DataFrame:
504
 
505
 
506
  def render_header() -> None:
507
- st.title("⚾ World Baseball Classic Analytics Assistant")
508
  st.caption(
509
- "WBC-first app using official WBC schedule pages, WBC Statcast from Baseball Savant, "
510
- "The Odds API, weather overlays, CockroachDB persistence, and a modern Streamlit UI."
 
511
  )
512
  secret_status = []
513
  secret_status.append("ODDS_API_KEY ✓" if ODDS_API_KEY else "ODDS_API_KEY missing")
@@ -2654,1032 +2657,6 @@ def render_dashboard() -> None:
2654
  if live_games.empty and final_games.empty and scheduled_games.empty:
2655
  st.warning("No games available from either schedule or scores feed.")
2656
 
2657
- with st.expander("Debug: raw schedule, scores"):
2658
- if st.button("Grade Final Game Outcomes From Scores", key="grade_final_games_debug"):
2659
- grade_final_game_outcomes_from_scores(scores_df)
2660
- st.success("Attempted final game grading from scores feed.")
2661
- if st.button("Build Batter Prop Outcome Rows From Audit", key="grade_batter_props_debug"):
2662
- grade_batter_prop_outcomes_from_audit()
2663
- st.success("Attempted batter-prop outcome scaffolding from audit rows.")
2664
- if st.button("Fill Batter Prop Realized Outcomes From Statcast", key="fill_batter_realized_debug"):
2665
- fill_batter_prop_realized_outcomes(statcast_df)
2666
- st.success("Attempted realized batter outcome fill from loaded Statcast.")
2667
- st.write("Current WBC date")
2668
- st.write(current_wbc_date_str())
2669
- st.write("Scores rows count")
2670
- st.write(len(scores_df))
2671
- game_outcomes_df = read_game_outcomes(conn)
2672
- st.write("Game outcome rows")
2673
- st.write(len(game_outcomes_df))
2674
-
2675
- if not game_outcomes_df.empty:
2676
- st.dataframe(
2677
- game_outcomes_df.tail(20),
2678
- use_container_width=True,
2679
- hide_index=True,
2680
- )
2681
- batter_prop_outcomes_df = read_batter_prop_outcomes(conn)
2682
- st.write("Batter prop outcome rows")
2683
- st.write(len(batter_prop_outcomes_df))
2684
-
2685
- if not batter_prop_outcomes_df.empty:
2686
- display_cols = [
2687
- col for col in [
2688
- "created_at",
2689
- "graded_at",
2690
- "game_pk",
2691
- "slot",
2692
- "batter_name",
2693
- "fair_hr_odds",
2694
- "book_hr_odds",
2695
- "adjusted_edge",
2696
- "confidence",
2697
- "recommendation_tier",
2698
- "realized_hit",
2699
- "realized_hr",
2700
- "realized_tb2p",
2701
- "grade_status",
2702
- "outcome_source",
2703
- "play_events_debug",
2704
- ] if col in batter_prop_outcomes_df.columns
2705
- ]
2706
-
2707
- st.dataframe(
2708
- batter_prop_outcomes_df[display_cols].tail(20),
2709
- use_container_width=True,
2710
- hide_index=True,
2711
- )
2712
- st.write("Raw score statuses")
2713
- st.write(sorted(scores_df["status"].fillna("").astype(str).unique().tolist()))
2714
- st.write("Schedule rows")
2715
- st.dataframe(schedule_df.head(20), use_container_width=True, hide_index=True)
2716
-
2717
- st.write("Scores rows")
2718
- st.dataframe(scores_df.head(20), use_container_width=True, hide_index=True)
2719
-
2720
- if not scores_df.empty and "status" in scores_df.columns:
2721
- st.write("Statuses from scores feed")
2722
- st.write(scores_df["status"].value_counts(dropna=False))
2723
-
2724
- st.write("Scores columns")
2725
- st.write(list(scores_df.columns))
2726
-
2727
- st.write("Live games extracted")
2728
- st.dataframe(live_games.head(20), use_container_width=True, hide_index=True)
2729
-
2730
- if not live_games.empty:
2731
- st.write("Live games detail")
2732
-
2733
- debug_cols = [
2734
- col for col in [
2735
- "away_team",
2736
- "home_team",
2737
- "status",
2738
- "batter_name",
2739
- "pitcher_name",
2740
- "last_pitch",
2741
- "pitch_type",
2742
- "pitch_velocity",
2743
- "pitch_spin_rate",
2744
- "pitch_extension",
2745
- "pitch_break_angle",
2746
- "pitch_break_length",
2747
- "pitch_pfx_x",
2748
- "pitch_pfx_z",
2749
- "pitch_data_debug",
2750
- "play_events_debug",
2751
- "savant_pitch_debut",
2752
- "savant_feed_debug",
2753
- ]
2754
- if col in live_games.columns
2755
- ]
2756
-
2757
- st.dataframe(
2758
- live_games[debug_cols].head(20),
2759
- use_container_width=True,
2760
- hide_index=True,
2761
- )
2762
-
2763
- st.write("Final games extracted")
2764
- st.dataframe(final_games.head(20), use_container_width=True, hide_index=True)
2765
-
2766
- if not final_games.empty:
2767
- st.write("Final games score hydration debug")
2768
- debug_cols = [
2769
- col for col in [
2770
- "away_team",
2771
- "home_team",
2772
- "status",
2773
- "game_pk",
2774
- "away_score",
2775
- "home_score",
2776
- "away_hits",
2777
- "home_hits",
2778
- "away_errors",
2779
- "home_errors",
2780
- ] if col in final_games.columns
2781
- ]
2782
- st.dataframe(final_games[debug_cols], use_container_width=True, hide_index=True)
2783
-
2784
- st.write("Scores fallback from schedule feeds used")
2785
- st.write(scores_df.empty and not schedule_df.empty)
2786
-
2787
- st.write("Scores have live/final content")
2788
- st.write(_scores_df_has_live_or_final_content(scores_df))
2789
-
2790
- st.write("Fallback schedule->live-feed path eligible")
2791
- st.write((scores_df.empty or not _scores_df_has_live_or_final_content(scores_df)) and not schedule_df.empty)
2792
-
2793
- st.write("Recovery live rows")
2794
- st.write(len(live_games))
2795
- st.write("Recovery final rows")
2796
- st.write(len(final_games))
2797
-
2798
- if not scores_df.empty and "status" in scores_df.columns:
2799
- st.write("Raw score statuses")
2800
- st.write(sorted(scores_df["status"].fillna("").astype(str).unique().tolist()))
2801
-
2802
- if not scores_df.empty and "status" in scores_df.columns:
2803
- st.write("Raw score statuses")
2804
- st.write(sorted(scores_df["status"].fillna("").astype(str).unique().tolist()))
2805
-
2806
- st.write("Scheduled games extracted")
2807
- st.dataframe(scheduled_games.head(20), use_container_width=True, hide_index=True)
2808
-
2809
- st.write("Schedule source date")
2810
- st.write(schedule_date_str)
2811
-
2812
- st.write("Using sticky last-good scores")
2813
- st.write("last_good_scores_df" in st.session_state)
2814
-
2815
- if not scores_df.empty and "scores_source_date" in scores_df.columns:
2816
- st.write("Scores source date")
2817
- st.write(scores_df["scores_source_date"].iloc[0])
2818
-
2819
- if not live_games.empty and "status" in live_games.columns:
2820
- st.write("Live game statuses")
2821
- st.write(live_games["status"].fillna("").astype(str).tolist())
2822
-
2823
- if not live_games.empty:
2824
- st.write("Recovered live game_pk values")
2825
- st.write(live_games.get("game_pk", pd.Series(dtype=str)).tolist() if "game_pk" in live_games.columns else [])
2826
-
2827
- st.write("LIVE GAME PK DEBUG")
2828
- if not live_games.empty:
2829
- st.dataframe(
2830
- live_games[["away_team", "home_team", "status", "game_pk"]],
2831
- use_container_width=True,
2832
- hide_index=True,
2833
- )
2834
-
2835
- from utils.import_savant_csvs import (
2836
- import_batter_savant_csv,
2837
- import_pitcher_savant_csv,
2838
- )
2839
- st.markdown("### Savant CSV Import")
2840
- clear_first = st.checkbox("Clear Savant tables before import")
2841
- if st.button("Import batter Savant CSV"):
2842
- try:
2843
- with st.spinner("Importing batter Savant CSV..."):
2844
- result = import_batter_savant_csv(clear_first=clear_first)
2845
- st.success(
2846
- f"Imported {result['inserted_rows']} / {result['total_rows']} batter rows into {result['table_name']}"
2847
- )
2848
- except Exception as e:
2849
- st.error(f"Batter import failed: {e}")
2850
-
2851
- if st.button("Import pitcher Savant CSV"):
2852
- try:
2853
- with st.spinner("Importing pitcher Savant CSV..."):
2854
- result = import_pitcher_savant_csv(clear_first=clear_first)
2855
- st.success(
2856
- f"Imported {result['inserted_rows']} / {result['total_rows']} pitcher rows into {result['table_name']}"
2857
- )
2858
- except Exception as e:
2859
- st.error(f"Pitcher import failed: {e}")
2860
-
2861
- from sqlalchemy import text
2862
- from database.remote_db import get_connection
2863
- from models.batter_zone_store import load_batter_zone_store_metrics
2864
-
2865
- if st.checkbox("Show single batter zone store summary"):
2866
- batter_name_debug = st.text_input(
2867
- "Batter name for zone store summary",
2868
- value="Bryce Harper"
2869
- )
2870
-
2871
- if batter_name_debug:
2872
- try:
2873
- summary = load_batter_zone_store_metrics(batter_name_debug)
2874
- st.write(summary)
2875
- except Exception as e:
2876
- st.error(f"Error loading batter zone store summary: {e}")
2877
-
2878
- if st.checkbox("Show pitcher baseline store status"):
2879
- try:
2880
- pitcher_store_conn = get_connection()
2881
- pitcher_table_exists = pitcher_store_conn.execute(
2882
- text(
2883
- """
2884
- SELECT EXISTS (
2885
- SELECT 1
2886
- FROM information_schema.tables
2887
- WHERE table_schema = 'public'
2888
- AND table_name = 'pitcher_inning_first_seed_events'
2889
- )
2890
- """
2891
- )
2892
- ).scalar()
2893
-
2894
- st.write("Pitcher baseline DB exists:", bool(pitcher_table_exists))
2895
-
2896
- if pitcher_table_exists:
2897
- count = pitcher_store_conn.execute(
2898
- text("SELECT COUNT(*) FROM pitcher_inning_first_seed_events")
2899
- ).scalar()
2900
-
2901
- st.write("Stored pitcher inning-first seed rows:", int(count or 0))
2902
-
2903
- preview_rows = pitcher_store_conn.execute(
2904
- text(
2905
- """
2906
- SELECT
2907
- pitcher_name,
2908
- game_date,
2909
- inning,
2910
- pitch_type_key,
2911
- velocity,
2912
- spin_rate,
2913
- extension,
2914
- pfx_x,
2915
- pfx_z,
2916
- created_at
2917
- FROM pitcher_inning_first_seed_events
2918
- ORDER BY created_at DESC
2919
- LIMIT 25
2920
- """
2921
- )
2922
- ).fetchall()
2923
-
2924
- if preview_rows:
2925
- preview = pd.DataFrame(
2926
- preview_rows,
2927
- columns=[
2928
- "pitcher_name",
2929
- "game_date",
2930
- "inning",
2931
- "pitch_type_key",
2932
- "velocity",
2933
- "spin_rate",
2934
- "extension",
2935
- "pfx_x",
2936
- "pfx_z",
2937
- "created_at",
2938
- ],
2939
- )
2940
- st.dataframe(preview, use_container_width=True, hide_index=True)
2941
- pitcher_store_conn.close()
2942
-
2943
- except Exception as e:
2944
- st.error(f"Error reading pitcher baseline store: {e}")
2945
-
2946
- if st.checkbox("Show batter zone store status"):
2947
- try:
2948
- batter_store_conn = get_connection()
2949
-
2950
- batter_table_exists = batter_store_conn.execute(
2951
- text(
2952
- """
2953
- SELECT EXISTS (
2954
- SELECT 1
2955
- FROM information_schema.tables
2956
- WHERE table_schema = 'public'
2957
- AND table_name = 'batter_zone_events'
2958
- )
2959
- """
2960
- )
2961
- ).scalar()
2962
-
2963
- st.write("Batter zone DB exists:", bool(batter_table_exists))
2964
-
2965
- if batter_table_exists:
2966
- count = batter_store_conn.execute(
2967
- text("SELECT COUNT(*) FROM batter_zone_events")
2968
- ).scalar()
2969
-
2970
- st.write("Stored batter zone event rows:", int(count or 0))
2971
-
2972
- preview_rows = batter_store_conn.execute(
2973
- text(
2974
- """
2975
- SELECT
2976
- batter_name,
2977
- game_date,
2978
- pitch_family,
2979
- zone_bucket,
2980
- plate_x,
2981
- plate_z,
2982
- pfx_x,
2983
- pfx_z,
2984
- ax,
2985
- ay,
2986
- az,
2987
- hit_flag,
2988
- hr_flag,
2989
- tb2p_flag,
2990
- whiff_flag,
2991
- damage_flag,
2992
- created_at
2993
- FROM batter_zone_events
2994
- ORDER BY created_at DESC
2995
- LIMIT 25
2996
- """
2997
- )
2998
- ).fetchall()
2999
-
3000
- if preview_rows:
3001
- preview = pd.DataFrame(
3002
- preview_rows,
3003
- columns=[
3004
- "batter_name",
3005
- "game_date",
3006
- "pitch_family",
3007
- "zone_bucket",
3008
- "plate_x",
3009
- "plate_z",
3010
- "pfx_x",
3011
- "pfx_z",
3012
- "ax",
3013
- "ay",
3014
- "az",
3015
- "hit_flag",
3016
- "hr_flag",
3017
- "tb2p_flag",
3018
- "whiff_flag",
3019
- "damage_flag",
3020
- "created_at",
3021
- ],
3022
- )
3023
- st.dataframe(preview, use_container_width=True, hide_index=True)
3024
- batter_store_conn.close()
3025
-
3026
- except Exception as e:
3027
- st.error(f"Error reading batter zone store: {e}")
3028
-
3029
- if st.checkbox("Show zone and pulled-barrel debug table"):
3030
- if phase6_debug_rows:
3031
- debug_df = pd.DataFrame(phase6_debug_rows)
3032
-
3033
- preferred_cols = [
3034
- "away_team",
3035
- "home_team",
3036
- "slot",
3037
- "batter_name",
3038
- "pitcher_name",
3039
- "hr_prob",
3040
- "zone_hr_boost",
3041
- "zone_hit_boost",
3042
- "zone_tb2p_boost",
3043
- "zone_sample_size",
3044
- "pull_rate",
3045
- "air_ball_rate",
3046
- "pull_air_rate",
3047
- "pulled_hard_air_rate",
3048
- "pulled_barrel_rate",
3049
- "pre_pull_hr_prob_base",
3050
- "post_pull_hr_prob_base",
3051
- ]
3052
-
3053
- available_cols = [col for col in preferred_cols if col in debug_df.columns]
3054
-
3055
- st.dataframe(
3056
- debug_df[available_cols],
3057
- use_container_width=True,
3058
- hide_index=True,
3059
- )
3060
- else:
3061
- st.info("No debug rows available.")
3062
-
3063
- if "pitcher_store_error" in st.session_state:
3064
- st.error(f"Pitcher store error: {st.session_state['pitcher_store_error']}")
3065
-
3066
- if "batter_zone_store_error" in st.session_state:
3067
- st.error(f"Batter zone store error: {st.session_state['batter_zone_store_error']}")
3068
-
3069
- st.write("Live pitch metrics debug")
3070
-
3071
- pitch_debug_df = build_live_pitch_metrics_debug_df(live_games)
3072
-
3073
- if pitch_debug_df.empty:
3074
- st.info("No live games available for pitch metrics debug.")
3075
- else:
3076
- st.dataframe(
3077
- pitch_debug_df,
3078
- use_container_width=True,
3079
- hide_index=True,
3080
- )
3081
-
3082
- st.write("Prepared live games debug")
3083
-
3084
- prepared_live_games_df = build_prepared_live_games_df(live_games)
3085
-
3086
- prepared_live_games_df = filter_games_for_display(
3087
- prepared_live_games_df,
3088
- filter_option,
3089
- )
3090
-
3091
- prepared_live_games_df = filter_games_for_competition(
3092
- prepared_live_games_df,
3093
- competition_filter,
3094
- )
3095
-
3096
- if prepared_live_games_df.empty:
3097
- st.info("No prepared live games available.")
3098
- else:
3099
- debug_cols = [
3100
- col for col in [
3101
- "away_team",
3102
- "home_team",
3103
- "status",
3104
- "game_pk",
3105
- "batter_name",
3106
- "pitcher_name",
3107
- "balls",
3108
- "strikes",
3109
- "outs",
3110
- "runner_on_1b",
3111
- "runner_on_2b",
3112
- "runner_on_3b",
3113
- "last_play",
3114
- "last_pitch",
3115
- "pitch_velocity",
3116
- "pitch_spin_rate",
3117
- "pitch_extension",
3118
- ] if col in prepared_live_games_df.columns
3119
- ]
3120
- st.dataframe(
3121
- prepared_live_games_df[debug_cols],
3122
- use_container_width=True,
3123
- hide_index=True,
3124
- )
3125
-
3126
- st.write("Opportunity model source inputs")
3127
-
3128
- prepared_live_games_df = build_prepared_live_games_df(live_games)
3129
-
3130
- if prepared_live_games_df is None or prepared_live_games_df.empty:
3131
- st.info("No prepared live games available for opportunity source debug.")
3132
- else:
3133
- source_cols = [
3134
- col for col in [
3135
- "away_team",
3136
- "home_team",
3137
- "status",
3138
- "pitcher_name",
3139
- "on_deck_name",
3140
- "in_hole_name",
3141
- "three_away_name",
3142
- "outs",
3143
- ]
3144
- if col in prepared_live_games_df.columns
3145
- ]
3146
-
3147
- if source_cols:
3148
- st.dataframe(
3149
- prepared_live_games_df[source_cols],
3150
- use_container_width=True,
3151
- hide_index=True,
3152
- )
3153
- else:
3154
- st.info("Opportunity source columns are not present on prepared live games.")
3155
-
3156
- st.write("Upcoming simulator raw rows")
3157
-
3158
- prepared_live_games_df = build_prepared_live_games_df(live_games)
3159
-
3160
- if prepared_live_games_df is None or prepared_live_games_df.empty:
3161
- st.info("No prepared live games available for simulator debug.")
3162
- else:
3163
- simulator_debug_rows: list[dict] = []
3164
-
3165
- for _, live_row in prepared_live_games_df.iterrows():
3166
- game = live_row.to_dict()
3167
-
3168
- pitcher_name_debug = str(game.get("pitcher_name", "") or "").strip()
3169
- pitcher_row_debug = build_pitcher_feature_row(statcast_df, pitcher_name_debug)
3170
-
3171
- try:
3172
- simulated_rows = build_upcoming_simulated_rows(
3173
- game_row=game,
3174
- statcast_df=statcast_df,
3175
- weather_row=None,
3176
- )
3177
- except Exception as e:
3178
- simulated_rows = []
3179
- simulator_debug_rows.append(
3180
- {
3181
- "away_team": game.get("away_team"),
3182
- "home_team": game.get("home_team"),
3183
- "slot": "ERROR",
3184
- "batter_name": None,
3185
- "pitcher_name": game.get("pitcher_name"),
3186
- "on_deck_name": game.get("on_deck_name"),
3187
- "in_hole_name": game.get("in_hole_name"),
3188
- "three_away_name": game.get("three_away_name"),
3189
- "debug_note": str(e),
3190
- }
3191
- )
3192
-
3193
- if isinstance(simulated_rows, list) and not simulated_rows:
3194
- simulator_debug_rows.append(
3195
- {
3196
- "away_team": game.get("away_team"),
3197
- "home_team": game.get("home_team"),
3198
- "slot": "EMPTY",
3199
- "batter_name": None,
3200
- "pitcher_name": game.get("pitcher_name"),
3201
- "on_deck_name": game.get("on_deck_name"),
3202
- "in_hole_name": game.get("in_hole_name"),
3203
- "three_away_name": game.get("three_away_name"),
3204
- "debug_note": "build_upcoming_simulated_rows returned []",
3205
- }
3206
- )
3207
-
3208
- if isinstance(simulated_rows, list):
3209
- for sim_row in simulated_rows:
3210
- if isinstance(sim_row, dict):
3211
- simulator_debug_rows.append(
3212
- {
3213
- "away_team": game.get("away_team"),
3214
- "home_team": game.get("home_team"),
3215
- "slot": sim_row.get("slot"),
3216
- "batter_name": sim_row.get("batter_name"),
3217
- "pitcher_name": sim_row.get("pitcher_name"),
3218
- "on_deck_name": game.get("on_deck_name"),
3219
- "in_hole_name": game.get("in_hole_name"),
3220
- "three_away_name": game.get("three_away_name"),
3221
- "hit_prob": sim_row.get("hit_prob"),
3222
- "hr_prob": sim_row.get("hr_prob"),
3223
- "tb2p_prob": sim_row.get("tb2p_prob"),
3224
- "debug_note": None,
3225
- }
3226
- )
3227
-
3228
- if not simulator_debug_rows:
3229
- st.info("No simulator debug rows available.")
3230
- else:
3231
- simulator_debug_df = pd.DataFrame(simulator_debug_rows)
3232
- st.dataframe(
3233
- simulator_debug_df,
3234
- use_container_width=True,
3235
- hide_index=True,
3236
- )
3237
-
3238
- st.write("Opportunity model debug")
3239
-
3240
- prepared_live_games_df = build_prepared_live_games_df(live_games)
3241
-
3242
- if prepared_live_games_df is None or prepared_live_games_df.empty:
3243
- st.info("No prepared live games available for opportunity-model debug.")
3244
- else:
3245
- opportunity_debug_rows: list[dict] = []
3246
-
3247
- for _, live_row in prepared_live_games_df.iterrows():
3248
- game = live_row.to_dict()
3249
-
3250
- try:
3251
- recommendations_debug = build_upcoming_hitter_recommendations(
3252
- game_row=game,
3253
- statcast_df=statcast_df,
3254
- odds_df=odds_df,
3255
- weather_row=None,
3256
- )
3257
- except Exception as e:
3258
- recommendations_debug = []
3259
- opportunity_debug_rows.append(
3260
- {
3261
- "away_team": game.get("away_team"),
3262
- "home_team": game.get("home_team"),
3263
- "batter_name": None,
3264
- "slot": "ERROR",
3265
- "lineup_distance": None,
3266
- "pa_prob_this_inning": None,
3267
- "pa_prob_next_two_innings": None,
3268
- "expected_pa": None,
3269
- "hit_prob": None,
3270
- "hr_prob": None,
3271
- "tb2p_prob": None,
3272
- "fair_hr_odds": None,
3273
- "book_hr_odds": None,
3274
- "hr_edge": str(e),
3275
- }
3276
- )
3277
-
3278
- if isinstance(recommendations_debug, list) and not recommendations_debug:
3279
- opportunity_debug_rows.append(
3280
- {
3281
- "away_team": game.get("away_team"),
3282
- "home_team": game.get("home_team"),
3283
- "batter_name": None,
3284
- "slot": "EMPTY",
3285
- "lineup_distance": None,
3286
- "pa_prob_this_inning": None,
3287
- "pa_prob_next_two_innings": None,
3288
- "expected_pa": None,
3289
- "hit_prob": None,
3290
- "hr_prob": None,
3291
- "tb2p_prob": None,
3292
- "fair_hr_odds": None,
3293
- "book_hr_odds": None,
3294
- "hr_edge": "build_upcoming_hitter_recommendations returned []",
3295
- }
3296
- )
3297
-
3298
- if isinstance(recommendations_debug, list):
3299
- for rec in recommendations_debug:
3300
- if isinstance(rec, dict):
3301
- opportunity_debug_rows.append(
3302
- {
3303
- "away_team": game.get("away_team"),
3304
- "home_team": game.get("home_team"),
3305
- "batter_name": rec.get("batter_name"),
3306
- "slot": rec.get("slot"),
3307
- "lineup_distance": rec.get("lineup_distance"),
3308
- "pa_prob_this_inning": rec.get("pa_prob_this_inning"),
3309
- "pa_prob_next_two_innings": rec.get("pa_prob_next_two_innings"),
3310
- "expected_pa": rec.get("expected_pa"),
3311
- "hit_prob": rec.get("hit_prob"),
3312
- "hr_prob": rec.get("hr_prob"),
3313
- "tb2p_prob": rec.get("tb2p_prob"),
3314
- "fair_hr_odds": rec.get("fair_hr_odds"),
3315
- "book_hr_odds": rec.get("book_hr_odds"),
3316
- "hr_edge": rec.get("hr_edge"),
3317
- }
3318
- )
3319
-
3320
- if not opportunity_debug_rows:
3321
- st.info("No opportunity-model debug rows available.")
3322
- else:
3323
- opportunity_debug_df = pd.DataFrame(opportunity_debug_rows)
3324
- st.dataframe(
3325
- opportunity_debug_df,
3326
- use_container_width=True,
3327
- hide_index=True,
3328
- )
3329
-
3330
- st.write("Phase 6 live-state debug")
3331
-
3332
- prepared_live_games_df = build_prepared_live_games_df(live_games)
3333
-
3334
- if prepared_live_games_df is None or prepared_live_games_df.empty:
3335
- st.info("No prepared live games available for Phase 6 debug.")
3336
- else:
3337
- phase6_debug_rows: list[dict] = []
3338
-
3339
- for _, live_row in prepared_live_games_df.iterrows():
3340
- game = live_row.to_dict()
3341
-
3342
- pitcher_name_debug = str(game.get("pitcher_name", "") or "").strip()
3343
- pitcher_id_debug = game.get("pitcher_id")
3344
- pitcher_row_debug = build_pitcher_feature_row(
3345
- statcast_df=statcast_df,
3346
- pitcher_name=pitcher_name_debug,
3347
- pitcher_id=pitcher_id_debug,
3348
- )
3349
-
3350
- try:
3351
- recommendations_debug = build_upcoming_hitter_recommendations(
3352
- game_row=game,
3353
- statcast_df=statcast_df,
3354
- odds_df=odds_df,
3355
- weather_row=None,
3356
- )
3357
- except Exception as e:
3358
- recommendations_debug = []
3359
- phase6_debug_rows.append(
3360
- {
3361
- "away_team": game.get("away_team"),
3362
- "home_team": game.get("home_team"),
3363
- "pitcher_name": pitcher_name_debug,
3364
- "batter_name": None,
3365
- "slot": "ERROR",
3366
-
3367
- "fatigue_score": None,
3368
- "degradation_score": None,
3369
- "trust_live_score": None,
3370
- "baseline_weight": None,
3371
- "live_weight": None,
3372
- "velo_delta": None,
3373
- "spin_delta": None,
3374
- "extension_delta": None,
3375
- "pitch_count": None,
3376
- "times_through_order": None,
3377
-
3378
- "live_velocity": game.get("pitch_velocity"),
3379
- "rolling_velocity": game.get("rolling_pitch_velocity"),
3380
- "baseline_velocity": pitcher_row_debug.get("avg_release_speed"),
3381
- "baseline_spin_rate": pitcher_row_debug.get("avg_release_spin_rate"),
3382
- "baseline_extension": pitcher_row_debug.get("avg_release_extension"),
3383
-
3384
- "rolling_pitch_sample_size": game.get("rolling_pitch_sample_size"),
3385
- "rolling_pitch_velocity_sample_size": game.get("rolling_pitch_velocity_sample_size"),
3386
- "rolling_pitch_spin_sample_size": game.get("rolling_pitch_spin_sample_size"),
3387
- "rolling_pitch_extension_sample_size": game.get("rolling_pitch_extension_sample_size"),
3388
-
3389
- "rolling_pitch_velocity": game.get("rolling_pitch_velocity"),
3390
- "rolling_pitch_spin_rate": game.get("rolling_pitch_spin_rate"),
3391
- "rolling_pitch_extension": game.get("rolling_pitch_extension"),
3392
-
3393
- "seed_baseline_velocity": game.get("seed_baseline_velocity"),
3394
- "seed_baseline_spin_rate": game.get("seed_baseline_spin_rate"),
3395
- "seed_baseline_extension": game.get("seed_baseline_extension"),
3396
- "seed_baseline_velocity_sample_size": game.get("seed_baseline_velocity_sample_size"),
3397
- "seed_baseline_spin_sample_size": game.get("seed_baseline_spin_sample_size"),
3398
- "seed_baseline_extension_sample_size": game.get("seed_baseline_extension_sample_size"),
3399
-
3400
- "pitch_type_key": game.get("pitch_type_key"),
3401
- "rolling_pitch_type_key": game.get("rolling_pitch_type_key"),
3402
-
3403
- "rolling_pitch_type_velocity": game.get("rolling_pitch_type_velocity"),
3404
- "rolling_pitch_type_spin_rate": game.get("rolling_pitch_type_spin_rate"),
3405
- "rolling_pitch_type_extension": game.get("rolling_pitch_type_extension"),
3406
-
3407
- "rolling_pitch_type_velocity_sample_size": game.get("rolling_pitch_type_velocity_sample_size"),
3408
- "rolling_pitch_type_spin_sample_size": game.get("rolling_pitch_type_spin_sample_size"),
3409
- "rolling_pitch_type_extension_sample_size": game.get("rolling_pitch_type_extension_sample_size"),
3410
-
3411
- "seed_pitch_type_key": game.get("seed_pitch_type_key"),
3412
- "seed_pitch_type_baseline_velocity": game.get("seed_pitch_type_baseline_velocity"),
3413
- "seed_pitch_type_baseline_spin_rate": game.get("seed_pitch_type_baseline_spin_rate"),
3414
- "seed_pitch_type_baseline_extension": game.get("seed_pitch_type_baseline_extension"),
3415
-
3416
- "seed_pitch_type_baseline_velocity_sample_size": game.get("seed_pitch_type_baseline_velocity_sample_size"),
3417
- "seed_pitch_type_baseline_spin_sample_size": game.get("seed_pitch_type_baseline_spin_sample_size"),
3418
- "seed_pitch_type_baseline_extension_sample_size": game.get("seed_pitch_type_baseline_extension_sample_size"),
3419
-
3420
- "stored_baseline_velocity": game.get("stored_baseline_velocity"),
3421
- "stored_baseline_spin_rate": game.get("stored_baseline_spin_rate"),
3422
- "stored_baseline_extension": game.get("stored_baseline_extension"),
3423
- "stored_baseline_velocity_sample_size": game.get("stored_baseline_velocity_sample_size"),
3424
- "stored_baseline_spin_sample_size": game.get("stored_baseline_spin_sample_size"),
3425
- "stored_baseline_extension_sample_size": game.get("stored_baseline_extension_sample_size"),
3426
-
3427
- "stored_pitch_type_baseline_velocity": game.get("stored_pitch_type_baseline_velocity"),
3428
- "stored_pitch_type_baseline_spin_rate": game.get("stored_pitch_type_baseline_spin_rate"),
3429
- "stored_pitch_type_baseline_extension": game.get("stored_pitch_type_baseline_extension"),
3430
- "stored_pitch_type_baseline_velocity_sample_size": game.get("stored_pitch_type_baseline_velocity_sample_size"),
3431
- "stored_pitch_type_baseline_spin_sample_size": game.get("stored_pitch_type_baseline_spin_sample_size"),
3432
- "stored_pitch_type_baseline_extension_sample_size": game.get("stored_pitch_type_baseline_extension_sample_size"),
3433
-
3434
- "arsenal_hr_boost": arsenal_matchup_adj.get("arsenal_hr_boost"),
3435
- "arsenal_hit_boost": arsenal_matchup_adj.get("arsenal_hit_boost"),
3436
- "arsenal_tb2p_boost": arsenal_matchup_adj.get("arsenal_tb2p_boost"),
3437
- "arsenal_whiff_risk": arsenal_matchup_adj.get("arsenal_whiff_risk"),
3438
- "arsenal_sample_size": batter_arsenal_row.get("arsenal_sample_size"),
3439
-
3440
- "family_zone_hr_boost": family_zone_matchup_adj.get("family_zone_hr_boost"),
3441
- "family_zone_hit_boost": family_zone_matchup_adj.get("family_zone_hit_boost"),
3442
- "family_zone_tb2p_boost": family_zone_matchup_adj.get("family_zone_tb2p_boost"),
3443
- "family_zone_whiff_risk": family_zone_matchup_adj.get("family_zone_whiff_risk"),
3444
- "family_zone_sample_size": batter_family_zone_row.get("family_zone_sample_size"),
3445
-
3446
- "debug_note": str(e),
3447
- }
3448
- )
3449
-
3450
- if isinstance(recommendations_debug, list):
3451
- for rec in recommendations_debug:
3452
- if isinstance(rec, dict):
3453
- phase6_debug_rows.append(
3454
- {
3455
- "away_team": game.get("away_team"),
3456
- "home_team": game.get("home_team"),
3457
- "pitcher_name": pitcher_name_debug,
3458
- "batter_name": rec.get("batter_name"),
3459
- "slot": rec.get("slot"),
3460
-
3461
- "fatigue_score": rec.get("fatigue_score"),
3462
- "degradation_score": rec.get("degradation_score"),
3463
- "trust_live_score": rec.get("trust_live_score"),
3464
- "baseline_weight": rec.get("baseline_weight"),
3465
- "live_weight": rec.get("live_weight"),
3466
- "velo_delta": rec.get("velo_delta"),
3467
- "spin_delta": rec.get("spin_delta"),
3468
- "extension_delta": rec.get("extension_delta"),
3469
- "pitch_count": rec.get("pitch_count"),
3470
- "times_through_order": rec.get("times_through_order"),
3471
- "zone_hr_boost": rec.get("zone_hr_boost"),
3472
- "zone_hit_boost": rec.get("zone_hit_boost"),
3473
- "zone_tb2p_boost": rec.get("zone_tb2p_boost"),
3474
- "zone_sample_size": rec.get("zone_sample_size"),
3475
- "pull_rate": rec.get("pull_rate"),
3476
- "air_ball_rate": rec.get("air_ball_rate"),
3477
- "pull_air_rate": rec.get("pull_air_rate"),
3478
- "pulled_hard_air_rate": rec.get("pulled_hard_air_rate"),
3479
- "pulled_barrel_rate": rec.get("pulled_barrel_rate"),
3480
- "pre_pull_hr_prob_base": rec.get("pre_pull_hr_prob_base"),
3481
- "post_pull_hr_prob_base": rec.get("post_pull_hr_prob_base"),
3482
-
3483
- "live_velocity": game.get("pitch_velocity"),
3484
- "rolling_velocity": game.get("rolling_pitch_velocity"),
3485
- "baseline_velocity": pitcher_row_debug.get("avg_release_speed"),
3486
- "baseline_spin_rate": pitcher_row_debug.get("avg_release_spin_rate"),
3487
- "baseline_extension": pitcher_row_debug.get("avg_release_extension"),
3488
-
3489
- "rolling_pitch_sample_size": rec.get("rolling_pitch_sample_size"),
3490
- "rolling_pitch_velocity_sample_size": rec.get("rolling_pitch_velocity_sample_size"),
3491
- "rolling_pitch_spin_sample_size": rec.get("rolling_pitch_spin_sample_size"),
3492
- "rolling_pitch_extension_sample_size": rec.get("rolling_pitch_extension_sample_size"),
3493
-
3494
- "rolling_pitch_velocity": game.get("rolling_pitch_velocity"),
3495
- "rolling_pitch_spin_rate": game.get("rolling_pitch_spin_rate"),
3496
- "rolling_pitch_extension": game.get("rolling_pitch_extension"),
3497
-
3498
- "seed_baseline_velocity": game.get("seed_baseline_velocity"),
3499
- "seed_baseline_spin_rate": game.get("seed_baseline_spin_rate"),
3500
- "seed_baseline_extension": game.get("seed_baseline_extension"),
3501
-
3502
- "debug_note": None,
3503
- }
3504
- )
3505
-
3506
- if not phase6_debug_rows:
3507
- st.info("No Phase 6 live-state debug rows available.")
3508
- else:
3509
- phase6_debug_df = pd.DataFrame(phase6_debug_rows)
3510
- st.dataframe(
3511
- phase6_debug_df,
3512
- use_container_width=True,
3513
- hide_index=True,
3514
- )
3515
-
3516
- batter_audit_df = read_batter_prop_audit_view(conn)
3517
- st.write("Batter prop audit rows")
3518
- st.write(len(batter_audit_df))
3519
-
3520
- if not batter_audit_df.empty:
3521
- st.dataframe(
3522
- batter_audit_df.tail(20),
3523
- use_container_width=True,
3524
- hide_index=True,
3525
- )
3526
-
3527
- st.markdown("### Batter HR Audit Metrics")
3528
-
3529
- batter_tier_table = build_batter_hr_tier_table(batter_audit_df)
3530
- batter_conf_table = build_batter_hr_confidence_table(batter_audit_df)
3531
- batter_edge_table = build_batter_hr_edge_table(batter_audit_df)
3532
-
3533
- if not batter_tier_table.empty:
3534
- st.write("Batter HR Rate by Recommendation Tier")
3535
- st.dataframe(batter_tier_table, use_container_width=True, hide_index=True)
3536
-
3537
- if not batter_conf_table.empty:
3538
- st.write("Batter HR Rate by Confidence Bucket")
3539
- st.dataframe(batter_conf_table, use_container_width=True, hide_index=True)
3540
-
3541
- if not batter_edge_table.empty:
3542
- st.write("Batter HR Rate by Adjusted Edge Bucket")
3543
- st.dataframe(batter_edge_table, use_container_width=True, hide_index=True)
3544
-
3545
- rec_logs_df = read_table(conn, "recommendation_logs")
3546
- st.write("Recommendation log rows")
3547
- st.write(len(rec_logs_df))
3548
-
3549
- if not rec_logs_df.empty:
3550
- st.dataframe(
3551
- rec_logs_df.tail(20),
3552
- use_container_width=True,
3553
- hide_index=True,
3554
- )
3555
-
3556
- audit_df = read_recommendation_audit_view(conn)
3557
- st.write("Recommendation audit rows")
3558
- st.write(len(audit_df))
3559
-
3560
- if not audit_df.empty:
3561
- audit_display_cols = [
3562
- col for col in [
3563
- "created_at",
3564
- "game_pk",
3565
- "away_team",
3566
- "home_team",
3567
- "slot",
3568
- "batter_name",
3569
- "fair_hr_odds",
3570
- "book_hr_odds",
3571
- "adjusted_edge",
3572
- "confidence",
3573
- "recommendation_tier",
3574
- "realized_hr",
3575
- "graded_at",
3576
- "outcome_source",
3577
- ] if col in audit_df.columns
3578
- ]
3579
-
3580
- st.dataframe(
3581
- audit_df[audit_display_cols].tail(20),
3582
- use_container_width=True,
3583
- hide_index=True,
3584
- )
3585
-
3586
- st.markdown("### Model Evaluation Metrics")
3587
-
3588
- audit_df = read_recommendation_audit_view(conn)
3589
-
3590
- cal_table = build_hr_calibration_table(audit_df)
3591
- edge_table = build_edge_bucket_table(audit_df)
3592
- conf_table = build_confidence_table(audit_df)
3593
- tier_table = build_tier_performance_table(audit_df)
3594
-
3595
- if not cal_table.empty:
3596
- st.write("HR Probability Calibration")
3597
- st.dataframe(cal_table, use_container_width=True, hide_index=True)
3598
-
3599
- if not edge_table.empty:
3600
- st.write("Edge Bucket Performance")
3601
- st.dataframe(edge_table, use_container_width=True, hide_index=True)
3602
-
3603
- if not conf_table.empty:
3604
- st.write("Confidence Bucket Performance")
3605
- st.dataframe(conf_table, use_container_width=True, hide_index=True)
3606
-
3607
- if not tier_table.empty:
3608
- st.write("Recommendation Tier Performance")
3609
- st.dataframe(tier_table, use_container_width=True, hide_index=True)
3610
-
3611
-
3612
- if not scores_df.empty and "status" in scores_df.columns:
3613
- st.write("Raw score statuses")
3614
- st.write(sorted(scores_df["status"].fillna("").astype(str).unique().tolist()))
3615
-
3616
-
3617
- st.markdown("### Batch 7: ERE and CLV")
3618
-
3619
- ere_table = build_ere_table(audit_df)
3620
- ere_edge_table = build_ere_by_edge_bucket_table(audit_df)
3621
- ere_conf_table = build_ere_by_confidence_bucket_table(audit_df)
3622
- ere_tier_table = build_ere_by_tier_table(audit_df)
3623
-
3624
- clv_table = build_clv_table(audit_df)
3625
- clv_tier_table = build_clv_by_tier_table(audit_df)
3626
-
3627
- if not ere_table.empty:
3628
- st.write("Global Edge Realization Efficiency (ERE)")
3629
- st.dataframe(ere_table, use_container_width=True, hide_index=True)
3630
- else:
3631
- st.info("No graded audit data available yet.")
3632
-
3633
- if not ere_edge_table.empty:
3634
- st.write("ERE by Edge Bucket")
3635
- st.dataframe(ere_edge_table, use_container_width=True, hide_index=True)
3636
- else:
3637
- st.info("No graded audit data available yet.")
3638
-
3639
- if not ere_conf_table.empty:
3640
- st.write("ERE by Confidence Bucket")
3641
- st.dataframe(ere_conf_table, use_container_width=True, hide_index=True)
3642
- else:
3643
- st.info("No graded audit data available yet.")
3644
-
3645
- if not ere_tier_table.empty:
3646
- st.write("ERE by Recommendation Tier")
3647
- st.dataframe(ere_tier_table, use_container_width=True, hide_index=True)
3648
- else:
3649
- st.info("No graded audit data available yet.")
3650
-
3651
- if not clv_table.empty:
3652
- st.write("Closing Line Value (CLV) Summary")
3653
- st.dataframe(clv_table, use_container_width=True, hide_index=True)
3654
- else:
3655
- st.info("No graded audit data available yet.")
3656
-
3657
- if not clv_tier_table.empty:
3658
- st.write("CLV by Recommendation Tier")
3659
- st.dataframe(clv_tier_table, use_container_width=True, hide_index=True)
3660
- else:
3661
- st.info("No graded audit data available yet.")
3662
-
3663
- st.markdown("### Batch 7 Readiness Check")
3664
-
3665
- st.write("Recommendation logging function restored")
3666
- st.write(True)
3667
-
3668
- st.write("Recommendation log rows")
3669
- rec_logs_df = read_table(conn, "recommendation_logs")
3670
- st.write(len(rec_logs_df))
3671
-
3672
- st.write("Recommendation outcome rows")
3673
- rec_outcomes_df = read_table(conn, "recommendation_outcomes")
3674
- st.write(len(rec_outcomes_df))
3675
-
3676
- st.write("Batter prop outcome rows")
3677
- batter_prop_outcomes_df = read_batter_prop_outcomes(conn)
3678
- st.write(len(batter_prop_outcomes_df))
3679
-
3680
- st.write("Recommendation audit rows")
3681
- audit_df = read_recommendation_audit_view(conn)
3682
- st.write(len(audit_df))
3683
  render_live_prop_odds_debug_panel(live_games)
3684
 
3685
  if statcast_df.empty:
@@ -3970,6 +2947,8 @@ def main() -> None:
3970
  "Betting",
3971
  "Bet Tracker",
3972
  "Algorithm Breakdown",
 
 
3973
  ],
3974
  )
3975
 
@@ -3986,8 +2965,22 @@ def main() -> None:
3986
  render_betting()
3987
  elif page == "Bet Tracker":
3988
  render_bet_tracker()
3989
- else:
3990
  render_algorithm_breakdown()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3991
 
3992
 
3993
  if __name__ == "__main__":
 
127
  from visualization.props_page import render_props
128
  from visualization.simulation import create_hr_distribution, create_total_bases_distribution
129
  from visualization.game_cards import render_game_card
130
+ from visualization.debug_page import render_debug
131
+ from visualization.feedback_page import render_feedback
132
 
133
  st.set_page_config(
134
  page_title=APP_TITLE,
 
506
 
507
 
508
  def render_header() -> None:
509
+ st.title("⚾ Kasper")
510
  st.caption(
511
+ "All-in-One Baseball Assistant. Excellent for finding Home Run True +EV. "
512
+ "Full pitch telemetry with XGBoost Machine Learning model trained on a 3.8M pitch-event "
513
+ "data set + live data with custom anchors."
514
  )
515
  secret_status = []
516
  secret_status.append("ODDS_API_KEY ✓" if ODDS_API_KEY else "ODDS_API_KEY missing")
 
2657
  if live_games.empty and final_games.empty and scheduled_games.empty:
2658
  st.warning("No games available from either schedule or scores feed.")
2659
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2660
  render_live_prop_odds_debug_panel(live_games)
2661
 
2662
  if statcast_df.empty:
 
2947
  "Betting",
2948
  "Bet Tracker",
2949
  "Algorithm Breakdown",
2950
+ "Feedback",
2951
+ "Debug",
2952
  ],
2953
  )
2954
 
 
2965
  render_betting()
2966
  elif page == "Bet Tracker":
2967
  render_bet_tracker()
2968
+ elif page == "Algorithm Breakdown":
2969
  render_algorithm_breakdown()
2970
+ elif page == "Feedback":
2971
+ render_feedback(conn)
2972
+ elif page == "Debug":
2973
+ _debug_scores = get_stable_scores_for_dashboard_date(current_wbc_date_str())
2974
+ render_debug(
2975
+ statcast_df=load_statcast_recent(),
2976
+ odds_df=load_odds(),
2977
+ conn=conn,
2978
+ live_games=pd.DataFrame(),
2979
+ scores_df=_debug_scores,
2980
+ grade_outcomes_fn=grade_final_game_outcomes_from_scores,
2981
+ grade_props_fn=grade_batter_prop_outcomes_from_audit,
2982
+ fill_realized_fn=fill_batter_prop_realized_outcomes,
2983
+ )
2984
 
2985
 
2986
  if __name__ == "__main__":
database/db.py CHANGED
@@ -28,6 +28,7 @@ import pandas as pd
28
  from sqlalchemy import text
29
 
30
  from database import remote_db
 
31
 
32
 
33
  # ---------------------------------------------------------------------------
@@ -584,3 +585,37 @@ def read_batter_prop_audit_view(conn) -> pd.DataFrame:
584
  ORDER BY graded_at DESC, created_at DESC
585
  """
586
  return pd.read_sql(text(query), conn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  from sqlalchemy import text
29
 
30
  from database import remote_db
31
+ from utils.helpers import utc_now_iso
32
 
33
 
34
  # ---------------------------------------------------------------------------
 
585
  ORDER BY graded_at DESC, created_at DESC
586
  """
587
  return pd.read_sql(text(query), conn)
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # Feedback submissions
592
+ # ---------------------------------------------------------------------------
593
+
594
+ def ensure_feedback_submissions_table(conn) -> None:
595
+ conn.execute(text(
596
+ """
597
+ CREATE TABLE IF NOT EXISTS feedback_submissions (
598
+ created_at TEXT NOT NULL,
599
+ message TEXT NOT NULL
600
+ )
601
+ """
602
+ ))
603
+
604
+
605
+ def insert_feedback_submission(conn, message: str) -> None:
606
+ ensure_feedback_submissions_table(conn)
607
+ conn.execute(
608
+ text(
609
+ "INSERT INTO feedback_submissions (created_at, message) "
610
+ "VALUES (:created_at, :message)"
611
+ ),
612
+ {"created_at": utc_now_iso(), "message": message},
613
+ )
614
+
615
+
616
+ def read_feedback_submissions(conn) -> pd.DataFrame:
617
+ ensure_feedback_submissions_table(conn)
618
+ return pd.read_sql(
619
+ text("SELECT * FROM feedback_submissions ORDER BY created_at DESC"),
620
+ conn,
621
+ )
models/arsenal_drift_model.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Batch 13 — Arsenal Drift Layer
5
+
6
+ Short-term pitcher form adjustment derived from existing rolling pitcher signals.
7
+ No new statcast queries — reuses pitcher_rolling_row and pitcher_row already
8
+ computed in the simulator.
9
+
10
+ Anti-double-counting: signals that the Rolling Form Layer (Batch 12E) already
11
+ fired are reduced by 50% or zeroed out based on rolling_reason_tags.
12
+
13
+ Returns additive HR and hit adjustments bounded at ±0.003 (HR) and ±0.0025 (hit).
14
+ """
15
+
16
+ from typing import Any
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Shared helpers (self-contained — do not import from rolling_form_model)
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ def _safe_delta(rolling_val: Any, baseline_val: Any) -> float | None:
25
+ """rolling - baseline; returns None if either is None."""
26
+ if rolling_val is None or baseline_val is None:
27
+ return None
28
+ try:
29
+ return float(rolling_val) - float(baseline_val)
30
+ except (TypeError, ValueError):
31
+ return None
32
+
33
+
34
+ def _clamp(value: float, lo: float, hi: float) -> float:
35
+ return max(lo, min(hi, value))
36
+
37
+
38
+ def _sample_scale(n_games: int) -> float:
39
+ if n_games < 2:
40
+ return 0.0
41
+ if n_games <= 3:
42
+ return 0.4
43
+ if n_games == 4:
44
+ return 0.7
45
+ return 1.0
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Public API
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def compute_arsenal_drift_adjustment(
54
+ pitcher_roll: dict[str, Any],
55
+ pitcher_row: dict[str, Any],
56
+ rolling_reason_tags: str = "",
57
+ ) -> dict[str, Any]:
58
+ """
59
+ Compute short-term pitcher drift signals from rolling data vs. season baseline.
60
+
61
+ pitcher_roll: output of build_pitcher_rolling_form_row() — absolute rolling values
62
+ pitcher_row: output of build_pitcher_feature_row() — season baseline
63
+ rolling_reason_tags: pipe-delimited tag string from compute_upcoming_rolling_adjustment()
64
+ used to detect and reduce overlapping signals
65
+
66
+ Returns arsenal_hr_adjustment, arsenal_hit_adjustment (additive, bounded).
67
+ """
68
+ # ------------------------------------------------------------------
69
+ # Sample dampening (same pattern as rolling_form_model)
70
+ # Confidence already includes match quality × n5_scale from build_pitcher_rolling_form_row.
71
+ # Apply _sample_scale once more here — but use n5 directly to avoid double-squaring:
72
+ # scale = confidence (match × n5_scale_from_row) is already a dampened measure.
73
+ # We use it directly without multiplying by sample_scale again.
74
+ # ------------------------------------------------------------------
75
+ n5 = int(pitcher_roll.get("pitcher_games_in_window_5g") or 0)
76
+ confidence = float(pitcher_roll.get("pitcher_rolling_confidence") or 0.0)
77
+ # confidence = match_scale × n5_scale (from rolling_form_model)
78
+ # Do NOT multiply by _sample_scale(n5) again — that would double-apply n5 dampening.
79
+ scale = confidence # already accounts for both match quality and sample size
80
+
81
+ if scale == 0.0:
82
+ return {
83
+ "arsenal_hr_adjustment": 0.0,
84
+ "arsenal_hit_adjustment": 0.0,
85
+ "arsenal_drift_score": 0.0,
86
+ "arsenal_reason_tags": "",
87
+ "arsenal_drift_applied_scale": 0.0,
88
+ }
89
+
90
+ # ------------------------------------------------------------------
91
+ # Compute deltas vs season baseline
92
+ # ------------------------------------------------------------------
93
+ velo_delta = _safe_delta(
94
+ pitcher_roll.get("pitcher_avg_release_speed_5g"),
95
+ pitcher_row.get("avg_release_speed"),
96
+ )
97
+ spin_delta = _safe_delta(
98
+ pitcher_roll.get("pitcher_avg_release_spin_rate_5g"),
99
+ pitcher_row.get("avg_release_spin_rate"),
100
+ )
101
+ ev_delta = _safe_delta(
102
+ pitcher_roll.get("pitcher_ev_allowed_5g"),
103
+ pitcher_row.get("ev_allowed"),
104
+ )
105
+ barrel_delta = _safe_delta(
106
+ pitcher_roll.get("pitcher_barrel_rate_allowed_5g"),
107
+ pitcher_row.get("barrel_rate_allowed"),
108
+ )
109
+
110
+ # ------------------------------------------------------------------
111
+ # Anti-double-count: identify which signals rolling already fired
112
+ # ------------------------------------------------------------------
113
+ rolling_tags: set[str] = set()
114
+ if rolling_reason_tags:
115
+ rolling_tags = {t.strip() for t in rolling_reason_tags.split("|") if t.strip()}
116
+
117
+ velo_overlap = bool(
118
+ "pitcher_velo_decline_hard" in rolling_tags
119
+ or "pitcher_velo_decline" in rolling_tags
120
+ )
121
+ ev_overlap = "pitcher_ev_allowed_spiking" in rolling_tags
122
+ barrel_overlap = "pitcher_barrel_allowed_spiking" in rolling_tags
123
+
124
+ # ------------------------------------------------------------------
125
+ # Drift scoring
126
+ # ------------------------------------------------------------------
127
+ raw_score = 0.0
128
+ active_tags: list[str] = []
129
+
130
+ # Velo signals
131
+ if velo_delta is not None and not velo_overlap:
132
+ if velo_delta < -3.0:
133
+ raw_score += 0.25
134
+ active_tags.append("drift_velo_hard_decline")
135
+ elif velo_delta < -1.5:
136
+ raw_score += 0.15
137
+ active_tags.append("drift_velo_soft_decline")
138
+
139
+ # Spin signals (new — not in rolling layer)
140
+ if spin_delta is not None:
141
+ if spin_delta < -100.0:
142
+ raw_score += 0.20
143
+ active_tags.append("drift_spin_decline")
144
+ elif spin_delta > 100.0:
145
+ raw_score -= 0.15
146
+ active_tags.append("drift_spin_surge")
147
+
148
+ # EV allowed signal (reduce if rolling already caught it)
149
+ if ev_delta is not None and ev_delta > 2.0:
150
+ contribution = 0.20 * (0.5 if ev_overlap else 1.0)
151
+ raw_score += contribution
152
+ active_tags.append("drift_ev_allowed_spike")
153
+
154
+ # Barrel allowed signal (reduce if rolling already caught it)
155
+ if barrel_delta is not None and barrel_delta > 0.03:
156
+ contribution = 0.25 * (0.5 if barrel_overlap else 1.0)
157
+ raw_score += contribution
158
+ active_tags.append("drift_barrel_allowed_spike")
159
+
160
+ # ------------------------------------------------------------------
161
+ # Apply scale and bounds
162
+ # ------------------------------------------------------------------
163
+ drift_score = _clamp(raw_score * scale, -0.5, 0.5)
164
+ hr_adj = round(_clamp(drift_score * 0.006, -0.003, 0.003), 5)
165
+ hit_adj = round(_clamp(drift_score * 0.005, -0.0025, 0.0025), 5)
166
+
167
+ return {
168
+ "arsenal_hr_adjustment": hr_adj,
169
+ "arsenal_hit_adjustment": hit_adj,
170
+ "arsenal_drift_score": round(drift_score, 4),
171
+ "arsenal_reason_tags": "|".join(active_tags),
172
+ "arsenal_drift_applied_scale": round(scale, 4),
173
+ }
models/live_fair_simulator_v3.py CHANGED
@@ -28,6 +28,13 @@ from models.arsenal_matchup_model import compute_arsenal_matchup_adjustment
28
  from models.trajectory_model import build_trajectory_features, compute_trajectory_adjustment
29
  from models.batter_trend_model import build_batter_trend_row
30
  from models.batter_archetype import classify_batter_archetype
 
 
 
 
 
 
 
31
  from config.settings import ENABLE_XGB_SHADOW
32
 
33
  if ENABLE_XGB_SHADOW:
@@ -83,6 +90,37 @@ def build_upcoming_simulated_rows(
83
  rows: list[dict] = []
84
  _game_ref_date = game_row.get("game_datetime_utc") or game_row.get("game_date")
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  for slot, batter_name in slots:
87
  if not batter_name:
88
  continue
@@ -204,6 +242,11 @@ def build_upcoming_simulated_rows(
204
  # Strongest Phase 6: use blended pitch outcome probabilities
205
  # to lightly influence batter baseline before simulation.
206
  batter_baseline = dict(batter_baseline)
 
 
 
 
 
207
  batter_baseline["hit_prob_base"] = min(
208
  0.55,
209
  max(
@@ -265,6 +308,10 @@ def build_upcoming_simulated_rows(
265
  ),
266
  )
267
 
 
 
 
 
268
  # Family + zone conditional arsenal overlay
269
  family_zone_hr_boost = float(
270
  family_zone_matchup_adj.get("family_zone_hr_boost", 0.0) or 0.0
@@ -308,7 +355,11 @@ def build_upcoming_simulated_rows(
308
  + (family_zone_hit_boost * 0.02),
309
  ),
310
  )
311
-
 
 
 
 
312
  # Arsenal matchup overlay
313
  arsenal_hr_boost = float(arsenal_matchup_adj.get("arsenal_hr_boost", 0.0) or 0.0)
314
  arsenal_hit_boost = float(arsenal_matchup_adj.get("arsenal_hit_boost", 0.0) or 0.0)
@@ -342,7 +393,11 @@ def build_upcoming_simulated_rows(
342
  + (arsenal_tb2p_boost * 0.08),
343
  ),
344
  )
345
-
 
 
 
 
346
  # HR-specific pulled-contact-shape overlay
347
  pre_pull_hr_prob_base = batter_baseline.get("hr_prob_base")
348
 
@@ -392,6 +447,10 @@ def build_upcoming_simulated_rows(
392
  except Exception as e:
393
  logger.debug(f"[simulator] pull_air_rate adjustment skipped: {e}")
394
 
 
 
 
 
395
  # Batch 11: Apply environment overlay (env_adj computed once per game before loop)
396
  batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
397
  float(batter_baseline.get("hit_prob_base", 0.15) or 0.15) + env_hit_boost))
@@ -401,6 +460,10 @@ def build_upcoming_simulated_rows(
401
  batter_baseline["tb2p_prob_base"] = min(0.45, max(0.03,
402
  float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) * tb2p_fac))
403
 
 
 
 
 
404
  # Phase E4: Platoon (handedness) adjustment
405
  batter_stand = batter_features.get("batter_stand", "R")
406
  p_throws = pitcher_row.get("p_throws", "R")
@@ -429,6 +492,10 @@ def build_upcoming_simulated_rows(
429
  0.45, float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) + 0.005
430
  )
431
 
 
 
 
 
432
  # Batch 10 physics layer: trajectory deception adjustment
433
  traj_adj = compute_trajectory_adjustment(trajectory_row)
434
  traj_hit = float(traj_adj.get("hit_adj", 0.0) or 0.0)
@@ -457,6 +524,107 @@ def build_upcoming_simulated_rows(
457
  ),
458
  )
459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  sim = simulate_upcoming_hitter(
461
  game_row=game_row,
462
  batter_row=batter_features,
@@ -644,6 +812,82 @@ def build_upcoming_simulated_rows(
644
  "xgb_hr_delta": _shadow.get("xgb_hr_delta"),
645
  "xgb_hr_adjusted": _shadow.get("xgb_hr_adjusted"),
646
  "xgb_shadow_active": _shadow.get("xgb_shadow_active", False),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  }
648
  )
649
 
 
28
  from models.trajectory_model import build_trajectory_features, compute_trajectory_adjustment
29
  from models.batter_trend_model import build_batter_trend_row
30
  from models.batter_archetype import classify_batter_archetype
31
+ from models.rolling_form_model import (
32
+ build_batter_rolling_form_row,
33
+ build_pitcher_rolling_form_row,
34
+ compute_upcoming_rolling_adjustment,
35
+ )
36
+ from models.opportunity_model import compute_opportunity_adjustment
37
+ from models.arsenal_drift_model import compute_arsenal_drift_adjustment
38
  from config.settings import ENABLE_XGB_SHADOW
39
 
40
  if ENABLE_XGB_SHADOW:
 
90
  rows: list[dict] = []
91
  _game_ref_date = game_row.get("game_datetime_utc") or game_row.get("game_date")
92
 
93
+ # Batch 12E: Rolling form — pitcher slice computed once per game
94
+ try:
95
+ pitcher_rolling_row = build_pitcher_rolling_form_row(
96
+ statcast_df=statcast_df,
97
+ pitcher_name=pitcher_name,
98
+ pitcher_id=pitcher_id,
99
+ reference_date=_game_ref_date,
100
+ )
101
+ except Exception:
102
+ pitcher_rolling_row = {
103
+ "pitcher_avg_release_speed_5g": None,
104
+ "pitcher_avg_release_speed_10g": None,
105
+ "pitcher_avg_release_spin_rate_5g": None,
106
+ "pitcher_ev_allowed_5g": None,
107
+ "pitcher_ev_allowed_10g": None,
108
+ "pitcher_hard_hit_rate_allowed_5g": None,
109
+ "pitcher_hard_hit_rate_allowed_10g": None,
110
+ "pitcher_barrel_rate_allowed_5g": None,
111
+ "pitcher_barrel_rate_allowed_10g": None,
112
+ "pitcher_avg_launch_angle_allowed_5g": None,
113
+ "pitcher_fb_rate_allowed_5g": None,
114
+ "pitcher_ld_rate_allowed_5g": None,
115
+ "pitcher_gb_rate_allowed_5g": None,
116
+ "pitcher_hr_allowed_rate_5g": None,
117
+ "pitcher_hr_allowed_rate_10g": None,
118
+ "pitcher_games_in_window_5g": 0,
119
+ "pitcher_games_in_window_10g": 0,
120
+ "pitcher_recent_form_available": 0,
121
+ "pitcher_rolling_confidence": 0.0,
122
+ }
123
+
124
  for slot, batter_name in slots:
125
  if not batter_name:
126
  continue
 
242
  # Strongest Phase 6: use blended pitch outcome probabilities
243
  # to lightly influence batter baseline before simulation.
244
  batter_baseline = dict(batter_baseline)
245
+
246
+ # Batch 13: Baseline snapshot for exact debug ladder
247
+ _snap_baseline_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
248
+ _snap_baseline_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
249
+ _snap_baseline_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
250
  batter_baseline["hit_prob_base"] = min(
251
  0.55,
252
  max(
 
308
  ),
309
  )
310
 
311
+ _snap_after_zone_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
312
+ _snap_after_zone_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
313
+ _snap_after_zone_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
314
+
315
  # Family + zone conditional arsenal overlay
316
  family_zone_hr_boost = float(
317
  family_zone_matchup_adj.get("family_zone_hr_boost", 0.0) or 0.0
 
355
  + (family_zone_hit_boost * 0.02),
356
  ),
357
  )
358
+
359
+ _snap_after_family_zone_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
360
+ _snap_after_family_zone_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
361
+ _snap_after_family_zone_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
362
+
363
  # Arsenal matchup overlay
364
  arsenal_hr_boost = float(arsenal_matchup_adj.get("arsenal_hr_boost", 0.0) or 0.0)
365
  arsenal_hit_boost = float(arsenal_matchup_adj.get("arsenal_hit_boost", 0.0) or 0.0)
 
393
  + (arsenal_tb2p_boost * 0.08),
394
  ),
395
  )
396
+
397
+ _snap_after_arsenal_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
398
+ _snap_after_arsenal_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
399
+ _snap_after_arsenal_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
400
+
401
  # HR-specific pulled-contact-shape overlay
402
  pre_pull_hr_prob_base = batter_baseline.get("hr_prob_base")
403
 
 
447
  except Exception as e:
448
  logger.debug(f"[simulator] pull_air_rate adjustment skipped: {e}")
449
 
450
+ _snap_after_pulled_contact_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
451
+ _snap_after_pulled_contact_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
452
+ _snap_after_pulled_contact_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
453
+
454
  # Batch 11: Apply environment overlay (env_adj computed once per game before loop)
455
  batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
456
  float(batter_baseline.get("hit_prob_base", 0.15) or 0.15) + env_hit_boost))
 
460
  batter_baseline["tb2p_prob_base"] = min(0.45, max(0.03,
461
  float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) * tb2p_fac))
462
 
463
+ _snap_after_env_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
464
+ _snap_after_env_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
465
+ _snap_after_env_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
466
+
467
  # Phase E4: Platoon (handedness) adjustment
468
  batter_stand = batter_features.get("batter_stand", "R")
469
  p_throws = pitcher_row.get("p_throws", "R")
 
492
  0.45, float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) + 0.005
493
  )
494
 
495
+ _snap_after_platoon_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
496
+ _snap_after_platoon_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
497
+ _snap_after_platoon_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
498
+
499
  # Batch 10 physics layer: trajectory deception adjustment
500
  traj_adj = compute_trajectory_adjustment(trajectory_row)
501
  traj_hit = float(traj_adj.get("hit_adj", 0.0) or 0.0)
 
524
  ),
525
  )
526
 
527
+ _snap_after_traj_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
528
+ _snap_after_traj_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
529
+ _snap_after_traj_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
530
+
531
+ # Batch 12E: Rolling form adjustment (upcoming-only path).
532
+ # batter_baseline is a local dict (copied at line ~206 above); mutating it
533
+ # here does NOT affect any live-game code path.
534
+ try:
535
+ batter_rolling_row = build_batter_rolling_form_row(
536
+ statcast_df=statcast_df,
537
+ player_name=batter_name,
538
+ reference_date=_game_ref_date,
539
+ )
540
+ rolling_adj = compute_upcoming_rolling_adjustment(
541
+ batter_roll=batter_rolling_row,
542
+ pitcher_roll=pitcher_rolling_row,
543
+ batter_features=batter_features,
544
+ pitcher_row=pitcher_row,
545
+ )
546
+ except Exception:
547
+ batter_rolling_row = {
548
+ "batter_ev90_5g": None, "batter_ev90_10g": None,
549
+ "batter_barrel_rate_5g": None, "batter_barrel_rate_10g": None,
550
+ "batter_hard_hit_rate_5g": None, "batter_avg_launch_angle_5g": None,
551
+ "batter_games_in_window_5g": 0, "batter_games_in_window_10g": 0,
552
+ "batter_recent_form_available": 0,
553
+ }
554
+ rolling_adj = {
555
+ "rolling_hit_adjustment": 0.0, "rolling_hr_adjustment": 0.0,
556
+ "rolling_tb2p_adjustment": 0.0, "rolling_batter_form_score": 0.0,
557
+ "rolling_pitcher_form_score": 0.0, "rolling_combined_form_score": 0.0,
558
+ "rolling_adjustment_applied": False, "rolling_adjustment_reason_tags": "",
559
+ "pitcher_rolling_confidence": 0.0,
560
+ }
561
+
562
+ rolling_hit = float(rolling_adj.get("rolling_hit_adjustment", 0.0) or 0.0)
563
+ rolling_hr = float(rolling_adj.get("rolling_hr_adjustment", 0.0) or 0.0)
564
+ rolling_tb2p = float(rolling_adj.get("rolling_tb2p_adjustment", 0.0) or 0.0)
565
+
566
+ batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
567
+ float(batter_baseline.get("hit_prob_base", 0.15) or 0.15) + rolling_hit))
568
+ batter_baseline["hr_prob_base"] = min(0.30, max(0.005,
569
+ float(batter_baseline.get("hr_prob_base", 0.03) or 0.03) + rolling_hr))
570
+ batter_baseline["tb2p_prob_base"] = min(0.45, max(0.03,
571
+ float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) + rolling_tb2p))
572
+
573
+ _snap_after_rolling_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
574
+ _snap_after_rolling_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
575
+ _snap_after_rolling_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
576
+
577
+ # Batch 13: Opportunity adjustment (multiplicative, upcoming-only)
578
+ try:
579
+ opp_adj = compute_opportunity_adjustment(
580
+ lineup_slot=None, # batting order not available in game_row currently
581
+ team_total=game_row.get("team_total"),
582
+ pitcher_row=pitcher_row,
583
+ )
584
+ except Exception:
585
+ opp_adj = {
586
+ "expected_pa": 4.3, "pa_multiplier": 1.0,
587
+ "pitcher_quality_score": 0.0, "opportunity_reason": "error",
588
+ "lineup_slot_used": None, "team_total_used": None,
589
+ "opportunity_mode": "error",
590
+ }
591
+
592
+ pa_mult = float(opp_adj.get("pa_multiplier", 1.0) or 1.0)
593
+ batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
594
+ float(batter_baseline.get("hit_prob_base", 0.15) or 0.15) * pa_mult))
595
+ batter_baseline["hr_prob_base"] = min(0.30, max(0.005,
596
+ float(batter_baseline.get("hr_prob_base", 0.03) or 0.03) * pa_mult))
597
+ batter_baseline["tb2p_prob_base"] = min(0.45, max(0.03,
598
+ float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) * pa_mult))
599
+
600
+ _snap_after_opportunity_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
601
+ _snap_after_opportunity_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
602
+ _snap_after_opportunity_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
603
+
604
+ # Batch 13: Arsenal drift adjustment (additive, upcoming-only)
605
+ try:
606
+ drift_adj = compute_arsenal_drift_adjustment(
607
+ pitcher_roll=pitcher_rolling_row,
608
+ pitcher_row=pitcher_row,
609
+ rolling_reason_tags=rolling_adj.get("rolling_adjustment_reason_tags", ""),
610
+ )
611
+ except Exception:
612
+ drift_adj = {
613
+ "arsenal_hr_adjustment": 0.0, "arsenal_hit_adjustment": 0.0,
614
+ "arsenal_drift_score": 0.0, "arsenal_reason_tags": "",
615
+ }
616
+
617
+ drift_hit = float(drift_adj.get("arsenal_hit_adjustment", 0.0) or 0.0)
618
+ drift_hr = float(drift_adj.get("arsenal_hr_adjustment", 0.0) or 0.0)
619
+ batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
620
+ float(batter_baseline.get("hit_prob_base", 0.15) or 0.15) + drift_hit))
621
+ batter_baseline["hr_prob_base"] = min(0.30, max(0.005,
622
+ float(batter_baseline.get("hr_prob_base", 0.03) or 0.03) + drift_hr))
623
+
624
+ _snap_after_drift_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
625
+ _snap_after_drift_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
626
+ _snap_after_drift_tb2p = float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
627
+
628
  sim = simulate_upcoming_hitter(
629
  game_row=game_row,
630
  batter_row=batter_features,
 
812
  "xgb_hr_delta": _shadow.get("xgb_hr_delta"),
813
  "xgb_hr_adjusted": _shadow.get("xgb_hr_adjusted"),
814
  "xgb_shadow_active": _shadow.get("xgb_shadow_active", False),
815
+
816
+ # Batch 12E — rolling form adjustments
817
+ "rolling_hit_adjustment": rolling_adj.get("rolling_hit_adjustment"),
818
+ "rolling_hr_adjustment": rolling_adj.get("rolling_hr_adjustment"),
819
+ "rolling_tb2p_adjustment": rolling_adj.get("rolling_tb2p_adjustment"),
820
+ "rolling_batter_form_score": rolling_adj.get("rolling_batter_form_score"),
821
+ "rolling_pitcher_form_score": rolling_adj.get("rolling_pitcher_form_score"),
822
+ "rolling_combined_form_score": rolling_adj.get("rolling_combined_form_score"),
823
+ "rolling_adjustment_applied": rolling_adj.get("rolling_adjustment_applied", False),
824
+ "rolling_adjustment_reason_tags": rolling_adj.get("rolling_adjustment_reason_tags", ""),
825
+ "pitcher_rolling_confidence": rolling_adj.get("pitcher_rolling_confidence"),
826
+ # Key batter rolling metrics for debug
827
+ "batter_ev90_5g": batter_rolling_row.get("batter_ev90_5g"),
828
+ "batter_ev90_10g": batter_rolling_row.get("batter_ev90_10g"),
829
+ "batter_barrel_rate_5g": batter_rolling_row.get("batter_barrel_rate_5g"),
830
+ "batter_barrel_rate_10g": batter_rolling_row.get("batter_barrel_rate_10g"),
831
+ "batter_avg_la_5g": batter_rolling_row.get("batter_avg_launch_angle_5g"),
832
+ "batter_hard_hit_rate_5g": batter_rolling_row.get("batter_hard_hit_rate_5g"),
833
+ "batter_games_in_window_5g": batter_rolling_row.get("batter_games_in_window_5g"),
834
+ "batter_games_in_window_10g": batter_rolling_row.get("batter_games_in_window_10g"),
835
+ # Key pitcher rolling metrics for debug
836
+ "pitcher_velo_5g": pitcher_rolling_row.get("pitcher_avg_release_speed_5g"),
837
+ "pitcher_ev_allowed_5g": pitcher_rolling_row.get("pitcher_ev_allowed_5g"),
838
+ "pitcher_ev_allowed_10g": pitcher_rolling_row.get("pitcher_ev_allowed_10g"),
839
+ "pitcher_barrel_allowed_5g": pitcher_rolling_row.get("pitcher_barrel_rate_allowed_5g"),
840
+ "pitcher_barrel_allowed_10g": pitcher_rolling_row.get("pitcher_barrel_rate_allowed_10g"),
841
+ "pitcher_games_in_window_5g": pitcher_rolling_row.get("pitcher_games_in_window_5g"),
842
+
843
+ # Batch 13 — exact intermediate probability checkpoints (debug ladder)
844
+ "snap_baseline_hr": _snap_baseline_hr,
845
+ "snap_baseline_hit": _snap_baseline_hit,
846
+ "snap_baseline_tb2p": _snap_baseline_tb2p,
847
+ "snap_after_zone_hr": _snap_after_zone_hr,
848
+ "snap_after_zone_hit": _snap_after_zone_hit,
849
+ "snap_after_zone_tb2p": _snap_after_zone_tb2p,
850
+ "snap_after_family_zone_hr": _snap_after_family_zone_hr,
851
+ "snap_after_family_zone_hit": _snap_after_family_zone_hit,
852
+ "snap_after_family_zone_tb2p": _snap_after_family_zone_tb2p,
853
+ "snap_after_arsenal_hr": _snap_after_arsenal_hr,
854
+ "snap_after_arsenal_hit": _snap_after_arsenal_hit,
855
+ "snap_after_arsenal_tb2p": _snap_after_arsenal_tb2p,
856
+ "snap_after_pulled_contact_hr": _snap_after_pulled_contact_hr,
857
+ "snap_after_pulled_contact_hit": _snap_after_pulled_contact_hit,
858
+ "snap_after_pulled_contact_tb2p": _snap_after_pulled_contact_tb2p,
859
+ "snap_after_env_hr": _snap_after_env_hr,
860
+ "snap_after_env_hit": _snap_after_env_hit,
861
+ "snap_after_env_tb2p": _snap_after_env_tb2p,
862
+ "snap_after_platoon_hr": _snap_after_platoon_hr,
863
+ "snap_after_platoon_hit": _snap_after_platoon_hit,
864
+ "snap_after_platoon_tb2p": _snap_after_platoon_tb2p,
865
+ "snap_after_traj_hr": _snap_after_traj_hr,
866
+ "snap_after_traj_hit": _snap_after_traj_hit,
867
+ "snap_after_traj_tb2p": _snap_after_traj_tb2p,
868
+ "snap_after_rolling_hr": _snap_after_rolling_hr,
869
+ "snap_after_rolling_hit": _snap_after_rolling_hit,
870
+ "snap_after_rolling_tb2p": _snap_after_rolling_tb2p,
871
+ "snap_after_opportunity_hr": _snap_after_opportunity_hr,
872
+ "snap_after_opportunity_hit": _snap_after_opportunity_hit,
873
+ "snap_after_opportunity_tb2p":_snap_after_opportunity_tb2p,
874
+ "snap_after_drift_hr": _snap_after_drift_hr,
875
+ "snap_after_drift_hit": _snap_after_drift_hit,
876
+ "snap_after_drift_tb2p": _snap_after_drift_tb2p,
877
+ # Batch 13 — opportunity adjustment
878
+ "expected_pa": opp_adj.get("expected_pa"),
879
+ "pa_multiplier": opp_adj.get("pa_multiplier"),
880
+ "pitcher_quality_score": opp_adj.get("pitcher_quality_score"),
881
+ "opportunity_reason": opp_adj.get("opportunity_reason"),
882
+ "lineup_slot_used": opp_adj.get("lineup_slot_used"),
883
+ "team_total_used": opp_adj.get("team_total_used"),
884
+ "opportunity_mode": opp_adj.get("opportunity_mode"),
885
+ # Batch 13 — arsenal drift adjustment
886
+ "arsenal_hr_adjustment": drift_adj.get("arsenal_hr_adjustment"),
887
+ "arsenal_hit_adjustment": drift_adj.get("arsenal_hit_adjustment"),
888
+ "arsenal_drift_score": drift_adj.get("arsenal_drift_score"),
889
+ "arsenal_reason_tags": drift_adj.get("arsenal_reason_tags"),
890
+ "arsenal_drift_applied_scale": drift_adj.get("arsenal_drift_applied_scale"),
891
  }
892
  )
893
 
models/opportunity_model.py CHANGED
@@ -1,5 +1,11 @@
1
  from __future__ import annotations
2
 
 
 
 
 
 
 
3
 
4
  def estimate_plate_appearance_probability(
5
  outs: int,
@@ -40,4 +46,125 @@ def estimate_plate_appearance_probability(
40
  "pa_prob_this_inning": pa_prob_this_inning,
41
  "pa_prob_next_two_innings": pa_prob_next_two,
42
  "expected_pa": expected_pa,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  }
 
1
  from __future__ import annotations
2
 
3
+ from typing import Any
4
+
5
+
6
+ def _clamp(value: float, lo: float, hi: float) -> float:
7
+ return max(lo, min(hi, value))
8
+
9
 
10
  def estimate_plate_appearance_probability(
11
  outs: int,
 
46
  "pa_prob_this_inning": pa_prob_this_inning,
47
  "pa_prob_next_two_innings": pa_prob_next_two,
48
  "expected_pa": expected_pa,
49
+ }
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Batch 13 — Full-game PA scaling (upcoming simulator path only)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ _PA_MAP = {1: 4.7, 2: 4.6, 3: 4.5, 4: 4.4, 5: 4.3, 6: 4.2, 7: 4.1, 8: 4.0, 9: 3.9}
57
+ _PA_BASELINE = 4.3
58
+
59
+
60
+ def compute_opportunity_adjustment(
61
+ lineup_slot: int | None,
62
+ team_total: float | None,
63
+ pitcher_row: dict[str, Any] | None,
64
+ ) -> dict[str, Any]:
65
+ """
66
+ Compute a PA-volume-based probability multiplier for the upcoming simulator.
67
+
68
+ lineup_slot: batting order position 1–9 (None = unavailable, uses baseline 4.3 PA)
69
+ team_total: implied team run total from odds (None = unavailable, skips adjustment)
70
+ pitcher_row: season pitcher features dict (uses swstr_rate, csw_rate, ball_rate)
71
+
72
+ Returns pa_multiplier ∈ [0.85, 1.15] plus traceability fields.
73
+ """
74
+ # Step 1 — Base PA by lineup slot
75
+ if lineup_slot is not None:
76
+ try:
77
+ pa = _PA_MAP.get(int(lineup_slot), _PA_BASELINE)
78
+ except (TypeError, ValueError):
79
+ pa = _PA_BASELINE
80
+ else:
81
+ pa = _PA_BASELINE
82
+
83
+ # Step 2 — Team total adjustment
84
+ if team_total is not None:
85
+ try:
86
+ tt = float(team_total)
87
+ if tt > 5.5:
88
+ pa += 0.15
89
+ elif tt < 3.5:
90
+ pa -= 0.15
91
+ except (TypeError, ValueError):
92
+ pass
93
+
94
+ # Step 3 — Pitcher quality model (SwStr / CSW / Ball)
95
+ swstr = pitcher_row.get("swstr_rate") if pitcher_row else None
96
+ csw = pitcher_row.get("csw_rate") if pitcher_row else None
97
+ ball = pitcher_row.get("ball_rate") if pitcher_row else None
98
+
99
+ if swstr is not None and csw is not None and ball is not None:
100
+ try:
101
+ swstr = float(swstr)
102
+ csw = float(csw)
103
+ ball = float(ball)
104
+
105
+ # 3A SwStr score
106
+ if swstr < 0.08: swstr_score = +0.4
107
+ elif swstr < 0.10: swstr_score = +0.2
108
+ elif swstr < 0.12: swstr_score = 0.0
109
+ elif swstr < 0.14: swstr_score = -0.3
110
+ else: swstr_score = -0.5
111
+
112
+ # 3B CSW score
113
+ if csw < 0.26: csw_score = +0.3
114
+ elif csw < 0.28: csw_score = +0.1
115
+ elif csw < 0.30: csw_score = 0.0
116
+ elif csw < 0.32: csw_score = -0.2
117
+ else: csw_score = -0.4
118
+
119
+ # 3C Ball score
120
+ if ball < 0.32: ball_score = -0.3
121
+ elif ball < 0.35: ball_score = -0.1
122
+ elif ball < 0.38: ball_score = 0.0
123
+ elif ball < 0.41: ball_score = +0.2
124
+ else: ball_score = +0.4
125
+
126
+ # 3D Ratio (SwStr / Ball)
127
+ ratio = swstr / max(ball, 0.01)
128
+ if ratio > 0.40: ratio_score = -0.4
129
+ elif ratio > 0.30: ratio_score = -0.2
130
+ elif ratio > 0.22: ratio_score = 0.0
131
+ elif ratio > 0.15: ratio_score = +0.2
132
+ else: ratio_score = +0.4
133
+
134
+ # 3E Combine
135
+ quality_score = _clamp(
136
+ swstr_score * 0.35 + csw_score * 0.30 + ball_score * 0.20 + ratio_score * 0.15,
137
+ -1.0, 1.0,
138
+ )
139
+ pa_multiplier = _clamp(1.0 + quality_score * 0.25, 0.85, 1.15)
140
+ opportunity_reason = f"swstr={swstr:.3f}|csw={csw:.3f}|ball={ball:.3f}"
141
+ except Exception:
142
+ quality_score = 0.0
143
+ pa_multiplier = 1.0
144
+ opportunity_reason = "pitcher_quality_error"
145
+ else:
146
+ quality_score = 0.0
147
+ pa_multiplier = 1.0
148
+ opportunity_reason = "pitcher_quality_missing"
149
+
150
+ # opportunity_mode for traceability
151
+ if lineup_slot is not None and team_total is not None and quality_score != 0.0:
152
+ opportunity_mode = "full"
153
+ elif lineup_slot is not None and team_total is not None:
154
+ opportunity_mode = "slot_and_total"
155
+ elif quality_score != 0.0:
156
+ opportunity_mode = "pitcher_quality_only"
157
+ elif lineup_slot is not None:
158
+ opportunity_mode = "slot_only"
159
+ else:
160
+ opportunity_mode = "baseline_only"
161
+
162
+ return {
163
+ "expected_pa": round(float(pa), 3),
164
+ "pa_multiplier": round(pa_multiplier, 4),
165
+ "pitcher_quality_score": round(quality_score, 4),
166
+ "opportunity_reason": opportunity_reason,
167
+ "lineup_slot_used": lineup_slot,
168
+ "team_total_used": team_total,
169
+ "opportunity_mode": opportunity_mode,
170
  }
models/pitcher_adjustment.py CHANGED
@@ -73,6 +73,9 @@ def build_pitcher_feature_row(
73
  "la_sweet_spot_allowed_rate": 0.0,
74
  "la_optimal_hr_allowed_rate": 0.0,
75
  "avg_launch_angle_allowed": None,
 
 
 
76
  }
77
 
78
  df = pd.DataFrame()
@@ -129,6 +132,9 @@ def build_pitcher_feature_row(
129
  "la_sweet_spot_allowed_rate": 0.0,
130
  "la_optimal_hr_allowed_rate": 0.0,
131
  "avg_launch_angle_allowed": None,
 
 
 
132
  }
133
 
134
  launch_speed = pd.to_numeric(df.get("launch_speed"), errors="coerce")
@@ -200,6 +206,20 @@ def build_pitcher_feature_row(
200
 
201
  avg_launch_angle_allowed = _safe_mean(launch_angle)
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  return {
204
  "pitcher_name": pitcher_name,
205
  "ev_allowed": ev_allowed,
@@ -219,6 +239,9 @@ def build_pitcher_feature_row(
219
  "la_sweet_spot_allowed_rate": la_sweet_spot_allowed_rate,
220
  "la_optimal_hr_allowed_rate": la_optimal_hr_allowed_rate,
221
  "avg_launch_angle_allowed": avg_launch_angle_allowed,
 
 
 
222
  }
223
 
224
 
 
73
  "la_sweet_spot_allowed_rate": 0.0,
74
  "la_optimal_hr_allowed_rate": 0.0,
75
  "avg_launch_angle_allowed": None,
76
+ "swstr_rate": None,
77
+ "csw_rate": None,
78
+ "ball_rate": None,
79
  }
80
 
81
  df = pd.DataFrame()
 
132
  "la_sweet_spot_allowed_rate": 0.0,
133
  "la_optimal_hr_allowed_rate": 0.0,
134
  "avg_launch_angle_allowed": None,
135
+ "swstr_rate": None,
136
+ "csw_rate": None,
137
+ "ball_rate": None,
138
  }
139
 
140
  launch_speed = pd.to_numeric(df.get("launch_speed"), errors="coerce")
 
206
 
207
  avg_launch_angle_allowed = _safe_mean(launch_angle)
208
 
209
+ # Batch 13: Pitch-level command rates from description column
210
+ swstr_rate = None
211
+ csw_rate = None
212
+ ball_rate = None
213
+ if "description" in df.columns and len(df) >= 10:
214
+ desc = df["description"].astype(str).str.strip().str.lower()
215
+ total = len(desc)
216
+ swstr_mask = desc.isin({"swinging_strike", "swinging_strike_blocked"})
217
+ cs_mask = desc == "called_strike"
218
+ ball_mask = desc.isin({"ball", "blocked_ball", "intent_ball", "pitchout"})
219
+ swstr_rate = float(swstr_mask.sum() / total)
220
+ csw_rate = float((swstr_mask | cs_mask).sum() / total)
221
+ ball_rate = float(ball_mask.sum() / total)
222
+
223
  return {
224
  "pitcher_name": pitcher_name,
225
  "ev_allowed": ev_allowed,
 
239
  "la_sweet_spot_allowed_rate": la_sweet_spot_allowed_rate,
240
  "la_optimal_hr_allowed_rate": la_optimal_hr_allowed_rate,
241
  "avg_launch_angle_allowed": avg_launch_angle_allowed,
242
+ "swstr_rate": swstr_rate,
243
+ "csw_rate": csw_rate,
244
+ "ball_rate": ball_rate,
245
  }
246
 
247
 
models/rolling_form_model.py ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Batch 12E — Rolling Upcoming Form Layer
5
+
6
+ Computes game-based (5g / 10g) rolling batter and pitcher form metrics and
7
+ translates them into bounded additive probability adjustments for the UPCOMING
8
+ game engine only.
9
+
10
+ Design principles:
11
+ - Returns absolute rolling values; deltas are computed in the adjustment function
12
+ against stable batter_features / pitcher_row baselines (NOT recomputed from the
13
+ same narrow window).
14
+ - Sample-aware: weak windows (< 2 games) produce zero adjustment.
15
+ - 10g window used as confirmation/dampening of 5g signal.
16
+ - Pitcher-side adjustments scaled by pitcher_rolling_confidence (match quality
17
+ × sample availability).
18
+ - Hard-capped adjustments; no runaway boosts.
19
+ - Pull/direction metrics SKIPPED (spray_angle not in normalized statcast).
20
+ - Zone/heart-rate metrics SKIPPED (not in normalized statcast).
21
+ """
22
+
23
+ import logging
24
+ import re
25
+ import unicodedata
26
+ from datetime import date, datetime
27
+ from typing import Any
28
+
29
+ import pandas as pd
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Shared helpers (same barrel definition and utils as batter_trend_model)
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def _parse_reference_date(reference_date: Any) -> date | None:
39
+ if reference_date is None:
40
+ return None
41
+ if isinstance(reference_date, datetime):
42
+ return reference_date.date()
43
+ if isinstance(reference_date, date):
44
+ return reference_date
45
+ if isinstance(reference_date, str):
46
+ for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"):
47
+ try:
48
+ return datetime.strptime(reference_date[:19], fmt).date()
49
+ except ValueError:
50
+ continue
51
+ return None
52
+
53
+
54
+ def _percentile(series: pd.Series, q: float) -> float | None:
55
+ numeric = pd.to_numeric(series, errors="coerce").dropna()
56
+ if len(numeric) < 5:
57
+ return None
58
+ return float(numeric.quantile(q))
59
+
60
+
61
+ def _safe_mean(series: pd.Series) -> float | None:
62
+ numeric = pd.to_numeric(series, errors="coerce").dropna()
63
+ if len(numeric) < 5:
64
+ return None
65
+ return float(numeric.mean())
66
+
67
+
68
+ def _barrel_rate(launch_speed: pd.Series, launch_angle: pd.Series) -> float | None:
69
+ valid = pd.DataFrame(
70
+ {
71
+ "ls": pd.to_numeric(launch_speed, errors="coerce"),
72
+ "la": pd.to_numeric(launch_angle, errors="coerce"),
73
+ }
74
+ ).dropna()
75
+ if len(valid) < 5:
76
+ return None
77
+ mask = (
78
+ ((valid["ls"] >= 98) & (valid["la"].between(26, 30)))
79
+ | ((valid["ls"] >= 99) & (valid["la"].between(25, 31)))
80
+ | ((valid["ls"] >= 100) & (valid["la"].between(23, 33)))
81
+ | ((valid["ls"] >= 102) & (valid["la"].between(20, 35)))
82
+ )
83
+ return float(mask.mean())
84
+
85
+
86
+ def _safe_rate_from_la(
87
+ launch_angle: pd.Series,
88
+ lo: float,
89
+ hi: float | None = None,
90
+ ) -> float | None:
91
+ """Fraction of non-null LA rows where lo <= la < hi (or la >= lo if hi is None)."""
92
+ la = pd.to_numeric(launch_angle, errors="coerce").dropna()
93
+ if len(la) < 5:
94
+ return None
95
+ if hi is None:
96
+ return float((la >= lo).mean())
97
+ return float(((la >= lo) & (la < hi)).mean())
98
+
99
+
100
+ def _n_games(df: pd.DataFrame) -> int:
101
+ """Count unique game_pk values in a slice; fall back to row-count heuristic."""
102
+ if "game_pk" in df.columns:
103
+ return int(df["game_pk"].nunique())
104
+ return len(df)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Game-window helper
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def _game_window_df(player_df: pd.DataFrame, ref: date, n_games: int) -> pd.DataFrame:
113
+ """
114
+ Return rows for the last `n_games` unique games before `ref` (exclusive).
115
+
116
+ Sorting is by `game_date` descending; unique `game_pk` values are taken in
117
+ that order. Falls back to the last N×25 rows (rough PA estimate) if
118
+ `game_pk` is unavailable.
119
+ """
120
+ if player_df.empty:
121
+ return player_df.iloc[0:0]
122
+
123
+ if "game_date" not in player_df.columns:
124
+ return player_df.iloc[0:0]
125
+
126
+ game_dates = pd.to_datetime(player_df["game_date"], errors="coerce")
127
+ cutoff = pd.Timestamp(ref)
128
+ before_ref = player_df[game_dates < cutoff].copy()
129
+
130
+ if before_ref.empty:
131
+ return before_ref
132
+
133
+ before_ref["_gd"] = pd.to_datetime(before_ref["game_date"], errors="coerce")
134
+
135
+ if "game_pk" in before_ref.columns:
136
+ before_ref["_gpk"] = pd.to_numeric(before_ref["game_pk"], errors="coerce")
137
+ sorted_games = (
138
+ before_ref.groupby("_gpk")["_gd"]
139
+ .max()
140
+ .sort_values(ascending=False)
141
+ .head(n_games)
142
+ .index.tolist()
143
+ )
144
+ result = before_ref[before_ref["_gpk"].isin(sorted_games)].drop(
145
+ columns=["_gd", "_gpk"], errors="ignore"
146
+ )
147
+ return result
148
+
149
+ # Fallback: no game_pk — take last n_games*25 rows sorted by date
150
+ fallback = before_ref.sort_values("_gd", ascending=False).head(n_games * 25)
151
+ return fallback.drop(columns=["_gd"], errors="ignore")
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Pitcher name normalization (mirrors pitcher_adjustment.py)
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ def _normalize_name(name: str) -> str:
160
+ text = str(name or "").strip().lower()
161
+ text = unicodedata.normalize("NFKD", text)
162
+ text = "".join(ch for ch in text if not unicodedata.combining(ch))
163
+ text = text.replace(",", " ")
164
+ text = re.sub(r"\s+", " ", text).strip()
165
+ return text
166
+
167
+
168
+ def _name_variants(name: str) -> set[str]:
169
+ normalized = _normalize_name(name)
170
+ if not normalized:
171
+ return set()
172
+ parts = normalized.split()
173
+ variants = {normalized}
174
+ if len(parts) >= 2:
175
+ first, last = parts[0], parts[-1]
176
+ middle = " ".join(parts[1:-1]).strip()
177
+ variants.add(f"{last} {first}".strip())
178
+ if middle:
179
+ variants.add(f"{last} {first} {middle}".strip())
180
+ return variants
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Empty skeletons
185
+ # ---------------------------------------------------------------------------
186
+
187
+ _EMPTY_BATTER_ROLL: dict[str, Any] = {
188
+ "batter_ev_5g": None,
189
+ "batter_ev_10g": None,
190
+ "batter_ev90_5g": None,
191
+ "batter_ev90_10g": None,
192
+ "batter_hard_hit_rate_5g": None,
193
+ "batter_hard_hit_rate_10g": None,
194
+ "batter_barrel_rate_5g": None,
195
+ "batter_barrel_rate_10g": None,
196
+ "batter_avg_launch_angle_5g": None,
197
+ "batter_avg_launch_angle_10g": None,
198
+ "batter_fb_rate_5g": None,
199
+ "batter_fb_rate_10g": None,
200
+ "batter_ld_rate_5g": None,
201
+ "batter_gb_rate_5g": None,
202
+ "batter_air_ball_rate_5g": None,
203
+ "batter_hr_rate_5g": None,
204
+ "batter_hr_rate_10g": None,
205
+ # direction metrics deferred (spray_angle not in normalized statcast)
206
+ "batter_pull_air_rate_5g": None,
207
+ "batter_pulled_hard_air_rate_5g": None,
208
+ "batter_pulled_barrel_rate_5g": None,
209
+ "batter_games_in_window_5g": 0,
210
+ "batter_games_in_window_10g": 0,
211
+ "batter_recent_form_available": 0,
212
+ }
213
+
214
+ _EMPTY_PITCHER_ROLL: dict[str, Any] = {
215
+ "pitcher_avg_release_speed_5g": None,
216
+ "pitcher_avg_release_speed_10g": None,
217
+ "pitcher_avg_release_spin_rate_5g": None,
218
+ "pitcher_ev_allowed_5g": None,
219
+ "pitcher_ev_allowed_10g": None,
220
+ "pitcher_hard_hit_rate_allowed_5g": None,
221
+ "pitcher_hard_hit_rate_allowed_10g": None,
222
+ "pitcher_barrel_rate_allowed_5g": None,
223
+ "pitcher_barrel_rate_allowed_10g": None,
224
+ "pitcher_avg_launch_angle_allowed_5g": None,
225
+ "pitcher_fb_rate_allowed_5g": None,
226
+ "pitcher_ld_rate_allowed_5g": None,
227
+ "pitcher_gb_rate_allowed_5g": None,
228
+ "pitcher_hr_allowed_rate_5g": None,
229
+ "pitcher_hr_allowed_rate_10g": None,
230
+ "pitcher_games_in_window_5g": 0,
231
+ "pitcher_games_in_window_10g": 0,
232
+ "pitcher_recent_form_available": 0,
233
+ "pitcher_rolling_confidence": 0.0,
234
+ }
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Public API — batter rolling form
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ def build_batter_rolling_form_row(
243
+ statcast_df: pd.DataFrame,
244
+ player_name: str,
245
+ reference_date: Any = None,
246
+ ) -> dict[str, Any]:
247
+ """
248
+ Compute game-based 5g / 10g rolling form metrics for *player_name*.
249
+
250
+ Returns absolute rolling values only; delta vs. baseline is handled in
251
+ compute_upcoming_rolling_adjustment() against stable batter_features values.
252
+ """
253
+ if statcast_df is None or statcast_df.empty:
254
+ return dict(_EMPTY_BATTER_ROLL)
255
+
256
+ ref = _parse_reference_date(reference_date)
257
+ if ref is None:
258
+ return dict(_EMPTY_BATTER_ROLL)
259
+
260
+ try:
261
+ player_df = statcast_df[
262
+ statcast_df["player_name"].astype(str) == str(player_name)
263
+ ].copy()
264
+ except Exception:
265
+ return dict(_EMPTY_BATTER_ROLL)
266
+
267
+ if player_df.empty:
268
+ return dict(_EMPTY_BATTER_ROLL)
269
+
270
+ df5 = _game_window_df(player_df, ref, 5)
271
+ df10 = _game_window_df(player_df, ref, 10)
272
+
273
+ n5 = _n_games(df5)
274
+ n10 = _n_games(df10)
275
+
276
+ def _hr_rate(df: pd.DataFrame) -> float | None:
277
+ if "events" not in df.columns or len(df) < 5:
278
+ return None
279
+ events = df["events"].dropna().astype(str)
280
+ if events.empty:
281
+ return None
282
+ return float((events == "home_run").mean())
283
+
284
+ def _hh_rate(df: pd.DataFrame) -> float | None:
285
+ ls = pd.to_numeric(df.get("launch_speed", pd.Series(dtype=float)), errors="coerce").dropna()
286
+ if len(ls) < 5:
287
+ return None
288
+ return float((ls >= 95).mean())
289
+
290
+ ls5 = df5.get("launch_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float)
291
+ la5 = df5.get("launch_angle", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float)
292
+ ls10 = df10.get("launch_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float)
293
+ la10 = df10.get("launch_angle", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float)
294
+
295
+ return {
296
+ "batter_ev_5g": _safe_mean(ls5),
297
+ "batter_ev_10g": _safe_mean(ls10),
298
+ "batter_ev90_5g": _percentile(ls5, 0.90),
299
+ "batter_ev90_10g": _percentile(ls10, 0.90),
300
+ "batter_hard_hit_rate_5g": _hh_rate(df5),
301
+ "batter_hard_hit_rate_10g": _hh_rate(df10),
302
+ "batter_barrel_rate_5g": _barrel_rate(ls5, la5),
303
+ "batter_barrel_rate_10g": _barrel_rate(ls10, la10),
304
+ "batter_avg_launch_angle_5g": _safe_mean(la5),
305
+ "batter_avg_launch_angle_10g": _safe_mean(la10),
306
+ "batter_fb_rate_5g": _safe_rate_from_la(la5, 25.0),
307
+ "batter_fb_rate_10g": _safe_rate_from_la(la10, 25.0),
308
+ "batter_ld_rate_5g": _safe_rate_from_la(la5, 10.0, 25.0),
309
+ "batter_gb_rate_5g": _safe_rate_from_la(la5, -90.0, 10.0),
310
+ "batter_air_ball_rate_5g": _safe_rate_from_la(la5, 10.0),
311
+ "batter_hr_rate_5g": _hr_rate(df5),
312
+ "batter_hr_rate_10g": _hr_rate(df10),
313
+ # direction metrics deferred
314
+ "batter_pull_air_rate_5g": None,
315
+ "batter_pulled_hard_air_rate_5g": None,
316
+ "batter_pulled_barrel_rate_5g": None,
317
+ "batter_games_in_window_5g": n5,
318
+ "batter_games_in_window_10g": n10,
319
+ "batter_recent_form_available": 1 if n5 >= 2 else 0,
320
+ }
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Public API — pitcher rolling form
325
+ # ---------------------------------------------------------------------------
326
+
327
+
328
+ def build_pitcher_rolling_form_row(
329
+ statcast_df: pd.DataFrame,
330
+ pitcher_name: str | None = None,
331
+ pitcher_id: int | None = None,
332
+ reference_date: Any = None,
333
+ ) -> dict[str, Any]:
334
+ """
335
+ Compute game-based 5g / 10g rolling form metrics for a pitcher.
336
+
337
+ Follows the same fuzzy-name-match pattern as pitcher_adjustment.py.
338
+ pitcher_rolling_confidence reflects match quality × sample availability.
339
+ """
340
+ if statcast_df is None or statcast_df.empty:
341
+ return dict(_EMPTY_PITCHER_ROLL)
342
+
343
+ ref = _parse_reference_date(reference_date)
344
+ if ref is None:
345
+ return dict(_EMPTY_PITCHER_ROLL)
346
+
347
+ pitcher_name = str(pitcher_name or "").strip()
348
+
349
+ df = pd.DataFrame()
350
+ match_quality = "none"
351
+
352
+ # Attempt 1: pitcher ID column (present in some CSVs)
353
+ if pitcher_id is not None and "pitcher" in statcast_df.columns:
354
+ try:
355
+ numeric_ids = pd.to_numeric(statcast_df["pitcher"], errors="coerce")
356
+ df = statcast_df[numeric_ids == int(pitcher_id)].copy()
357
+ if not df.empty:
358
+ match_quality = "id"
359
+ except Exception:
360
+ df = pd.DataFrame()
361
+
362
+ # Attempt 2: exact / variant name match on player_name
363
+ if df.empty and pitcher_name and "player_name" in statcast_df.columns:
364
+ variants = _name_variants(pitcher_name)
365
+ normalized_series = statcast_df["player_name"].astype(str).map(_normalize_name)
366
+ mask = normalized_series.isin(variants)
367
+ df = statcast_df[mask].copy()
368
+ if not df.empty:
369
+ match_quality = "exact"
370
+
371
+ # Attempt 3: loose contains-style match
372
+ if df.empty and pitcher_name and "player_name" in statcast_df.columns:
373
+ parts = _normalize_name(pitcher_name).split()
374
+ if len(parts) >= 2:
375
+ first, last = parts[0], parts[-1]
376
+ normalized_series = statcast_df["player_name"].astype(str).map(_normalize_name)
377
+ loose_mask = normalized_series.apply(
378
+ lambda n: isinstance(n, str) and first in n and last in n
379
+ )
380
+ df = statcast_df[loose_mask].copy()
381
+ if not df.empty:
382
+ match_quality = "loose"
383
+
384
+ if df.empty:
385
+ return dict(_EMPTY_PITCHER_ROLL)
386
+
387
+ df5 = _game_window_df(df, ref, 5)
388
+ df10 = _game_window_df(df, ref, 10)
389
+
390
+ n5 = _n_games(df5)
391
+ n10 = _n_games(df10)
392
+
393
+ # pitcher_rolling_confidence: match quality × sample scale
394
+ sample_scale_5g = (
395
+ 0.0 if n5 < 2
396
+ else 0.4 if n5 <= 3
397
+ else 0.7 if n5 == 4
398
+ else 1.0
399
+ )
400
+ match_scale = {
401
+ "id": 1.0,
402
+ "exact": 1.0,
403
+ "loose": 0.4,
404
+ "none": 0.0,
405
+ }.get(match_quality, 0.0)
406
+ confidence = round(match_scale * sample_scale_5g, 3)
407
+
408
+ def _hh_rate(df: pd.DataFrame) -> float | None:
409
+ ls = pd.to_numeric(df.get("launch_speed", pd.Series(dtype=float)), errors="coerce").dropna()
410
+ if len(ls) < 5:
411
+ return None
412
+ return float((ls >= 95).mean())
413
+
414
+ def _hr_rate_allowed(df: pd.DataFrame) -> float | None:
415
+ if "events" not in df.columns or len(df) < 5:
416
+ return None
417
+ events = df["events"].dropna().astype(str)
418
+ if events.empty:
419
+ return None
420
+ return float((events == "home_run").mean())
421
+
422
+ ls5 = df5.get("launch_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float)
423
+ la5 = df5.get("launch_angle", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float)
424
+ ls10 = df10.get("launch_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float)
425
+ la10 = df10.get("launch_angle", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float)
426
+ rs5 = df5.get("release_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float)
427
+ rs10 = df10.get("release_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float)
428
+ spin5 = df5.get("release_spin_rate", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float)
429
+
430
+ return {
431
+ "pitcher_avg_release_speed_5g": _safe_mean(rs5),
432
+ "pitcher_avg_release_speed_10g": _safe_mean(rs10),
433
+ "pitcher_avg_release_spin_rate_5g": _safe_mean(spin5),
434
+ "pitcher_ev_allowed_5g": _safe_mean(ls5),
435
+ "pitcher_ev_allowed_10g": _safe_mean(ls10),
436
+ "pitcher_hard_hit_rate_allowed_5g": _hh_rate(df5),
437
+ "pitcher_hard_hit_rate_allowed_10g": _hh_rate(df10),
438
+ "pitcher_barrel_rate_allowed_5g": _barrel_rate(ls5, la5),
439
+ "pitcher_barrel_rate_allowed_10g": _barrel_rate(ls10, la10),
440
+ "pitcher_avg_launch_angle_allowed_5g": _safe_mean(la5),
441
+ "pitcher_fb_rate_allowed_5g": _safe_rate_from_la(la5, 25.0),
442
+ "pitcher_ld_rate_allowed_5g": _safe_rate_from_la(la5, 10.0, 25.0),
443
+ "pitcher_gb_rate_allowed_5g": _safe_rate_from_la(la5, -90.0, 10.0),
444
+ "pitcher_hr_allowed_rate_5g": _hr_rate_allowed(df5),
445
+ "pitcher_hr_allowed_rate_10g": _hr_rate_allowed(df10),
446
+ "pitcher_games_in_window_5g": n5,
447
+ "pitcher_games_in_window_10g": n10,
448
+ "pitcher_recent_form_available": 1 if n5 >= 2 else 0,
449
+ "pitcher_rolling_confidence": confidence,
450
+ }
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Helpers for adjustment function
455
+ # ---------------------------------------------------------------------------
456
+
457
+
458
+ def _safe_delta(rolling_val: Any, baseline_val: Any) -> float | None:
459
+ """rolling - baseline; returns None if either is None."""
460
+ if rolling_val is None or baseline_val is None:
461
+ return None
462
+ try:
463
+ return float(rolling_val) - float(baseline_val)
464
+ except (TypeError, ValueError):
465
+ return None
466
+
467
+
468
+ def _clamp(value: float, lo: float, hi: float) -> float:
469
+ return max(lo, min(hi, value))
470
+
471
+
472
+ def _10g_confirmation_scale(delta_5g: float | None, delta_10g: float | None, threshold: float) -> float:
473
+ """
474
+ 1.0 if 10g confirms 5g direction or is None (neutral).
475
+ 0.5 if 10g conflicts with 5g direction.
476
+ """
477
+ if delta_5g is None or delta_10g is None:
478
+ return 1.0
479
+ aligned = (delta_5g > threshold) == (delta_10g > threshold)
480
+ return 1.0 if aligned else 0.5
481
+
482
+
483
+ def _sample_scale(n_games: int) -> float:
484
+ if n_games < 2:
485
+ return 0.0
486
+ if n_games <= 3:
487
+ return 0.4
488
+ if n_games == 4:
489
+ return 0.7
490
+ return 1.0
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # Public API — rolling adjustment
495
+ # ---------------------------------------------------------------------------
496
+
497
+
498
+ def compute_upcoming_rolling_adjustment(
499
+ batter_roll: dict[str, Any],
500
+ pitcher_roll: dict[str, Any],
501
+ batter_features: dict[str, Any],
502
+ pitcher_row: dict[str, Any],
503
+ ) -> dict[str, Any]:
504
+ """
505
+ Compute bounded additive probability adjustments from rolling form.
506
+
507
+ Deltas are computed against the stable batter_features / pitcher_row
508
+ baselines (not recomputed from the narrow rolling window).
509
+
510
+ Returns a dict with rolling_hit_adjustment, rolling_hr_adjustment,
511
+ rolling_tb2p_adjustment, scores, tags (pipe-delimited string), and
512
+ pitcher_rolling_confidence.
513
+ """
514
+ batter_n5 = int(batter_roll.get("batter_games_in_window_5g") or 0)
515
+ pitcher_n5 = int(pitcher_roll.get("pitcher_games_in_window_5g") or 0)
516
+ pitcher_confidence = float(pitcher_roll.get("pitcher_rolling_confidence") or 0.0)
517
+
518
+ batter_scale = _sample_scale(batter_n5)
519
+ pitcher_n5_scale = _sample_scale(pitcher_n5)
520
+ pitcher_scale = pitcher_confidence * pitcher_n5_scale
521
+
522
+ # ------------------------------------------------------------------
523
+ # Compute deltas vs stable engine baselines
524
+ # ------------------------------------------------------------------
525
+
526
+ # Batter deltas
527
+ ev90_delta_5g = _safe_delta(batter_roll.get("batter_ev90_5g"), batter_features.get("ev90"))
528
+ ev90_delta_10g = _safe_delta(batter_roll.get("batter_ev90_10g"), batter_features.get("ev90"))
529
+ barrel_delta_5g = _safe_delta(batter_roll.get("batter_barrel_rate_5g"), batter_features.get("barrel_rate"))
530
+ barrel_delta_10g = _safe_delta(batter_roll.get("batter_barrel_rate_10g"), batter_features.get("barrel_rate"))
531
+ hh_delta_5g = _safe_delta(batter_roll.get("batter_hard_hit_rate_5g"), batter_features.get("hard_hit_rate"))
532
+ la_delta_5g = _safe_delta(batter_roll.get("batter_avg_launch_angle_5g"), batter_features.get("avg_launch_angle"))
533
+ air_ball_5g = batter_roll.get("batter_air_ball_rate_5g")
534
+ air_ball_baseline = batter_features.get("air_ball_rate")
535
+ air_ball_delta_5g = _safe_delta(air_ball_5g, air_ball_baseline)
536
+
537
+ # Pitcher deltas vs stable pitcher_row baselines
538
+ velo_delta_5g = _safe_delta(pitcher_roll.get("pitcher_avg_release_speed_5g"), pitcher_row.get("avg_release_speed"))
539
+ ev_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_ev_allowed_5g"), pitcher_row.get("ev_allowed"))
540
+ ev_allowed_delta_10g = _safe_delta(pitcher_roll.get("pitcher_ev_allowed_10g"), pitcher_row.get("ev_allowed"))
541
+ barrel_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_barrel_rate_allowed_5g"), pitcher_row.get("barrel_rate_allowed"))
542
+ barrel_allowed_delta_10g = _safe_delta(pitcher_roll.get("pitcher_barrel_rate_allowed_10g"), pitcher_row.get("barrel_rate_allowed"))
543
+ hh_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_hard_hit_rate_allowed_5g"), pitcher_row.get("hard_hit_rate_allowed"))
544
+
545
+ # ------------------------------------------------------------------
546
+ # Batter form score
547
+ # ------------------------------------------------------------------
548
+ batter_score = 0.0
549
+ active_batter_tags: list[str] = []
550
+
551
+ if ev90_delta_5g is not None:
552
+ conf_10g = _10g_confirmation_scale(ev90_delta_5g, ev90_delta_10g, 2.0)
553
+ if ev90_delta_5g > 2.0:
554
+ batter_score += 0.25 * conf_10g
555
+ active_batter_tags.append("batter_ev90_surge")
556
+ elif ev90_delta_5g < -2.0:
557
+ batter_score -= 0.25 * conf_10g
558
+ active_batter_tags.append("batter_ev90_decline")
559
+
560
+ if barrel_delta_5g is not None:
561
+ conf_10g = _10g_confirmation_scale(barrel_delta_5g, barrel_delta_10g, 0.03)
562
+ if barrel_delta_5g > 0.03:
563
+ batter_score += 0.40 * conf_10g
564
+ active_batter_tags.append("batter_barrel_spike")
565
+ elif barrel_delta_5g < -0.03:
566
+ batter_score -= 0.40 * conf_10g
567
+ active_batter_tags.append("batter_barrel_drop")
568
+
569
+ if hh_delta_5g is not None and hh_delta_5g > 0.05:
570
+ batter_score += 0.20
571
+ active_batter_tags.append("batter_hard_hit_rising")
572
+
573
+ if (
574
+ la_delta_5g is not None
575
+ and batter_roll.get("batter_avg_launch_angle_5g") is not None
576
+ and 20.0 < float(batter_roll["batter_avg_launch_angle_5g"]) < 30.0
577
+ and la_delta_5g > 3.0
578
+ ):
579
+ batter_score += 0.20
580
+ active_batter_tags.append("batter_la_optimizing")
581
+
582
+ if (
583
+ air_ball_5g is not None
584
+ and air_ball_delta_5g is not None
585
+ and float(air_ball_5g) > 0.45
586
+ and air_ball_delta_5g > 0.05
587
+ ):
588
+ batter_score += 0.15
589
+ active_batter_tags.append("batter_air_ball_spike")
590
+
591
+ batter_score = _clamp(batter_score * batter_scale, -1.0, 1.0)
592
+
593
+ # ------------------------------------------------------------------
594
+ # Pitcher form score
595
+ # ------------------------------------------------------------------
596
+ pitcher_score = 0.0
597
+ active_pitcher_tags: list[str] = []
598
+
599
+ if velo_delta_5g is not None:
600
+ if velo_delta_5g < -3.0:
601
+ pitcher_score += 0.50 # -1.5 and -3.0 contributions combined
602
+ active_pitcher_tags.append("pitcher_velo_decline_hard")
603
+ elif velo_delta_5g < -1.5:
604
+ pitcher_score += 0.30
605
+ active_pitcher_tags.append("pitcher_velo_decline")
606
+
607
+ if ev_allowed_delta_5g is not None:
608
+ conf_10g = _10g_confirmation_scale(ev_allowed_delta_5g, ev_allowed_delta_10g, 2.0)
609
+ if ev_allowed_delta_5g > 2.0:
610
+ pitcher_score += 0.30 * conf_10g
611
+ active_pitcher_tags.append("pitcher_ev_allowed_spiking")
612
+
613
+ if barrel_allowed_delta_5g is not None:
614
+ conf_10g = _10g_confirmation_scale(barrel_allowed_delta_5g, barrel_allowed_delta_10g, 0.03)
615
+ if barrel_allowed_delta_5g > 0.03:
616
+ pitcher_score += 0.40 * conf_10g
617
+ active_pitcher_tags.append("pitcher_barrel_allowed_spiking")
618
+
619
+ if hh_allowed_delta_5g is not None and hh_allowed_delta_5g > 0.05:
620
+ pitcher_score += 0.20
621
+ active_pitcher_tags.append("pitcher_hard_hit_allowed_rising")
622
+
623
+ # Pitcher sharp: velo up + EV allowed down + barrel allowed down
624
+ pitcher_sharp = (
625
+ velo_delta_5g is not None and velo_delta_5g > 1.5
626
+ and ev_allowed_delta_5g is not None and ev_allowed_delta_5g < -2.0
627
+ and barrel_allowed_delta_5g is not None and barrel_allowed_delta_5g < -0.03
628
+ )
629
+ if pitcher_sharp:
630
+ pitcher_score -= 0.35
631
+ active_pitcher_tags.append("pitcher_sharp_recently")
632
+
633
+ pitcher_score = _clamp(pitcher_score * pitcher_scale, -1.0, 1.0)
634
+
635
+ # ------------------------------------------------------------------
636
+ # Combined score and adjustments
637
+ # ------------------------------------------------------------------
638
+ combined = _clamp(batter_score + pitcher_score, -1.0, 1.0)
639
+
640
+ rolling_hr_adjustment = _clamp(combined * 0.012, -0.012, 0.012)
641
+ rolling_hit_adjustment = _clamp(combined * 0.010, -0.010, 0.010)
642
+ rolling_tb2p_adjustment = _clamp(combined * 0.011, -0.011, 0.011)
643
+
644
+ adjustment_applied = abs(combined) > 0.05
645
+
646
+ # Compact pipe-delimited reason tags (up to 3 most active)
647
+ all_tags = (active_batter_tags + active_pitcher_tags)[:3]
648
+ reason_tags_str = "|".join(all_tags)
649
+
650
+ return {
651
+ "rolling_hit_adjustment": round(rolling_hit_adjustment, 5),
652
+ "rolling_hr_adjustment": round(rolling_hr_adjustment, 5),
653
+ "rolling_tb2p_adjustment": round(rolling_tb2p_adjustment, 5),
654
+ "rolling_batter_form_score": round(batter_score, 4),
655
+ "rolling_pitcher_form_score": round(pitcher_score, 4),
656
+ "rolling_combined_form_score": round(combined, 4),
657
+ "rolling_adjustment_applied": adjustment_applied,
658
+ "rolling_adjustment_reason_tags": reason_tags_str,
659
+ "pitcher_rolling_confidence": pitcher_confidence,
660
+ }
visualization/debug_page.py ADDED
@@ -0,0 +1,576 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Batch 13 — Full Debug Dashboard
5
+
6
+ Renders the Debug navigation page. All model-layer diagnostics,
7
+ adjustment ladders, signal attribution, admin tools, and audit
8
+ metrics are consolidated here, replacing the Debug expander that
9
+ previously lived inside render_dashboard().
10
+ """
11
+
12
+ import json
13
+ from typing import Any, Callable
14
+
15
+ import pandas as pd
16
+ import streamlit as st
17
+
18
+ from analytics.evaluation_metrics import (
19
+ build_clv_by_tier_table,
20
+ build_clv_table,
21
+ build_confidence_table,
22
+ build_edge_bucket_table,
23
+ build_ere_by_confidence_bucket_table,
24
+ build_ere_by_edge_bucket_table,
25
+ build_ere_by_tier_table,
26
+ build_ere_table,
27
+ build_hr_calibration_table,
28
+ build_tier_performance_table,
29
+ )
30
+ from analytics.batter_audit_metrics import (
31
+ build_batter_hr_tier_table,
32
+ build_batter_hr_confidence_table,
33
+ build_batter_hr_edge_table,
34
+ )
35
+ from analytics.recommendation_engine import build_upcoming_hitter_recommendations
36
+ from database.db import (
37
+ read_batter_prop_audit_view,
38
+ read_batter_prop_outcomes,
39
+ read_game_outcomes,
40
+ read_recommendation_audit_view,
41
+ read_table,
42
+ )
43
+ from models.live_fair_simulator_v3 import build_upcoming_simulated_rows
44
+ from models.pitcher_adjustment import build_pitcher_feature_row
45
+ from utils.dates import current_wbc_date_str
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Ladder definition — HR prob checkpoint fields in output dict order
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _LADDER_HR_FIELDS = [
52
+ ("Baseline", "snap_baseline_hr"),
53
+ ("After Zone", "snap_after_zone_hr"),
54
+ ("After Family Zone", "snap_after_family_zone_hr"),
55
+ ("After Arsenal", "snap_after_arsenal_hr"),
56
+ ("After Pulled Contact", "snap_after_pulled_contact_hr"),
57
+ ("After Env", "snap_after_env_hr"),
58
+ ("After Platoon", "snap_after_platoon_hr"),
59
+ ("After Trajectory", "snap_after_traj_hr"),
60
+ ("After Rolling", "snap_after_rolling_hr"),
61
+ ("After Opportunity", "snap_after_opportunity_hr"),
62
+ ("After Drift", "snap_after_drift_hr"),
63
+ ("Final (simulated)", "hr_prob"),
64
+ ]
65
+
66
+ _LADDER_HIT_FIELDS = [
67
+ ("Baseline", "snap_baseline_hit"),
68
+ ("After Zone", "snap_after_zone_hit"),
69
+ ("After Family Zone", "snap_after_family_zone_hit"),
70
+ ("After Arsenal", "snap_after_arsenal_hit"),
71
+ ("After Pulled Contact", "snap_after_pulled_contact_hit"),
72
+ ("After Env", "snap_after_env_hit"),
73
+ ("After Platoon", "snap_after_platoon_hit"),
74
+ ("After Trajectory", "snap_after_traj_hit"),
75
+ ("After Rolling", "snap_after_rolling_hit"),
76
+ ("After Opportunity", "snap_after_opportunity_hit"),
77
+ ("After Drift", "snap_after_drift_hit"),
78
+ ("Final (simulated)", "hit_prob"),
79
+ ]
80
+
81
+ _LADDER_TB2P_FIELDS = [
82
+ ("Baseline", "snap_baseline_tb2p"),
83
+ ("After Zone", "snap_after_zone_tb2p"),
84
+ ("After Family Zone", "snap_after_family_zone_tb2p"),
85
+ ("After Arsenal", "snap_after_arsenal_tb2p"),
86
+ ("After Pulled Contact", "snap_after_pulled_contact_tb2p"),
87
+ ("After Env", "snap_after_env_tb2p"),
88
+ ("After Platoon", "snap_after_platoon_tb2p"),
89
+ ("After Trajectory", "snap_after_traj_tb2p"),
90
+ ("After Rolling", "snap_after_rolling_tb2p"),
91
+ ("After Opportunity", "snap_after_opportunity_tb2p"),
92
+ ("After Drift", "snap_after_drift_tb2p"),
93
+ ("Final (simulated)", "tb2p_prob"),
94
+ ]
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Public entry point
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def render_debug(
103
+ statcast_df: pd.DataFrame,
104
+ odds_df: pd.DataFrame | None,
105
+ conn: Any,
106
+ live_games: pd.DataFrame,
107
+ scores_df: pd.DataFrame,
108
+ prepared_live_games_df: pd.DataFrame | None = None,
109
+ grade_outcomes_fn: Callable | None = None,
110
+ grade_props_fn: Callable | None = None,
111
+ fill_realized_fn: Callable | None = None,
112
+ ) -> None:
113
+ """
114
+ Full Debug Dashboard page.
115
+
116
+ Parameters
117
+ ----------
118
+ statcast_df : normalized statcast data (from load_statcast_recent)
119
+ odds_df : odds dataframe (may be None / empty)
120
+ conn : active DB connection
121
+ live_games : raw live games DataFrame
122
+ scores_df : scores feed DataFrame
123
+ prepared_live_games_df : optional pre-enriched live games (avoids re-enrichment)
124
+ grade_outcomes_fn : callable(scores_df) → grade final game outcomes
125
+ grade_props_fn : callable() → grade batter prop outcomes from audit
126
+ fill_realized_fn : callable(statcast_df) → fill realized batter outcomes
127
+ """
128
+ st.header("Debug Dashboard")
129
+ st.caption("Model diagnostics, adjustment ladders, signal attribution, and admin tools.")
130
+
131
+ # ------------------------------------------------------------------
132
+ # Resolve prepared live games
133
+ # ------------------------------------------------------------------
134
+ if prepared_live_games_df is None or prepared_live_games_df.empty:
135
+ prep_df = pd.DataFrame()
136
+ else:
137
+ prep_df = prepared_live_games_df
138
+
139
+ # ------------------------------------------------------------------
140
+ # SECTION 1 — Filters
141
+ # ------------------------------------------------------------------
142
+ st.subheader("Filters")
143
+ col_game, col_player, col_team, col_edge = st.columns(4)
144
+
145
+ with col_game:
146
+ game_options: list[str] = []
147
+ if not prep_df.empty and "away_team" in prep_df.columns and "home_team" in prep_df.columns:
148
+ game_options = [
149
+ f"{row.get('away_team','?')} @ {row.get('home_team','?')}"
150
+ for _, row in prep_df.iterrows()
151
+ ]
152
+ selected_games = st.multiselect("Games", options=game_options, default=[])
153
+
154
+ with col_player:
155
+ player_filter = st.text_input("Player filter", value="")
156
+
157
+ with col_team:
158
+ team_options: list[str] = []
159
+ if not prep_df.empty:
160
+ for col in ("away_team", "home_team"):
161
+ if col in prep_df.columns:
162
+ team_options += prep_df[col].dropna().astype(str).unique().tolist()
163
+ team_options = sorted(set(team_options))
164
+ selected_teams = st.multiselect("Teams", options=team_options, default=[])
165
+
166
+ with col_edge:
167
+ edge_threshold = st.slider("Min HR edge (%)", min_value=0, max_value=30, value=0, step=1)
168
+
169
+ # ------------------------------------------------------------------
170
+ # Run simulator for selected games
171
+ # ------------------------------------------------------------------
172
+ all_sim_rows: list[dict] = []
173
+
174
+ if not prep_df.empty:
175
+ for _, live_row in prep_df.iterrows():
176
+ game = live_row.to_dict()
177
+ game_label = f"{game.get('away_team','?')} @ {game.get('home_team','?')}"
178
+
179
+ # Apply game filter
180
+ if selected_games and game_label not in selected_games:
181
+ continue
182
+
183
+ try:
184
+ sim_rows = build_upcoming_simulated_rows(
185
+ game_row=game,
186
+ statcast_df=statcast_df,
187
+ weather_row=None,
188
+ )
189
+ except Exception as e:
190
+ all_sim_rows.append({"game": game_label, "batter_name": "ERROR", "debug_note": str(e)})
191
+ continue
192
+
193
+ for row in (sim_rows or []):
194
+ if isinstance(row, dict):
195
+ row["_game_label"] = game_label
196
+ all_sim_rows.append(row)
197
+
198
+ # Apply player / team filters
199
+ filtered_rows = all_sim_rows
200
+ if player_filter.strip():
201
+ pf = player_filter.strip().lower()
202
+ filtered_rows = [r for r in filtered_rows if pf in str(r.get("batter_name", "")).lower()]
203
+ if selected_teams:
204
+ filtered_rows = [
205
+ r for r in filtered_rows
206
+ if any(t in r.get("_game_label", "") for t in selected_teams)
207
+ ]
208
+
209
+ sim_df = pd.DataFrame(filtered_rows) if filtered_rows else pd.DataFrame()
210
+
211
+ # ------------------------------------------------------------------
212
+ # SECTION 2 — Model Snapshot Table
213
+ # ------------------------------------------------------------------
214
+ st.subheader("Model Snapshot")
215
+
216
+ if sim_df.empty:
217
+ st.info("No simulation rows available. Load live games and statcast data first.")
218
+ else:
219
+ snapshot_cols = [
220
+ c for c in [
221
+ "_game_label", "slot", "batter_name", "pitcher_name",
222
+ "hit_prob", "hr_prob", "tb2p_prob",
223
+ "fair_hr_odds", "book_hr_odds", "hr_edge",
224
+ "pa_multiplier", "pitcher_quality_score", "opportunity_mode",
225
+ "rolling_combined_form_score", "arsenal_drift_score",
226
+ ] if c in sim_df.columns
227
+ ]
228
+
229
+ display_df = sim_df[snapshot_cols].copy()
230
+
231
+ # Apply edge threshold filter
232
+ if edge_threshold > 0 and "hr_edge" in display_df.columns:
233
+ display_df = display_df[
234
+ pd.to_numeric(display_df["hr_edge"], errors="coerce").fillna(0) >= edge_threshold / 100.0
235
+ ]
236
+
237
+ st.dataframe(display_df, use_container_width=True, hide_index=True)
238
+
239
+ # ------------------------------------------------------------------
240
+ # SECTION 3 — Adjustment Ladder (per batter, exact checkpoints)
241
+ # ------------------------------------------------------------------
242
+ st.subheader("Adjustment Ladder (HR probability)")
243
+
244
+ if sim_df.empty:
245
+ st.info("No simulation data loaded.")
246
+ else:
247
+ for _, brow in sim_df.iterrows():
248
+ batter = str(brow.get("batter_name", "?"))
249
+ game = str(brow.get("_game_label", ""))
250
+ label = f"{batter} — {game}"
251
+
252
+ with st.expander(label, expanded=False):
253
+ ladder_metric = st.selectbox(
254
+ "Ladder metric",
255
+ options=["HR", "Hit", "TB2P"],
256
+ index=0,
257
+ key=f"ladder_metric_{batter}",
258
+ )
259
+ ladder_fields = (
260
+ _LADDER_HR_FIELDS if ladder_metric == "HR"
261
+ else _LADDER_HIT_FIELDS if ladder_metric == "Hit"
262
+ else _LADDER_TB2P_FIELDS
263
+ )
264
+
265
+ ladder_rows = []
266
+ prev_val: float | None = None
267
+
268
+ for layer_name, field in ladder_fields:
269
+ val = brow.get(field)
270
+ if val is not None:
271
+ try:
272
+ val_f = float(val)
273
+ except (TypeError, ValueError):
274
+ val_f = None
275
+ else:
276
+ val_f = None
277
+
278
+ delta_str = ""
279
+ if val_f is not None and prev_val is not None:
280
+ delta = val_f - prev_val
281
+ delta_str = f"{delta:+.4f}"
282
+ elif val_f is not None and prev_val is None:
283
+ delta_str = "—"
284
+
285
+ ladder_rows.append({
286
+ "Layer": layer_name,
287
+ "Delta": delta_str,
288
+ f"Cumulative {ladder_metric} prob": f"{val_f:.4f}" if val_f is not None else "—",
289
+ })
290
+ if val_f is not None:
291
+ prev_val = val_f
292
+
293
+ st.dataframe(
294
+ pd.DataFrame(ladder_rows),
295
+ use_container_width=True,
296
+ hide_index=True,
297
+ )
298
+
299
+ # Opportunity mode display
300
+ opp_mode = brow.get("opportunity_mode")
301
+ if opp_mode:
302
+ st.caption(
303
+ f"Opportunity mode: **{opp_mode}** | "
304
+ f"pa_multiplier={brow.get('pa_multiplier', '?')} | "
305
+ f"lineup_slot_used={brow.get('lineup_slot_used', 'None')} | "
306
+ f"team_total_used={brow.get('team_total_used', 'None')}"
307
+ )
308
+
309
+ # ------------------------------------------------------------------
310
+ # SECTION 4 — Full Feature Snapshot
311
+ # ------------------------------------------------------------------
312
+ st.subheader("Feature Snapshot (per batter)")
313
+
314
+ if not prep_df.empty and not sim_df.empty:
315
+ batter_names = sim_df["batter_name"].dropna().unique().tolist() if "batter_name" in sim_df.columns else []
316
+ selected_batter = st.selectbox("Select batter", options=["—"] + batter_names)
317
+
318
+ if selected_batter and selected_batter != "—":
319
+ from models.batter_baseline import build_batter_feature_row # local import to avoid circular
320
+ try:
321
+ batter_features = build_batter_feature_row(statcast_df, selected_batter)
322
+ except Exception:
323
+ batter_features = {}
324
+
325
+ # Get pitcher from first sim row for this batter
326
+ batter_rows = sim_df[sim_df["batter_name"] == selected_batter]
327
+ pitcher_name = batter_rows.iloc[0].get("pitcher_name", "") if not batter_rows.empty else ""
328
+
329
+ try:
330
+ pitcher_row = build_pitcher_feature_row(statcast_df, pitcher_name)
331
+ except Exception:
332
+ pitcher_row = {}
333
+
334
+ col_b, col_p = st.columns(2)
335
+ with col_b:
336
+ with st.expander("Batter features", expanded=True):
337
+ st.json({k: (v if v is not None else None) for k, v in batter_features.items()})
338
+ with col_p:
339
+ with st.expander("Pitcher row", expanded=True):
340
+ st.json({k: (v if v is not None else None) for k, v in pitcher_row.items()})
341
+
342
+ # ------------------------------------------------------------------
343
+ # SECTION 5 — Signal Attribution
344
+ # ------------------------------------------------------------------
345
+ st.subheader("Signal Attribution")
346
+
347
+ if not sim_df.empty:
348
+ tag_rows = []
349
+ for _, srow in sim_df.iterrows():
350
+ batter = srow.get("batter_name", "?")
351
+ game = srow.get("_game_label", "")
352
+ for tag_field, source in [
353
+ ("rolling_adjustment_reason_tags", "Rolling"),
354
+ ("arsenal_reason_tags", "Drift"),
355
+ ("reason_tags", "Pitcher Live"),
356
+ ]:
357
+ tags_val = srow.get(tag_field, "")
358
+ if isinstance(tags_val, list):
359
+ tags = tags_val
360
+ elif isinstance(tags_val, str) and tags_val:
361
+ tags = [t.strip() for t in tags_val.split("|") if t.strip()]
362
+ else:
363
+ tags = []
364
+ for tag in tags:
365
+ tag_rows.append({"Game": game, "Batter": batter, "Source": source, "Tag": tag})
366
+
367
+ if tag_rows:
368
+ st.dataframe(pd.DataFrame(tag_rows), use_container_width=True, hide_index=True)
369
+ else:
370
+ st.info("No active signal tags for filtered batters.")
371
+
372
+ # ------------------------------------------------------------------
373
+ # SECTION 6 — Admin Tools
374
+ # ------------------------------------------------------------------
375
+ st.subheader("Admin Tools")
376
+
377
+ col_a, col_b2, col_c = st.columns(3)
378
+ with col_a:
379
+ if grade_outcomes_fn is not None:
380
+ if st.button("Grade Final Game Outcomes", key="dbg_grade_final"):
381
+ grade_outcomes_fn(scores_df)
382
+ st.success("Grading attempted.")
383
+ else:
384
+ st.caption("grade_outcomes_fn not provided.")
385
+ with col_b2:
386
+ if grade_props_fn is not None:
387
+ if st.button("Build Batter Prop Outcomes", key="dbg_grade_props"):
388
+ grade_props_fn()
389
+ st.success("Prop outcome build attempted.")
390
+ else:
391
+ st.caption("grade_props_fn not provided.")
392
+ with col_c:
393
+ if fill_realized_fn is not None:
394
+ if st.button("Fill Realized Outcomes (Statcast)", key="dbg_fill_realized"):
395
+ fill_realized_fn(statcast_df)
396
+ st.success("Realized outcome fill attempted.")
397
+ else:
398
+ st.caption("fill_realized_fn not provided.")
399
+
400
+ st.caption(f"Current WBC date: {current_wbc_date_str()}")
401
+
402
+ # --- Simulator raw rows ---
403
+ with st.expander("Simulator raw rows", expanded=False):
404
+ if not prep_df.empty:
405
+ sim_debug_rows: list[dict] = []
406
+ for _, live_row in prep_df.iterrows():
407
+ game = live_row.to_dict()
408
+ try:
409
+ sim_rows = build_upcoming_simulated_rows(
410
+ game_row=game, statcast_df=statcast_df, weather_row=None,
411
+ )
412
+ except Exception as e:
413
+ sim_debug_rows.append({
414
+ "away_team": game.get("away_team"), "home_team": game.get("home_team"),
415
+ "slot": "ERROR", "batter_name": None, "pitcher_name": game.get("pitcher_name"),
416
+ "hit_prob": None, "hr_prob": None, "tb2p_prob": None, "debug_note": str(e),
417
+ })
418
+ continue
419
+ for row in (sim_rows or []):
420
+ if isinstance(row, dict):
421
+ sim_debug_rows.append({
422
+ "away_team": game.get("away_team"),
423
+ "home_team": game.get("home_team"),
424
+ "slot": row.get("slot"),
425
+ "batter_name": row.get("batter_name"),
426
+ "pitcher_name": row.get("pitcher_name"),
427
+ "hit_prob": row.get("hit_prob"),
428
+ "hr_prob": row.get("hr_prob"),
429
+ "tb2p_prob": row.get("tb2p_prob"),
430
+ "debug_note": None,
431
+ })
432
+ if sim_debug_rows:
433
+ st.dataframe(pd.DataFrame(sim_debug_rows), use_container_width=True, hide_index=True)
434
+ else:
435
+ st.info("No simulator rows available.")
436
+ else:
437
+ st.info("No prepared live games.")
438
+
439
+ # --- Batter prop outcomes ---
440
+ with st.expander("Batter prop outcomes", expanded=False):
441
+ batter_prop_outcomes_df = read_batter_prop_outcomes(conn)
442
+ st.write(f"Rows: {len(batter_prop_outcomes_df)}")
443
+ if not batter_prop_outcomes_df.empty:
444
+ display_cols = [c for c in [
445
+ "created_at", "graded_at", "game_pk", "slot", "batter_name",
446
+ "fair_hr_odds", "book_hr_odds", "adjusted_edge", "confidence",
447
+ "recommendation_tier", "realized_hit", "realized_hr", "realized_tb2p",
448
+ "grade_status", "outcome_source",
449
+ ] if c in batter_prop_outcomes_df.columns]
450
+ st.dataframe(batter_prop_outcomes_df[display_cols].tail(30), use_container_width=True, hide_index=True)
451
+
452
+ # --- Game outcomes ---
453
+ with st.expander("Game outcomes", expanded=False):
454
+ game_outcomes_df = read_game_outcomes(conn)
455
+ st.write(f"Rows: {len(game_outcomes_df)}")
456
+ if not game_outcomes_df.empty:
457
+ st.dataframe(game_outcomes_df.tail(20), use_container_width=True, hide_index=True)
458
+
459
+ # --- Recommendation logs ---
460
+ with st.expander("Recommendation logs", expanded=False):
461
+ rec_logs_df = read_table(conn, "recommendation_logs")
462
+ st.write(f"Rows: {len(rec_logs_df)}")
463
+ if not rec_logs_df.empty:
464
+ st.dataframe(rec_logs_df.tail(20), use_container_width=True, hide_index=True)
465
+
466
+ # --- Recommendation audit ---
467
+ with st.expander("Recommendation audit", expanded=False):
468
+ audit_df = read_recommendation_audit_view(conn)
469
+ st.write(f"Rows: {len(audit_df)}")
470
+ if not audit_df.empty:
471
+ audit_display_cols = [c for c in [
472
+ "created_at", "game_pk", "away_team", "home_team", "slot", "batter_name",
473
+ "fair_hr_odds", "book_hr_odds", "adjusted_edge", "confidence",
474
+ "recommendation_tier", "realized_hr", "graded_at", "outcome_source",
475
+ ] if c in audit_df.columns]
476
+ st.dataframe(audit_df[audit_display_cols].tail(20), use_container_width=True, hide_index=True)
477
+
478
+ # --- Batter prop audit ---
479
+ with st.expander("Batter prop audit", expanded=False):
480
+ batter_audit_df = read_batter_prop_audit_view(conn)
481
+ st.write(f"Rows: {len(batter_audit_df)}")
482
+ if not batter_audit_df.empty:
483
+ st.dataframe(batter_audit_df.tail(20), use_container_width=True, hide_index=True)
484
+
485
+ # ------------------------------------------------------------------
486
+ # SECTION 7 — Export
487
+ # ------------------------------------------------------------------
488
+ st.subheader("Export")
489
+
490
+ if not sim_df.empty:
491
+ col_csv, col_json = st.columns(2)
492
+ with col_csv:
493
+ csv_data = sim_df.to_csv(index=False).encode("utf-8")
494
+ st.download_button(
495
+ label="Download CSV",
496
+ data=csv_data,
497
+ file_name="debug_sim_rows.csv",
498
+ mime="text/csv",
499
+ key="dbg_dl_csv",
500
+ )
501
+ with col_json:
502
+ json_data = json.dumps(
503
+ [
504
+ {k: (v.item() if hasattr(v, "item") else v) for k, v in row.items()}
505
+ for row in filtered_rows
506
+ ],
507
+ default=str,
508
+ ).encode("utf-8")
509
+ st.download_button(
510
+ label="Download JSON",
511
+ data=json_data,
512
+ file_name="debug_sim_rows.json",
513
+ mime="application/json",
514
+ key="dbg_dl_json",
515
+ )
516
+ else:
517
+ st.info("No data to export.")
518
+
519
+ # ------------------------------------------------------------------
520
+ # SECTION 8 — Audit Metadata (placeholders)
521
+ # ------------------------------------------------------------------
522
+ st.subheader("Audit Metadata")
523
+ st.json({
524
+ "model_version": "Batch 13",
525
+ "feature_version": "rolling_form+opportunity+drift",
526
+ "odds_snapshot_id": None,
527
+ "data_timestamp": str(pd.Timestamp.now()),
528
+ })
529
+
530
+ # ------------------------------------------------------------------
531
+ # SECTION 9 — Model Evaluation Metrics (CLV / ERE)
532
+ # ------------------------------------------------------------------
533
+ st.subheader("Model Evaluation Metrics")
534
+
535
+ try:
536
+ audit_df = read_recommendation_audit_view(conn)
537
+ except Exception:
538
+ audit_df = pd.DataFrame()
539
+
540
+ eval_tables = [
541
+ ("HR Probability Calibration", build_hr_calibration_table(audit_df)),
542
+ ("Edge Bucket Performance", build_edge_bucket_table(audit_df)),
543
+ ("Confidence Bucket", build_confidence_table(audit_df)),
544
+ ("Recommendation Tier", build_tier_performance_table(audit_df)),
545
+ ("Global ERE", build_ere_table(audit_df)),
546
+ ("ERE by Edge Bucket", build_ere_by_edge_bucket_table(audit_df)),
547
+ ("ERE by Confidence", build_ere_by_confidence_bucket_table(audit_df)),
548
+ ("ERE by Tier", build_ere_by_tier_table(audit_df)),
549
+ ("CLV Summary", build_clv_table(audit_df)),
550
+ ("CLV by Tier", build_clv_by_tier_table(audit_df)),
551
+ ]
552
+
553
+ for title, tbl in eval_tables:
554
+ if not tbl.empty:
555
+ st.write(title)
556
+ st.dataframe(tbl, use_container_width=True, hide_index=True)
557
+
558
+ # Batter-specific metrics
559
+ try:
560
+ batter_audit_df_eval = read_batter_prop_audit_view(conn)
561
+ for title, fn in [
562
+ ("Batter HR Rate by Tier", build_batter_hr_tier_table),
563
+ ("Batter HR Rate by Confidence", build_batter_hr_confidence_table),
564
+ ("Batter HR Rate by Edge", build_batter_hr_edge_table),
565
+ ]:
566
+ tbl = fn(batter_audit_df_eval)
567
+ if not tbl.empty:
568
+ st.write(title)
569
+ st.dataframe(tbl, use_container_width=True, hide_index=True)
570
+ except Exception:
571
+ pass
572
+
573
+ # Scores raw status (diagnostic)
574
+ if not scores_df.empty and "status" in scores_df.columns:
575
+ with st.expander("Raw score statuses", expanded=False):
576
+ st.write(sorted(scores_df["status"].fillna("").astype(str).unique().tolist()))
visualization/feedback_page.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import streamlit as st
5
+
6
+ from database.db import (
7
+ insert_feedback_submission,
8
+ read_feedback_submissions,
9
+ )
10
+
11
+
12
+ def render_feedback(conn: Any) -> None:
13
+ st.header("Feedback")
14
+ st.caption("Submit feedback or suggestions. All submissions are stored together.")
15
+
16
+ message = st.text_area(
17
+ "Your feedback", height=120, placeholder="Type your feedback here..."
18
+ )
19
+
20
+ if st.button("Submit"):
21
+ trimmed = (message or "").strip()
22
+ if not trimmed:
23
+ st.warning("Feedback cannot be empty.")
24
+ else:
25
+ try:
26
+ insert_feedback_submission(conn, trimmed)
27
+ st.success("Feedback submitted. Thank you!")
28
+ except Exception as e:
29
+ st.error(f"Failed to submit feedback: {e}")
30
+
31
+ st.divider()
32
+ st.subheader("Submitted Feedback")
33
+
34
+ try:
35
+ df = read_feedback_submissions(conn)
36
+ except Exception as e:
37
+ st.error(f"Could not load feedback: {e}")
38
+ return
39
+
40
+ if df.empty:
41
+ st.info("No feedback submitted yet.")
42
+ return
43
+
44
+ for _, row in df.iterrows():
45
+ st.markdown(f"**{row.get('created_at', '')}**")
46
+ st.write(row.get("message", ""))
47
+ st.divider()