Spaces:
Sleeping
Sleeping
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 +23 -1030
- database/db.py +35 -0
- models/arsenal_drift_model.py +173 -0
- models/live_fair_simulator_v3.py +246 -2
- models/opportunity_model.py +127 -0
- models/pitcher_adjustment.py +23 -0
- models/rolling_form_model.py +660 -0
- visualization/debug_page.py +576 -0
- visualization/feedback_page.py +47 -0
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("⚾
|
| 508 |
st.caption(
|
| 509 |
-
"
|
| 510 |
-
"
|
|
|
|
| 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 |
-
|
| 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()
|