File size: 9,964 Bytes
726e2cd
 
 
 
 
 
21151ce
b761d20
726e2cd
e2bfd69
95e27f5
726e2cd
26e7378
 
 
 
 
 
 
 
 
 
 
 
 
 
684b4f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726e2cd
154aa0b
726e2cd
7b3d14f
726e2cd
dba351a
154aa0b
 
726e2cd
154aa0b
67f928e
c8d7762
 
 
 
f762454
726e2cd
 
 
 
7b3d14f
726e2cd
 
 
dba351a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154aa0b
726e2cd
 
dba351a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67f928e
c8d7762
 
 
 
 
 
 
 
 
67f928e
c8d7762
 
 
 
 
95e27f5
 
c8d7762
67f928e
 
 
 
 
 
 
 
 
 
95e27f5
67f928e
95e27f5
 
67f928e
 
 
21151ce
 
 
67f928e
21151ce
95e27f5
 
67f928e
 
 
95e27f5
67f928e
95e27f5
 
67f928e
 
c8d7762
 
 
 
 
726e2cd
 
 
 
 
 
 
 
247bd3d
154aa0b
 
247bd3d
154aa0b
 
247bd3d
 
f762454
 
 
 
 
 
 
 
7b3d14f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
from __future__ import annotations

import pandas as pd

from analytics.confidence import compute_confidence
from analytics.recommendation_rules import apply_recommendation_rules
from analytics.no_vig_props import american_to_implied_prob, compute_bet_ev, compute_edge, kelly_fraction, remove_vig_single_side
from models.fair_odds import probability_to_american
from models.live_fair_simulator_v3 import build_upcoming_simulated_rows
from models.opportunity_model import estimate_plate_appearance_probability
from utils.logger import logger

def _lineup_distance_from_slot(slot: str) -> int:
    s = str(slot or "").strip().lower()

    if s in {"current", "current batter", "current_batter", "now"}:
        return 0
    if s in {"on deck", "on_deck", "ondeck", "on-deck"}:
        return 1
    if s in {"in hole", "in_hole", "inhole", "in-hole"}:
        return 2
    if s in {"3 away", "three away", "3_away", "three_away", "three-away", "3-away"}:
        return 3

    return 0

def _apply_opportunity_badges(recommendations: list[dict]) -> list[dict]:
    if not recommendations:
        return recommendations

    rows = [dict(r) for r in recommendations]

    for row in rows:
        row["opportunity_badges"] = []

    def _best_row_index(metric: str) -> int | None:
        best_idx = None
        best_val = None

        for idx, row in enumerate(rows):
            value = row.get(metric)
            try:
                numeric_value = float(value)
            except Exception:
                continue

            if best_val is None or numeric_value > best_val:
                best_val = numeric_value
                best_idx = idx

        return best_idx

    best_overall_idx = _best_row_index("priority_score")
    best_hr_idx = _best_row_index("hr_edge")
    best_hit_idx = _best_row_index("hit_edge")
    best_tb_idx = _best_row_index("tb2p_edge")

    if best_overall_idx is not None:
        rows[best_overall_idx]["opportunity_badges"].append("BEST OVERALL")

    if best_hr_idx is not None:
        rows[best_hr_idx]["opportunity_badges"].append("BEST HR EDGE")

    if best_hit_idx is not None:
        rows[best_hit_idx]["opportunity_badges"].append("BEST HIT EDGE")

    if best_tb_idx is not None:
        rows[best_tb_idx]["opportunity_badges"].append("BEST TB EDGE")

    for row in rows:
        tier = str(row.get("recommendation_tier", "") or "").strip().lower()

        if tier == "bet":
            row["opportunity_badges"].append("BET")
        elif tier == "watch":
            row["opportunity_badges"].append("WATCH")

        # de-duplicate while preserving order
        seen = set()
        deduped = []
        for badge in row["opportunity_badges"]:
            if badge in seen:
                continue
            seen.add(badge)
            deduped.append(badge)

        row["opportunity_badges"] = deduped[:3]

    return rows

def build_upcoming_hitter_recommendations(
    game_row: dict,
    statcast_df: pd.DataFrame,
    pitcher_statcast_df: pd.DataFrame | None = None,
    odds_df: pd.DataFrame | None = None,
    prop_odds_df: pd.DataFrame | None = None,
    weather_row: dict | None = None,
) -> list[dict]:
    """
    Decision-layer wrapper.
    Uses simulated fair rows, then applies:
    1) opportunity adjustment
    2) confidence
    3) recommendation tier

    Suppresses low-value PASS rows unless nothing else exists.
    """
    rows = build_upcoming_simulated_rows(
        game_row=game_row,
        statcast_df=statcast_df,
        pitcher_statcast_df=pitcher_statcast_df,
        weather_row=weather_row,
    )

    # Build lookup: normalized_player_name → best HR american odds from real prop feed
    _prop_odds_lookup: dict[str, int] = {}
    if prop_odds_df is not None and not prop_odds_df.empty:
        try:
            from data.odds_name_map import map_odds_name_to_model_name
            hr_props = (
                prop_odds_df[prop_odds_df["market"].isin(["batter_home_runs", "hr"])]
                if "market" in prop_odds_df.columns
                else prop_odds_df
            )
            if not hr_props.empty and "odds_american" in hr_props.columns and "player_name" in hr_props.columns:
                # Explicit sort: MAX(odds_american) per player = best price for bettor
                best_hr = (
                    hr_props
                    .sort_values("odds_american", ascending=False)
                    .drop_duplicates(subset=["player_name"])
                )
                for _, prow in best_hr.iterrows():
                    norm_name = map_odds_name_to_model_name(str(prow.get("player_name") or ""))
                    odds_val = prow.get("odds_american")
                    if norm_name and odds_val is not None:
                        try:
                            _prop_odds_lookup[norm_name] = int(float(odds_val))
                        except (TypeError, ValueError):
                            pass
        except Exception as exc:
            logger.warning("[prop_odds_lookup] build failure: %s", exc)

    recommendations: list[dict] = []

    for row in rows:
        # Inject real book HR odds if available; fall back to simulator placeholder
        if _prop_odds_lookup:
            from data.odds_name_map import map_odds_name_to_model_name
            _norm_batter = map_odds_name_to_model_name(str(row.get("batter_name") or ""))
            _real_hr_odds = _prop_odds_lookup.get(_norm_batter)

            # Fallback: raw name match if normalized mapping misses
            if _real_hr_odds is None:
                _real_hr_odds = _prop_odds_lookup.get(str(row.get("batter_name") or ""))
                if _real_hr_odds is not None:
                    row["book_hr_odds_source"] = "live_feed_unmapped"

            if _real_hr_odds is not None:
                row["book_hr_odds"] = _real_hr_odds
                row.setdefault("book_hr_odds_source", "live_feed")
            else:
                row.setdefault("book_hr_odds_source", "placeholder")
                if prop_odds_df is not None and not prop_odds_df.empty:
                    logger.warning(
                        "[prop_odds_mapping_miss] batter=%s",
                        row.get("batter_name"),
                    )
        else:
            row.setdefault("book_hr_odds_source", "placeholder")

        slot = row.get("slot", "Current")
        lineup_distance = _lineup_distance_from_slot(slot)

        opportunity = estimate_plate_appearance_probability(
            outs=game_row.get("outs", 0),
            lineup_distance=lineup_distance,
        )

        expected_pa = float(opportunity.get("expected_pa", 1.0) or 1.0)

        # Apply opportunity adjustment to the simulated probabilities
        for prob_col in ["hit_prob", "hr_prob", "tb2p_prob"]:
            if prob_col in row and row.get(prob_col) is not None:
                try:
                    raw_prob = float(row.get(prob_col))
                    row[prob_col] = min(0.95, max(0.001, raw_prob * expected_pa))
                except Exception as e:
                    logger.warning(f"[prob_opportunity_adjust] failure: {e}", exc_info=True)

        # Recalculate fair odds and edges after probability adjustment
        if row.get("hit_prob") is not None:
            row["fair_hit_odds"] = probability_to_american(row["hit_prob"])
        if row.get("hr_prob") is not None:
            row["fair_hr_odds"] = probability_to_american(row["hr_prob"])
        if row.get("tb2p_prob") is not None:
            row["fair_tb2p_odds"] = probability_to_american(row["tb2p_prob"])

        try:
            book_hit_odds = float(row.get("book_hit_odds"))
            row["hit_edge"] = compute_edge(row["hit_prob"], american_to_implied_prob(book_hit_odds))
            row["hit_bet_ev"] = compute_bet_ev(row["hit_prob"], int(book_hit_odds))
        except Exception as e:
            logger.warning(f"[hit_edge_compute] failure: {e}", exc_info=True)

        try:
            book_hr_odds = float(row.get("book_hr_odds"))
            # HR props are single-sided markets — de-vig with flat margin, not two-way normalization
            hr_book_prob_novig = remove_vig_single_side(american_to_implied_prob(book_hr_odds))
            row["hr_edge"] = compute_edge(row["hr_prob"], hr_book_prob_novig)
            row["hr_bet_ev"] = compute_bet_ev(row["hr_prob"], int(book_hr_odds))
            row["kelly_pct"] = kelly_fraction(row["hr_prob"], int(book_hr_odds))
        except Exception as e:
            logger.warning(f"[hr_edge_compute] failure: {e}", exc_info=True)

        try:
            book_tb2p_odds = float(row.get("book_tb2p_odds"))
            row["tb2p_edge"] = compute_edge(row["tb2p_prob"], american_to_implied_prob(book_tb2p_odds))
            row["tb2p_bet_ev"] = compute_bet_ev(row["tb2p_prob"], int(book_tb2p_odds))
        except Exception as e:
            logger.warning(f"[tb2p_edge_compute] failure: {e}", exc_info=True)

        # Carry diagnostics forward
        row["lineup_distance"] = lineup_distance
        row["pa_prob_this_inning"] = opportunity.get("pa_prob_this_inning")
        row["pa_prob_next_two_innings"] = opportunity.get("pa_prob_next_two_innings")
        row["expected_pa"] = expected_pa

        confidence_block = compute_confidence(row, game_row=game_row)
        row.update(confidence_block)

        rules_block = apply_recommendation_rules(row)
        row.update(rules_block)

        recommendations.append(row)

        # Preserve lineup-order display, but compute badges from model outputs.
    recommendations = sorted(
        recommendations,
        key=lambda x: _lineup_distance_from_slot(x.get("slot", "")),
    )

    recommendations = _apply_opportunity_badges(recommendations)

    surfaced = [
        row for row in recommendations
        if str(row.get("recommendation_tier", "")).lower() in {"bet", "watch"}
    ]

    if surfaced:
        return surfaced

    return recommendations[:1]