Spaces:
Running
Running
| from __future__ import annotations | |
| import os | |
| import sys | |
| import unittest | |
| from unittest.mock import patch | |
| import pandas as pd | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) | |
| from analytics.props_mapper import map_hr_props_to_model, map_props_to_models, map_strikeout_props_to_model | |
| class TestPropsMapper(unittest.TestCase): | |
| def test_props_mapper_exposes_shared_engine_fields(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "player_name": "slugger sam", | |
| "player_name_raw": "Slugger Sam", | |
| "odds_american": 425, | |
| "sportsbook": "DraftKings", | |
| "away_team": "Away", | |
| "home_team": "Home", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Slugger Sam", | |
| "baseline_mode": "blended", | |
| "prior_sample_size": 260, | |
| "season_2026_sample_size": 24, | |
| "prior_weight": 0.64, | |
| "season_2026_weight": 0.36, | |
| "baseline_driver": "prior_led", | |
| "rolling_overlay_active": True, | |
| } | |
| ] | |
| ) | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "adjusted_hr_prob": 0.18, | |
| "raw_hr_prob": 0.18, | |
| "calibrated_hr_prob": 0.17, | |
| "baseline_hr_prob": 0.14, | |
| "pregame_hr_prob": 0.17, | |
| "mode": "pregame", | |
| "applied_layers": "pitcher|environment|opportunity", | |
| "skipped_layers": "live_pitch_telemetry|bullpen_transition", | |
| "confidence_score": 82.0, | |
| "confidence_bucket": "high", | |
| "confidence_reasons": ["Good batter sample"], | |
| "opportunity_hr_adjustment": 0.004, | |
| "expected_pa": 4.45, | |
| "pa_multiplier": 1.04, | |
| "lineup_slot_used": 3, | |
| "lineup_slot_source": "projected", | |
| "team_total_used": 4.8, | |
| "team_total_source": "projected", | |
| "opportunity_mode": "slot_and_total", | |
| "opportunity_reason": "pitcher_quality_missing", | |
| "pregame_pitcher_context_adj": 0.01, | |
| "pregame_park_context_adj": 0.01, | |
| "pregame_weather_context_adj": 0.0, | |
| "pregame_context_applied": True, | |
| "pitcher_hr_adjustment": 0.01, | |
| "trend_hr_adjustment": 0.0, | |
| "zone_hr_adjustment": 0.0, | |
| "family_zone_hr_adjustment": 0.0, | |
| "arsenal_hr_adjustment": 0.0, | |
| "pulled_contact_hr_adjustment": 0.0, | |
| "env_hr_adjustment": 0.01, | |
| "park_hr_adjustment": 0.01, | |
| "weather_hr_adjustment": 0.0, | |
| "platoon_hr_adjustment": 0.0, | |
| "trajectory_hr_adjustment": 0.0, | |
| "rolling_hr_adjustment": 0.0, | |
| "pitcher_reliability": 0.75, | |
| "trend_reliability": 0.60, | |
| "zone_reliability": 0.55, | |
| "family_zone_reliability": 0.55, | |
| "arsenal_reliability": 0.55, | |
| "pulled_contact_reliability": 0.65, | |
| "environment_reliability": 0.82, | |
| "trajectory_reliability": 0.70, | |
| "rolling_reliability": 0.66, | |
| "opportunity_reliability": 0.82, | |
| "matchup_platoon_multiplier": 1.0, | |
| "matchup_platoon_reason": "unknown", | |
| }, | |
| ): | |
| result = map_hr_props_to_model(props_df, statcast_df) | |
| self.assertEqual(len(result), 1) | |
| row = result.iloc[0] | |
| self.assertEqual(row["model_hr_prob_source"], "shared_pregame_engine") | |
| self.assertEqual(row["probability_mode"], "pregame") | |
| self.assertAlmostEqual(float(row["baseline_hr_prob"]), 0.14, places=6) | |
| self.assertAlmostEqual(float(row["pregame_hr_prob"]), 0.17, places=6) | |
| self.assertAlmostEqual(float(row["raw_hr_prob"]), 0.18, places=6) | |
| self.assertAlmostEqual(float(row["model_hr_prob"]), 0.17, places=6) | |
| self.assertTrue(pd.notna(row["bet_ev"])) | |
| self.assertEqual(row["confidence_bucket"], "high") | |
| self.assertTrue(str(row["model_voice"]).strip()) | |
| self.assertTrue(str(row["model_voice_primary_reason"]).strip()) | |
| self.assertIn("final_recommendation_score", result.columns) | |
| self.assertTrue(pd.notna(row["edge"])) | |
| self.assertEqual(row["baseline_mode"], "blended") | |
| self.assertEqual(int(row["prior_sample_size"]), 260) | |
| self.assertEqual(int(row["season_2026_sample_size"]), 24) | |
| self.assertAlmostEqual(float(row["prior_weight"]), 0.64, places=6) | |
| self.assertAlmostEqual(float(row["season_2026_weight"]), 0.36, places=6) | |
| self.assertEqual(row["baseline_driver"], "prior_led") | |
| self.assertTrue(bool(row["rolling_overlay_active"])) | |
| self.assertTrue(bool(row["expected_modeled_hr_row"])) | |
| self.assertTrue(bool(row["has_model_probability"])) | |
| self.assertTrue(bool(row["has_modeled_edge"])) | |
| self.assertEqual(row["model_probability_status"], "modeled_ok") | |
| self.assertFalse(bool(row["projected_starter_available"])) | |
| self.assertEqual(row["projected_starter_match_status"], "projected_starter_unavailable") | |
| def test_props_mapper_marks_missing_probability_reason_for_modeled_1plus_hr(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "market_family": "hr", | |
| "threshold": 1, | |
| "is_modeled": True, | |
| "player_name": "slugger sam", | |
| "player_name_raw": "Slugger Sam", | |
| "odds_american": 425, | |
| "sportsbook": "DraftKings", | |
| "away_team": "Away", | |
| "home_team": "Home", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Slugger Sam", | |
| "baseline_mode": "blended", | |
| } | |
| ] | |
| ) | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "baseline_hr_prob": 0.12, | |
| "raw_hr_prob": None, | |
| "calibrated_hr_prob": None, | |
| "pregame_hr_prob": None, | |
| "mode": "pregame", | |
| "applied_layers": "", | |
| "skipped_layers": "", | |
| "confidence_score": None, | |
| "confidence_bucket": None, | |
| "confidence_reasons": [], | |
| "pitcher_resolution_status": "pitcher_missing", | |
| "model_voice_reason_candidates": [], | |
| "model_voice_tags": [], | |
| }, | |
| ): | |
| result = map_hr_props_to_model(props_df, statcast_df) | |
| self.assertEqual(len(result), 1) | |
| row = result.iloc[0] | |
| self.assertTrue(bool(row["expected_modeled_hr_row"])) | |
| self.assertFalse(bool(row["has_model_probability"])) | |
| self.assertFalse(bool(row["has_modeled_edge"])) | |
| self.assertEqual(row["model_probability_status"], "missing_pitcher_context") | |
| def test_props_mapper_uses_probable_starters_when_pitcher_missing(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "player_name": "slugger sam", | |
| "player_name_raw": "Slugger Sam", | |
| "odds_american": 425, | |
| "sportsbook": "DraftKings", | |
| "away_team": "Away Team", | |
| "home_team": "Home Team", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Slugger Sam", | |
| "inning_topbot": "Top", | |
| "away_team": "Away Team", | |
| "home_team": "Home Team", | |
| } | |
| ] | |
| ) | |
| probable_starters = { | |
| ("away team", "home team"): { | |
| "home_pitcher": "Home Starter", | |
| "away_pitcher": "Away Starter", | |
| "home_pitcher_source": "oddsapi_pitcher_strikeouts_two_candidate_match", | |
| "away_pitcher_source": "statsapi_probable_pitcher", | |
| "starter_cache_source": "statsapi_plus_oddsapi_fallback", | |
| "fallback_used": True, | |
| } | |
| } | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "adjusted_hr_prob": 0.18, | |
| "raw_hr_prob": 0.18, | |
| "calibrated_hr_prob": 0.18, | |
| "baseline_hr_prob": 0.14, | |
| "pregame_hr_prob": 0.18, | |
| "mode": "pregame", | |
| "applied_layers": "pitcher", | |
| "skipped_layers": "", | |
| "confidence_score": 78.0, | |
| "confidence_bucket": "medium", | |
| "confidence_reasons": [], | |
| "opportunity_hr_adjustment": 0.0, | |
| "expected_pa": 4.3, | |
| "pa_multiplier": 1.0, | |
| "lineup_slot_used": None, | |
| "lineup_slot_source": "unknown", | |
| "team_total_used": None, | |
| "team_total_source": "unknown", | |
| "opportunity_mode": "baseline_only", | |
| "opportunity_reason": "pitcher_quality_missing", | |
| "pregame_pitcher_context_adj": 0.01, | |
| "pregame_park_context_adj": 0.0, | |
| "pregame_weather_context_adj": 0.0, | |
| "pregame_context_applied": True, | |
| "pitcher_hr_adjustment": 0.01, | |
| "trend_hr_adjustment": 0.0, | |
| "zone_hr_adjustment": 0.0, | |
| "family_zone_hr_adjustment": 0.0, | |
| "arsenal_hr_adjustment": 0.0, | |
| "pulled_contact_hr_adjustment": 0.0, | |
| "env_hr_adjustment": 0.0, | |
| "park_hr_adjustment": 0.0, | |
| "weather_hr_adjustment": 0.0, | |
| "platoon_hr_adjustment": 0.0, | |
| "trajectory_hr_adjustment": 0.0, | |
| "rolling_hr_adjustment": 0.0, | |
| "pitcher_reliability": 0.75, | |
| "trend_reliability": 0.60, | |
| "zone_reliability": 0.55, | |
| "family_zone_reliability": 0.55, | |
| "arsenal_reliability": 0.55, | |
| "pulled_contact_reliability": 0.65, | |
| "environment_reliability": 0.82, | |
| "trajectory_reliability": 0.70, | |
| "rolling_reliability": 0.66, | |
| "opportunity_reliability": 0.0, | |
| "matchup_platoon_multiplier": 1.0, | |
| "matchup_platoon_reason": "unknown", | |
| }, | |
| ) as mocked_build: | |
| result = map_hr_props_to_model( | |
| props_df, | |
| statcast_df, | |
| probable_starters=probable_starters, | |
| ) | |
| self.assertEqual(len(result), 1) | |
| self.assertEqual(result.iloc[0]["resolved_pitcher_name"], "Home Starter") | |
| self.assertEqual(result.iloc[0]["projected_home_pitcher"], "Home Starter") | |
| self.assertEqual(result.iloc[0]["projected_away_pitcher"], "Away Starter") | |
| self.assertTrue(bool(result.iloc[0]["projected_starter_available"])) | |
| self.assertEqual(result.iloc[0]["projected_home_pitcher_source"], "oddsapi_pitcher_strikeouts_two_candidate_match") | |
| self.assertEqual(result.iloc[0]["projected_away_pitcher_source"], "statsapi_probable_pitcher") | |
| self.assertEqual(result.iloc[0]["starter_cache_source"], "statsapi_plus_oddsapi_fallback") | |
| self.assertTrue(bool(result.iloc[0]["fallback_used"])) | |
| self.assertEqual(result.iloc[0]["projected_starter_match_status"], "matched_projected_home") | |
| self.assertEqual(mocked_build.call_args.kwargs["pitcher_name"], "Home Starter") | |
| def test_props_mapper_uses_statcast_inferred_lineup_slot_when_available(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "player_name": "aaron judge", | |
| "player_name_raw": "Aaron Judge", | |
| "odds_american": 245, | |
| "sportsbook": "Caesars", | |
| "away_team": "New York Yankees", | |
| "home_team": "Boston Red Sox", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| "pitcher_name": "Lefty Lou", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Aaron Judge", | |
| "source_season": 2026, | |
| "inning_topbot": "Top", | |
| "away_team": "New York Yankees", | |
| "home_team": "Boston Red Sox", | |
| "lineup_slot": 3, | |
| } | |
| ] | |
| ) | |
| pitcher_statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Lefty Lou", | |
| "p_throws": "L", | |
| } | |
| ] | |
| ) | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "adjusted_hr_prob": 0.18, | |
| "raw_hr_prob": 0.18, | |
| "calibrated_hr_prob": 0.18, | |
| "baseline_hr_prob": 0.14, | |
| "pregame_hr_prob": 0.18, | |
| "mode": "pregame", | |
| "applied_layers": "opportunity", | |
| "skipped_layers": "", | |
| "confidence_score": 75.0, | |
| "confidence_bucket": "medium", | |
| "confidence_reasons": [], | |
| "opportunity_hr_adjustment": 0.003, | |
| "expected_pa": 4.5, | |
| "pa_multiplier": 1.03, | |
| "lineup_slot_used": 3, | |
| "lineup_slot_source": "projected", | |
| "team_total_used": None, | |
| "team_total_source": "unknown", | |
| "opportunity_mode": "slot_only", | |
| "opportunity_reason": "pitcher_quality_missing", | |
| "pregame_pitcher_context_adj": 0.0, | |
| "pregame_park_context_adj": 0.0, | |
| "pregame_weather_context_adj": 0.0, | |
| "pregame_context_applied": True, | |
| "pitcher_hr_adjustment": 0.0, | |
| "trend_hr_adjustment": 0.0, | |
| "zone_hr_adjustment": 0.0, | |
| "family_zone_hr_adjustment": 0.0, | |
| "arsenal_hr_adjustment": 0.0, | |
| "pulled_contact_hr_adjustment": 0.0, | |
| "env_hr_adjustment": 0.0, | |
| "park_hr_adjustment": 0.0, | |
| "weather_hr_adjustment": 0.0, | |
| "platoon_hr_adjustment": 0.0, | |
| "trajectory_hr_adjustment": 0.0, | |
| "rolling_hr_adjustment": 0.0, | |
| "pitcher_reliability": 0.0, | |
| "trend_reliability": 0.0, | |
| "zone_reliability": 0.0, | |
| "family_zone_reliability": 0.0, | |
| "arsenal_reliability": 0.0, | |
| "pulled_contact_reliability": 0.0, | |
| "environment_reliability": 0.0, | |
| "trajectory_reliability": 0.0, | |
| "rolling_reliability": 0.0, | |
| "opportunity_reliability": 0.82, | |
| "matchup_platoon_multiplier": 1.0, | |
| "matchup_platoon_reason": "unknown", | |
| }, | |
| ) as mocked_build: | |
| result = map_hr_props_to_model( | |
| props_df, | |
| statcast_df, | |
| pitcher_statcast_df=pitcher_statcast_df, | |
| ) | |
| self.assertEqual(len(result), 1) | |
| self.assertEqual(result.iloc[0]["lineup_slot_source"], "projected") | |
| self.assertEqual(mocked_build.call_args.kwargs["game_row"]["lineup_slot"], 3) | |
| def test_props_mapper_prefers_row_matchup_context_for_team_and_pitcher_resolution(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "player_name": "luis arraez", | |
| "player_name_raw": "Luis Arraez", | |
| "odds_american": 1300, | |
| "sportsbook": "Caesars", | |
| "away_team": "Away Club", | |
| "home_team": "Home Club", | |
| "team": "Away Club", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Luis Arraez", | |
| "inning_topbot": "Bottom", | |
| "away_team": "Old Away", | |
| "home_team": "Old Home", | |
| } | |
| ] | |
| ) | |
| probable_starters = { | |
| ("away club", "home club"): { | |
| "home_pitcher": "Home Starter", | |
| "away_pitcher": "Away Starter", | |
| } | |
| } | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "adjusted_hr_prob": 0.03, | |
| "raw_hr_prob": 0.03, | |
| "calibrated_hr_prob": 0.03, | |
| "baseline_hr_prob": 0.02, | |
| "pregame_hr_prob": 0.03, | |
| "mode": "pregame", | |
| "applied_layers": "pitcher", | |
| "skipped_layers": "", | |
| "confidence_score": 65.0, | |
| "confidence_bucket": "medium", | |
| "confidence_reasons": [], | |
| "opportunity_hr_adjustment": 0.0, | |
| "expected_pa": 4.4, | |
| "pa_multiplier": 1.0, | |
| "lineup_slot_used": 1, | |
| "lineup_slot_source": "unknown", | |
| "team_total_used": None, | |
| "team_total_source": "unknown", | |
| "opportunity_mode": "slot_only", | |
| "opportunity_reason": None, | |
| "pregame_pitcher_context_adj": 0.01, | |
| "pregame_park_context_adj": 0.0, | |
| "pregame_weather_context_adj": 0.0, | |
| "pregame_context_applied": True, | |
| "pitcher_hr_adjustment": 0.01, | |
| "trend_hr_adjustment": 0.0, | |
| "zone_hr_adjustment": 0.0, | |
| "family_zone_hr_adjustment": 0.0, | |
| "arsenal_hr_adjustment": 0.0, | |
| "pulled_contact_hr_adjustment": 0.0, | |
| "env_hr_adjustment": 0.0, | |
| "park_hr_adjustment": 0.0, | |
| "weather_hr_adjustment": 0.0, | |
| "platoon_hr_adjustment": 0.0, | |
| "trajectory_hr_adjustment": 0.0, | |
| "rolling_hr_adjustment": 0.0, | |
| "pitcher_reliability": 0.80, | |
| "pitcher_resolution_status": "resolved", | |
| "trend_reliability": 0.0, | |
| "zone_reliability": 0.0, | |
| "zone_status": "missing_batter_zone_profile", | |
| "zone_store_sample_size": 0, | |
| "family_zone_reliability": 0.0, | |
| "family_zone_status": "missing_batter_family_zone_profile", | |
| "family_zone_batter_sample_size": 0, | |
| "family_zone_pitcher_sample_size": 0, | |
| "arsenal_reliability": 0.0, | |
| "arsenal_status": "missing_batter_arsenal_profile", | |
| "arsenal_batter_sample_size": 0, | |
| "arsenal_pitcher_sample_size": 0, | |
| "pulled_contact_reliability": 0.0, | |
| "environment_reliability": 0.0, | |
| "trajectory_reliability": 0.0, | |
| "rolling_reliability": 0.0, | |
| "opportunity_reliability": 0.0, | |
| "matchup_platoon_multiplier": 1.0, | |
| "matchup_platoon_reason": "unknown", | |
| "model_voice_reason_candidates": [], | |
| "model_voice_tags": [], | |
| "reason_candidate_count": 0, | |
| }, | |
| ) as mocked_build: | |
| result = map_hr_props_to_model( | |
| props_df, | |
| statcast_df, | |
| probable_starters=probable_starters, | |
| ) | |
| self.assertEqual(len(result), 1) | |
| row = result.iloc[0] | |
| self.assertEqual(row["batter_team"], "Away Club") | |
| self.assertEqual(row["batter_team_source"], "row_team") | |
| self.assertEqual(row["resolved_pitcher_name"], "Home Starter") | |
| self.assertEqual(row["resolved_pitcher_source"], "probable_starters_matchup") | |
| self.assertEqual(mocked_build.call_args.kwargs["pitcher_name"], "Home Starter") | |
| def test_props_mapper_passes_model_voice_candidates_through(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "player_name": "slugger sam", | |
| "player_name_raw": "Slugger Sam", | |
| "odds_american": 425, | |
| "sportsbook": "DraftKings", | |
| "away_team": "Away", | |
| "home_team": "Home", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame([{"player_name": "Slugger Sam"}]) | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "adjusted_hr_prob": 0.18, | |
| "raw_hr_prob": 0.18, | |
| "calibrated_hr_prob": 0.17, | |
| "baseline_hr_prob": 0.14, | |
| "pregame_hr_prob": 0.17, | |
| "mode": "pregame", | |
| "applied_layers": "arsenal|pitcher", | |
| "skipped_layers": "", | |
| "confidence_score": 82.0, | |
| "confidence_bucket": "high", | |
| "confidence_reasons": ["Good batter sample"], | |
| "opportunity_hr_adjustment": 0.0, | |
| "expected_pa": 4.4, | |
| "pa_multiplier": 1.0, | |
| "lineup_slot_used": None, | |
| "lineup_slot_source": "unknown", | |
| "team_total_used": None, | |
| "team_total_source": "unknown", | |
| "opportunity_mode": None, | |
| "opportunity_reason": None, | |
| "pregame_pitcher_context_adj": 0.01, | |
| "pregame_park_context_adj": 0.0, | |
| "pregame_weather_context_adj": 0.0, | |
| "pregame_context_applied": True, | |
| "pitcher_hr_adjustment": 0.01, | |
| "trend_hr_adjustment": 0.0, | |
| "zone_hr_adjustment": 0.0, | |
| "family_zone_hr_adjustment": 0.0, | |
| "arsenal_hr_adjustment": 0.012, | |
| "pulled_contact_hr_adjustment": 0.0, | |
| "env_hr_adjustment": 0.0, | |
| "park_hr_adjustment": 0.0, | |
| "weather_hr_adjustment": 0.0, | |
| "platoon_hr_adjustment": 0.0, | |
| "trajectory_hr_adjustment": 0.0, | |
| "rolling_hr_adjustment": 0.0, | |
| "pitcher_reliability": 0.75, | |
| "pitcher_resolution_status": "resolved", | |
| "trend_reliability": 0.60, | |
| "zone_reliability": 0.55, | |
| "zone_status": "available_zero_effect", | |
| "zone_store_sample_size": 15, | |
| "family_zone_reliability": 0.55, | |
| "family_zone_status": "available_zero_effect", | |
| "family_zone_batter_sample_size": 20, | |
| "family_zone_pitcher_sample_size": 18, | |
| "arsenal_reliability": 0.55, | |
| "arsenal_status": "applied", | |
| "arsenal_batter_sample_size": 40, | |
| "arsenal_pitcher_sample_size": 55, | |
| "pulled_contact_reliability": 0.65, | |
| "environment_reliability": 0.82, | |
| "trajectory_reliability": 0.70, | |
| "rolling_reliability": 0.66, | |
| "opportunity_reliability": 0.82, | |
| "matchup_platoon_multiplier": 1.0, | |
| "matchup_platoon_reason": "unknown", | |
| "model_voice_reason_candidates": [ | |
| { | |
| "category": "arsenal", | |
| "direction": "supportive", | |
| "magnitude": 0.012, | |
| "signed_magnitude": 0.012, | |
| "template_key": "arsenal_favorable", | |
| "template_inputs": {}, | |
| } | |
| ], | |
| "model_voice_tags": ["arsenal_favorable"], | |
| "reason_candidate_count": 1, | |
| }, | |
| ): | |
| result = map_hr_props_to_model(props_df, statcast_df) | |
| self.assertEqual(len(result), 1) | |
| row = result.iloc[0] | |
| self.assertEqual(row["reason_candidate_count"], 1) | |
| self.assertEqual(row["model_voice_tags"], ["arsenal_favorable"]) | |
| self.assertIn("arsenal", str(row["model_voice"]).lower()) | |
| def test_props_mapper_matches_pitcher_aliases_across_starters_and_baseline(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "player_name": "alek thomas", | |
| "player_name_raw": "Alek Thomas", | |
| "odds_american": 450, | |
| "sportsbook": "Caesars", | |
| "away_team": "Arizona Diamondbacks", | |
| "home_team": "Los Angeles Dodgers", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Alek Thomas", | |
| "inning_topbot": "Top", | |
| "away_team": "Arizona Diamondbacks", | |
| "home_team": "Los Angeles Dodgers", | |
| "baseline_mode": "blended", | |
| } | |
| ] | |
| ) | |
| pitcher_statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Rodriguez, Eduardo", | |
| "baseline_mode": "prior_only", | |
| "prior_sample_size": 5000, | |
| "season_2026_sample_size": 0, | |
| "prior_weight": 1.0, | |
| "season_2026_weight": 0.0, | |
| "baseline_driver": "prior_led", | |
| "rolling_overlay_active": False, | |
| "p_throws": "L", | |
| } | |
| ] | |
| ) | |
| probable_starters = { | |
| ("arizona diamondbacks", "los angeles dodgers"): { | |
| "home_pitcher": "Eduardo Rodriguez", | |
| "away_pitcher": "Someone Else", | |
| } | |
| } | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "adjusted_hr_prob": 0.04, | |
| "raw_hr_prob": 0.04, | |
| "calibrated_hr_prob": 0.04, | |
| "baseline_hr_prob": 0.03, | |
| "pregame_hr_prob": 0.04, | |
| "mode": "pregame", | |
| "applied_layers": "pitcher", | |
| "skipped_layers": "", | |
| "confidence_score": 70.0, | |
| "confidence_bucket": "medium", | |
| "confidence_reasons": [], | |
| "opportunity_hr_adjustment": 0.0, | |
| "expected_pa": 4.3, | |
| "pa_multiplier": 1.0, | |
| "lineup_slot_used": None, | |
| "lineup_slot_source": "unknown", | |
| "team_total_used": None, | |
| "team_total_source": "unknown", | |
| "opportunity_mode": None, | |
| "opportunity_reason": None, | |
| "pregame_pitcher_context_adj": 0.0, | |
| "pregame_park_context_adj": 0.0, | |
| "pregame_weather_context_adj": 0.0, | |
| "pregame_context_applied": True, | |
| "pitcher_hr_adjustment": 0.0, | |
| "trend_hr_adjustment": 0.0, | |
| "zone_hr_adjustment": 0.0, | |
| "family_zone_hr_adjustment": 0.0, | |
| "arsenal_hr_adjustment": 0.0, | |
| "pulled_contact_hr_adjustment": 0.0, | |
| "env_hr_adjustment": 0.0, | |
| "park_hr_adjustment": 0.0, | |
| "weather_hr_adjustment": 0.0, | |
| "platoon_hr_adjustment": 0.0, | |
| "trajectory_hr_adjustment": 0.0, | |
| "rolling_hr_adjustment": 0.0, | |
| "pitcher_reliability": 0.8, | |
| "pitcher_resolution_status": "resolved", | |
| "trend_reliability": 0.0, | |
| "zone_reliability": 0.0, | |
| "family_zone_reliability": 0.0, | |
| "arsenal_reliability": 0.0, | |
| "pulled_contact_reliability": 0.0, | |
| "environment_reliability": 0.0, | |
| "trajectory_reliability": 0.0, | |
| "rolling_reliability": 0.0, | |
| "opportunity_reliability": 0.0, | |
| "matchup_platoon_multiplier": 1.0, | |
| "matchup_platoon_reason": "unknown", | |
| }, | |
| ): | |
| result = map_hr_props_to_model( | |
| props_df, | |
| statcast_df, | |
| pitcher_statcast_df=pitcher_statcast_df, | |
| probable_starters=probable_starters, | |
| ) | |
| row = result.iloc[0] | |
| self.assertEqual(row["resolved_pitcher_name"], "Eduardo Rodriguez") | |
| self.assertEqual(row["pitcher_baseline_mode"], "prior_only") | |
| self.assertEqual(row["projected_starter_match_status"], "matched_projected_home") | |
| def test_props_mapper_uses_current_season_statcast_team_when_row_team_is_missing(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "player_name": "andrew benintendi", | |
| "player_name_raw": "Andrew Benintendi", | |
| "odds_american": 500, | |
| "sportsbook": "BetMGM", | |
| "away_team": "Chicago White Sox", | |
| "home_team": "Toronto Blue Jays", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame( | |
| [ | |
| { | |
| "player_name": "Andrew Benintendi", | |
| "inning_topbot": "Top", | |
| "away_team": "Chicago White Sox", | |
| "home_team": "Toronto Blue Jays", | |
| "source_season": 2026, | |
| } | |
| ] | |
| ) | |
| probable_starters = { | |
| ("chicago white sox", "toronto blue jays"): { | |
| "home_pitcher": "Anthony Kay", | |
| "away_pitcher": "Brandon Sproat", | |
| } | |
| } | |
| with patch( | |
| "analytics.props_mapper.build_hr_probability_result", | |
| return_value={ | |
| "adjusted_hr_prob": 0.03, | |
| "raw_hr_prob": 0.03, | |
| "calibrated_hr_prob": 0.03, | |
| "baseline_hr_prob": 0.02, | |
| "pregame_hr_prob": 0.03, | |
| "mode": "pregame", | |
| "applied_layers": "pitcher", | |
| "skipped_layers": "", | |
| "confidence_score": 68.0, | |
| "confidence_bucket": "medium", | |
| "confidence_reasons": [], | |
| "opportunity_hr_adjustment": 0.0, | |
| "expected_pa": 4.2, | |
| "pa_multiplier": 1.0, | |
| "lineup_slot_used": None, | |
| "lineup_slot_source": "unknown", | |
| "team_total_used": None, | |
| "team_total_source": "unknown", | |
| "opportunity_mode": None, | |
| "opportunity_reason": None, | |
| "pregame_pitcher_context_adj": 0.0, | |
| "pregame_park_context_adj": 0.0, | |
| "pregame_weather_context_adj": 0.0, | |
| "pregame_context_applied": True, | |
| "pitcher_hr_adjustment": 0.0, | |
| "trend_hr_adjustment": 0.0, | |
| "zone_hr_adjustment": 0.0, | |
| "family_zone_hr_adjustment": 0.0, | |
| "arsenal_hr_adjustment": 0.0, | |
| "pulled_contact_hr_adjustment": 0.0, | |
| "env_hr_adjustment": 0.0, | |
| "park_hr_adjustment": 0.0, | |
| "weather_hr_adjustment": 0.0, | |
| "platoon_hr_adjustment": 0.0, | |
| "trajectory_hr_adjustment": 0.0, | |
| "rolling_hr_adjustment": 0.0, | |
| "pitcher_reliability": 0.8, | |
| "pitcher_resolution_status": "resolved", | |
| "trend_reliability": 0.0, | |
| "zone_reliability": 0.0, | |
| "family_zone_reliability": 0.0, | |
| "arsenal_reliability": 0.0, | |
| "pulled_contact_reliability": 0.0, | |
| "environment_reliability": 0.0, | |
| "trajectory_reliability": 0.0, | |
| "rolling_reliability": 0.0, | |
| "opportunity_reliability": 0.0, | |
| "matchup_platoon_multiplier": 1.0, | |
| "matchup_platoon_reason": "unknown", | |
| }, | |
| ): | |
| result = map_hr_props_to_model( | |
| props_df, | |
| statcast_df, | |
| probable_starters=probable_starters, | |
| ) | |
| row = result.iloc[0] | |
| self.assertEqual(row["batter_team"], "Chicago White Sox") | |
| self.assertEqual(row["batter_team_source"], "current_season_statcast") | |
| self.assertEqual(row["resolved_pitcher_name"], "Anthony Kay") | |
| def test_unmodeled_hr_ladders_do_not_get_model_probability(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "hr", | |
| "market_family": "hr", | |
| "threshold": 2, | |
| "display_label": "2+ HR", | |
| "is_modeled": False, | |
| "player_name": "slugger sam", | |
| "player_name_raw": "Slugger Sam", | |
| "odds_american": 4000, | |
| "sportsbook": "Caesars", | |
| "away_team": "Away", | |
| "home_team": "Home", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame([{"player_name": "Slugger Sam"}]) | |
| with patch("analytics.props_mapper.build_hr_probability_result") as mocked_build: | |
| result = map_hr_props_to_model(props_df, statcast_df) | |
| self.assertEqual(len(result), 1) | |
| row = result.iloc[0] | |
| self.assertEqual(row["model_hr_prob_source"], "unmodeled_hr_ladder") | |
| self.assertTrue(pd.isna(row["model_hr_prob"])) | |
| self.assertTrue(pd.isna(row["bet_ev"])) | |
| self.assertTrue(pd.isna(row["edge"])) | |
| mocked_build.assert_not_called() | |
| def test_strikeout_props_are_mapped_with_verdict_and_model_voice(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "k", | |
| "market_family": "k", | |
| "selection_side": "over", | |
| "selection_scope": "pitcher", | |
| "display_label": "Over 6.5 K", | |
| "player_name": "ace pitcher", | |
| "player_name_raw": "Ace Pitcher", | |
| "odds_american": 105, | |
| "line": 6.5, | |
| "sportsbook": "Caesars", | |
| "away_team": "Away Team", | |
| "home_team": "Home Team", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| } | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame([{"player_name": "Slugger Sam"}]) | |
| probable_starters = { | |
| ("away team", "home team"): { | |
| "home_pitcher": "Ace Pitcher", | |
| "away_pitcher": "Other Pitcher", | |
| } | |
| } | |
| with patch( | |
| "analytics.props_mapper.build_strikeout_probability_result_v2", | |
| return_value={ | |
| "formula_version": "strikeout_v2_live", | |
| "fair_prob": 0.61, | |
| "fair_prob_v2": 0.61, | |
| "expected_strikeouts": 7.0, | |
| "expected_strikeouts_v2": 7.0, | |
| "projected_pitch_count": 94.0, | |
| "projected_batters_faced": 25.0, | |
| "projected_innings": 5.7, | |
| "pitches_per_bf": 3.76, | |
| "opportunity_confidence": 0.74, | |
| "opportunity_reasons": ["stable_starter_workload"], | |
| "projected_k_rate": 0.28, | |
| "raw_k_prob": 0.61, | |
| "calibrated_k_prob": 0.61, | |
| "raw_k_prob_v2": 0.61, | |
| "calibrated_k_prob_v2": 0.61, | |
| "confidence_score": 64.0, | |
| "confidence_score_raw": 64.0, | |
| "confidence_score_display": 64.0, | |
| "confidence_source": "strikeout_v2_live", | |
| "confidence_bucket": "medium", | |
| "confidence_reasons": ["Projected opponent lineup is incomplete"], | |
| "confidence_component_bonuses": [{"label": "Strong pitcher sample", "value": 10, "direction": "bonus"}], | |
| "confidence_component_penalties": [{"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"}], | |
| "confidence_primary_driver": {"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"}, | |
| "confidence_summary_label": "Projected opponent lineup is incomplete", | |
| "confidence_score_v2": 64.0, | |
| "confidence_score_raw_v2": 64.0, | |
| "confidence_score_display_v2": 64.0, | |
| "confidence_source_v2": "strikeout_v2_live", | |
| "confidence_bucket_v2": "medium", | |
| "confidence_reasons_v2": ["Projected opponent lineup is incomplete"], | |
| "confidence_component_bonuses_v2": [{"label": "Strong pitcher sample", "value": 10, "direction": "bonus"}], | |
| "confidence_component_penalties_v2": [{"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"}], | |
| "confidence_primary_driver_v2": {"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"}, | |
| "confidence_summary_label_v2": "Projected opponent lineup is incomplete", | |
| "k_rate_pitch_signal": 0.28, | |
| "k_rate_anchor": 0.27, | |
| "bb_rate_anchor": 0.06, | |
| "command_efficiency_signal": 0.69, | |
| "swing_miss_subscore": 0.77, | |
| "called_strike_subscore": 0.73, | |
| "command_efficiency_subscore": 0.69, | |
| "lineup_whiff_subscore": 0.58, | |
| "zone_matchup_subscore": 0.19, | |
| "family_zone_matchup_subscore": 0.22, | |
| "arsenal_fit_subscore": 0.24, | |
| "tunneling_subscore": 0.64, | |
| "release_consistency_subscore": 0.62, | |
| "sequencing_subscore": 0.66, | |
| "count_leverage_subscore": 0.58, | |
| "leash_risk_subscore": 0.34, | |
| "role_certainty_score": 0.96, | |
| "times_through_order_penalty": 0.01, | |
| "telemetry_path_status": "full_telemetry", | |
| "model_tier": "full_telemetry", | |
| "variance_band_low": 6.0, | |
| "variance_band_high": 8.0, | |
| "matchup_coverage_confidence": 0.48, | |
| "component_source_map": {"shared_composer": "ok"}, | |
| "predicted_whiff_regions": ["shadow"], | |
| "predicted_attack_regions": ["shadow"], | |
| "predicted_damage_regions": ["heart"], | |
| "tunnel_pair_scores": [{"pair": "FF/CH", "score": 0.62}], | |
| "reason_tags_for": ["Misses bats consistently", "Projected workload supports deep strikeout opportunity"], | |
| "reason_tags_against": ["Projected opponent lineup is incomplete"], | |
| }, | |
| ): | |
| result = map_props_to_models( | |
| props_df, | |
| statcast_df=statcast_df, | |
| probable_starters=probable_starters, | |
| ) | |
| self.assertEqual(len(result), 1) | |
| row = result.iloc[0] | |
| self.assertEqual(row["market"], "k") | |
| self.assertAlmostEqual(float(row["fair_prob"]), 0.61, places=6) | |
| self.assertEqual(row["selection_scope"], "pitcher") | |
| self.assertIn(row["verdict"], {"bet", "watch", "pass"}) | |
| self.assertTrue(bool(str(row["model_voice_for"]))) | |
| self.assertEqual(row["confidence_source"], "strikeout_v2_live") | |
| self.assertGreater(float(row["confidence_score"]), 64.0) | |
| self.assertEqual(row["confidence_summary_label"], "Projected opponent lineup is incomplete") | |
| self.assertTrue(bool(row["confidence_component_bonuses"])) | |
| self.assertTrue(bool(row["confidence_component_penalties"])) | |
| self.assertEqual(float(row["projected_pitch_count"]), 94.0) | |
| self.assertEqual(row["formula_version"], "strikeout_v2_live") | |
| self.assertEqual(row["model_probability_status"], "modeled_ok") | |
| self.assertTrue(bool(row["has_model_probability"])) | |
| self.assertEqual(row["telemetry_path_status"], "full_telemetry") | |
| self.assertEqual(row["model_tier"], "full_telemetry") | |
| def test_strikeout_props_reuse_probability_results_for_duplicate_books(self) -> None: | |
| props_df = pd.DataFrame( | |
| [ | |
| { | |
| "market": "k", | |
| "market_family": "k", | |
| "selection_side": "over", | |
| "selection_scope": "pitcher", | |
| "display_label": "Over 6.5 K", | |
| "player_name": "ace pitcher", | |
| "player_name_raw": "Ace Pitcher", | |
| "odds_american": 105, | |
| "line": 6.5, | |
| "sportsbook": "Caesars", | |
| "away_team": "Away Team", | |
| "home_team": "Home Team", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| }, | |
| { | |
| "market": "k", | |
| "market_family": "k", | |
| "selection_side": "over", | |
| "selection_scope": "pitcher", | |
| "display_label": "Over 6.5 K", | |
| "player_name": "ace pitcher", | |
| "player_name_raw": "Ace Pitcher", | |
| "odds_american": 102, | |
| "line": 6.5, | |
| "sportsbook": "FanDuel", | |
| "away_team": "Away Team", | |
| "home_team": "Home Team", | |
| "commence_time": "2026-03-25T00:10:00Z", | |
| }, | |
| ] | |
| ) | |
| statcast_df = pd.DataFrame([{"player_name": "Slugger Sam"}]) | |
| probable_starters = { | |
| ("away team", "home team"): { | |
| "home_pitcher": "Ace Pitcher", | |
| "away_pitcher": "Other Pitcher", | |
| } | |
| } | |
| with patch( | |
| "analytics.props_mapper.build_strikeout_probability_result_v2", | |
| return_value={ | |
| "formula_version": "strikeout_v2_live", | |
| "fair_prob": 0.61, | |
| "fair_prob_v2": 0.61, | |
| "expected_strikeouts": 7.0, | |
| "expected_strikeouts_v2": 7.0, | |
| "projected_pitch_count": 94.0, | |
| "projected_batters_faced": 25.0, | |
| "projected_innings": 5.7, | |
| "pitches_per_bf": 3.76, | |
| "opportunity_confidence": 0.74, | |
| "opportunity_reasons": ["stable_starter_workload"], | |
| "projected_k_rate": 0.28, | |
| "raw_k_prob": 0.61, | |
| "calibrated_k_prob": 0.61, | |
| "raw_k_prob_v2": 0.61, | |
| "calibrated_k_prob_v2": 0.61, | |
| "confidence_score": 64.0, | |
| "confidence_score_raw": 64.0, | |
| "confidence_score_display": 64.0, | |
| "confidence_source": "strikeout_v2_live", | |
| "confidence_bucket": "medium", | |
| "confidence_reasons": [], | |
| "confidence_component_bonuses": [], | |
| "confidence_component_penalties": [], | |
| "confidence_primary_driver": None, | |
| "confidence_summary_label": None, | |
| "confidence_score_v2": 64.0, | |
| "confidence_score_raw_v2": 64.0, | |
| "confidence_score_display_v2": 64.0, | |
| "confidence_source_v2": "strikeout_v2_live", | |
| "confidence_bucket_v2": "medium", | |
| "confidence_reasons_v2": [], | |
| "confidence_component_bonuses_v2": [], | |
| "confidence_component_penalties_v2": [], | |
| "confidence_primary_driver_v2": None, | |
| "confidence_summary_label_v2": None, | |
| "k_rate_pitch_signal": 0.28, | |
| "k_rate_anchor": 0.27, | |
| "bb_rate_anchor": 0.06, | |
| "command_efficiency_signal": 0.69, | |
| "swing_miss_subscore": 0.77, | |
| "called_strike_subscore": 0.73, | |
| "command_efficiency_subscore": 0.69, | |
| "lineup_whiff_subscore": 0.58, | |
| "zone_matchup_subscore": 0.19, | |
| "family_zone_matchup_subscore": 0.22, | |
| "arsenal_fit_subscore": 0.24, | |
| "tunneling_subscore": 0.64, | |
| "release_consistency_subscore": 0.62, | |
| "sequencing_subscore": 0.66, | |
| "count_leverage_subscore": 0.58, | |
| "leash_risk_subscore": 0.34, | |
| "role_certainty_score": 0.96, | |
| "times_through_order_penalty": 0.01, | |
| "telemetry_path_status": "full_telemetry", | |
| "model_tier": "full_telemetry", | |
| "variance_band_low": 6.0, | |
| "variance_band_high": 8.0, | |
| "matchup_coverage_confidence": 0.48, | |
| "component_source_map": {"shared_composer": "ok"}, | |
| "predicted_whiff_regions": ["shadow"], | |
| "predicted_attack_regions": ["shadow"], | |
| "predicted_damage_regions": ["heart"], | |
| "tunnel_pair_scores": [{"pair": "FF/CH", "score": 0.62}], | |
| "reason_tags_for": ["Misses bats consistently"], | |
| "reason_tags_against": [], | |
| }, | |
| ) as mocked_build: | |
| result = map_props_to_models( | |
| props_df, | |
| statcast_df=statcast_df, | |
| probable_starters=probable_starters, | |
| ) | |
| self.assertEqual(len(result), 2) | |
| self.assertEqual(mocked_build.call_count, 1) | |
| if __name__ == "__main__": | |
| unittest.main() | |