2026_MLB_Model / tests /test_props_mapper.py
Syntrex's picture
Remove Fangraphs from Props runtime
c4cd7c6
raw
history blame
47.2 kB
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()