Spaces:
Sleeping
Sleeping
Odds tab: add pitcher strikeout alt lines with Standard/Alt Lines/Both view toggle
Browse files- Fetch pitcher_strikeouts_alternate market from TheOddsAPI
- Normalize alt K rows: populate threshold from line, set market_variant=alternate, is_primary_line=False
- Fix latent bug: threshold now populated for all K rows, enabling Strikeout Ladder Summary to render
- Ladder Summary gains Variant column (Standard/Alt/Mixed), sorted Standard-first
- K section adds horizontal radio: Standard (existing O/U grids) | Alt Lines (Over-only alt grid) | Both (unified single table of all lines)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- config/settings.py +1 -1
- data/live_prop_odds.py +7 -1
- data/provider_theoddsapi.py +2 -0
- visualization/betting_page.py +96 -43
config/settings.py
CHANGED
|
@@ -18,7 +18,7 @@ ENABLE_XGB_SHADOW = os.getenv("ENABLE_XGB_SHADOW", "true").lower() == "true"
|
|
| 18 |
LIVE_PROP_ODDS_TTL_SECONDS = 20
|
| 19 |
DEFAULT_PROP_BOOKS = ["draftkings", "fanduel", "betmgm", "williamhill_us"]
|
| 20 |
DEFAULT_PROP_MARKETS = ["batter_home_runs", "batter_hits", "batter_total_bases"]
|
| 21 |
-
DEFAULT_UPCOMING_PROP_MARKETS = ["batter_home_runs", "pitcher_strikeouts"]
|
| 22 |
|
| 23 |
# Phase 2: Baseline HR probability (empirical MLB 2024 per-PA HR rate ≈ 0.036)
|
| 24 |
BASELINE_HR_PROB = 0.036
|
|
|
|
| 18 |
LIVE_PROP_ODDS_TTL_SECONDS = 20
|
| 19 |
DEFAULT_PROP_BOOKS = ["draftkings", "fanduel", "betmgm", "williamhill_us"]
|
| 20 |
DEFAULT_PROP_MARKETS = ["batter_home_runs", "batter_hits", "batter_total_bases"]
|
| 21 |
+
DEFAULT_UPCOMING_PROP_MARKETS = ["batter_home_runs", "pitcher_strikeouts", "pitcher_strikeouts_alternate"]
|
| 22 |
|
| 23 |
# Phase 2: Baseline HR probability (empirical MLB 2024 per-PA HR rate ≈ 0.036)
|
| 24 |
BASELINE_HR_PROB = 0.036
|
data/live_prop_odds.py
CHANGED
|
@@ -127,6 +127,7 @@ def normalize_prop_odds(raw_df: pd.DataFrame) -> pd.DataFrame:
|
|
| 127 |
out.loc[~hr_mask, "threshold"].notna(),
|
| 128 |
None,
|
| 129 |
)
|
|
|
|
| 130 |
out.loc[hr_mask, "market_variant"] = out.loc[hr_mask, "threshold"].apply(
|
| 131 |
lambda v: "primary" if pd.notna(v) and int(v) == 1 else "alternate"
|
| 132 |
)
|
|
@@ -143,6 +144,9 @@ def normalize_prop_odds(raw_df: pd.DataFrame) -> pd.DataFrame:
|
|
| 143 |
out.loc[~hr_mask, "market_variant"].notna(),
|
| 144 |
"standard",
|
| 145 |
)
|
|
|
|
|
|
|
|
|
|
| 146 |
out.loc[k_mask, "selection_scope"] = out.loc[k_mask, "selection_scope"].where(
|
| 147 |
out.loc[k_mask, "selection_scope"].notna(),
|
| 148 |
"pitcher",
|
|
@@ -155,7 +159,9 @@ def normalize_prop_odds(raw_df: pd.DataFrame) -> pd.DataFrame:
|
|
| 155 |
),
|
| 156 |
axis=1,
|
| 157 |
)
|
| 158 |
-
out.loc[k_mask, "is_primary_line"] =
|
|
|
|
|
|
|
| 159 |
out.loc[k_mask, "is_modeled"] = out.loc[k_mask, "selection_side"].isin(["over", "under"])
|
| 160 |
|
| 161 |
out.loc[no_hr_mask, "selection_scope"] = out.loc[no_hr_mask, "selection_scope"].where(
|
|
|
|
| 127 |
out.loc[~hr_mask, "threshold"].notna(),
|
| 128 |
None,
|
| 129 |
)
|
| 130 |
+
out.loc[k_mask, "threshold"] = out.loc[k_mask, "line"].apply(_safe_float)
|
| 131 |
out.loc[hr_mask, "market_variant"] = out.loc[hr_mask, "threshold"].apply(
|
| 132 |
lambda v: "primary" if pd.notna(v) and int(v) == 1 else "alternate"
|
| 133 |
)
|
|
|
|
| 144 |
out.loc[~hr_mask, "market_variant"].notna(),
|
| 145 |
"standard",
|
| 146 |
)
|
| 147 |
+
if "market_key" in out.columns:
|
| 148 |
+
k_alt_mask = k_mask & out["market_key"].astype(str).str.strip().eq("pitcher_strikeouts_alternate")
|
| 149 |
+
out.loc[k_alt_mask, "market_variant"] = "alternate"
|
| 150 |
out.loc[k_mask, "selection_scope"] = out.loc[k_mask, "selection_scope"].where(
|
| 151 |
out.loc[k_mask, "selection_scope"].notna(),
|
| 152 |
"pitcher",
|
|
|
|
| 159 |
),
|
| 160 |
axis=1,
|
| 161 |
)
|
| 162 |
+
out.loc[k_mask, "is_primary_line"] = out.loc[k_mask, "market_variant"].apply(
|
| 163 |
+
lambda v: v != "alternate"
|
| 164 |
+
)
|
| 165 |
out.loc[k_mask, "is_modeled"] = out.loc[k_mask, "selection_side"].isin(["over", "under"])
|
| 166 |
|
| 167 |
out.loc[no_hr_mask, "selection_scope"] = out.loc[no_hr_mask, "selection_scope"].where(
|
data/provider_theoddsapi.py
CHANGED
|
@@ -36,6 +36,7 @@ SUPPORTED_MARKETS = {
|
|
| 36 |
"batter_hits",
|
| 37 |
"batter_total_bases",
|
| 38 |
"pitcher_strikeouts",
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
MARKET_NAME_MAP = {
|
|
@@ -43,6 +44,7 @@ MARKET_NAME_MAP = {
|
|
| 43 |
"batter_hits": "hit",
|
| 44 |
"batter_total_bases": "tb",
|
| 45 |
"pitcher_strikeouts": "k",
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
BOOK_KEY_MAP = {
|
|
|
|
| 36 |
"batter_hits",
|
| 37 |
"batter_total_bases",
|
| 38 |
"pitcher_strikeouts",
|
| 39 |
+
"pitcher_strikeouts_alternate",
|
| 40 |
}
|
| 41 |
|
| 42 |
MARKET_NAME_MAP = {
|
|
|
|
| 44 |
"batter_hits": "hit",
|
| 45 |
"batter_total_bases": "tb",
|
| 46 |
"pitcher_strikeouts": "k",
|
| 47 |
+
"pitcher_strikeouts_alternate": "k",
|
| 48 |
}
|
| 49 |
|
| 50 |
BOOK_KEY_MAP = {
|
visualization/betting_page.py
CHANGED
|
@@ -551,8 +551,16 @@ def _build_strikeout_ladder_summary(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 551 |
line_df = df[pd.to_numeric(df["threshold"], errors="coerce") == threshold].copy()
|
| 552 |
if line_df.empty:
|
| 553 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
row: dict[str, Any] = {
|
| 555 |
"Line": f"{float(threshold):.1f} K",
|
|
|
|
| 556 |
"Pitchers": int(line_df["player_name"].dropna().nunique()) if "player_name" in line_df.columns else 0,
|
| 557 |
"Books": int(line_df["sportsbook"].dropna().nunique()) if "sportsbook" in line_df.columns else 0,
|
| 558 |
"Rows": int(len(line_df)),
|
|
@@ -568,7 +576,12 @@ def _build_strikeout_ladder_summary(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 568 |
row[f"{side.title()} Book"] = str(best_row.get("sportsbook") or "-")
|
| 569 |
rows.append(row)
|
| 570 |
summary_df = pd.DataFrame(rows)
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
return summary_df[[col for col in preferred if col in summary_df.columns]] if not summary_df.empty else summary_df
|
| 573 |
|
| 574 |
|
|
@@ -579,13 +592,17 @@ def _render_market_section(df: pd.DataFrame, family: str, prev_snap: dict[str, f
|
|
| 579 |
with controls[1]:
|
| 580 |
teams = sorted({team for col in ("away_team", "home_team") if col in df.columns for team in df[col].dropna().astype(str).unique()})
|
| 581 |
team_sel = st.selectbox("team", ["All teams"] + teams, key=f"team_{family}", label_visibility="collapsed")
|
| 582 |
-
|
|
|
|
| 583 |
selected_threshold: float | None = None
|
| 584 |
with controls[2]:
|
| 585 |
if len(thresholds) > 1:
|
| 586 |
selected_threshold = float(st.selectbox("thresh", thresholds, key=f"thresh_{family}", label_visibility="collapsed", format_func=lambda value: f"{float(value):.1f} K" if family == "k" else f"{float(value):.1f}+"))
|
| 587 |
elif thresholds:
|
| 588 |
st.caption(f"Line: {float(thresholds[0]):.1f} K" if family == "k" else f"Line: {float(thresholds[0]):.1f}+")
|
|
|
|
|
|
|
|
|
|
| 589 |
if search_q:
|
| 590 |
df = df[df["player_name"].astype(str).str.contains(search_q, case=False, na=False)].copy()
|
| 591 |
if team_sel != "All teams":
|
|
@@ -595,49 +612,85 @@ def _render_market_section(df: pd.DataFrame, family: str, prev_snap: dict[str, f
|
|
| 595 |
st.info("No lines match the current filter.")
|
| 596 |
return
|
| 597 |
grouped_source_df = df.copy()
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
return
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
else:
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
if family == "k":
|
| 642 |
summary_df = _build_strikeout_ladder_summary(grouped_source_df)
|
| 643 |
if not summary_df.empty:
|
|
|
|
| 551 |
line_df = df[pd.to_numeric(df["threshold"], errors="coerce") == threshold].copy()
|
| 552 |
if line_df.empty:
|
| 553 |
continue
|
| 554 |
+
variants = line_df["market_variant"].dropna().astype(str).unique().tolist() if "market_variant" in line_df.columns else []
|
| 555 |
+
if all(v == "alternate" for v in variants) and variants:
|
| 556 |
+
variant_label = "Alt"
|
| 557 |
+
elif any(v == "alternate" for v in variants):
|
| 558 |
+
variant_label = "Mixed"
|
| 559 |
+
else:
|
| 560 |
+
variant_label = "Standard"
|
| 561 |
row: dict[str, Any] = {
|
| 562 |
"Line": f"{float(threshold):.1f} K",
|
| 563 |
+
"Variant": variant_label,
|
| 564 |
"Pitchers": int(line_df["player_name"].dropna().nunique()) if "player_name" in line_df.columns else 0,
|
| 565 |
"Books": int(line_df["sportsbook"].dropna().nunique()) if "sportsbook" in line_df.columns else 0,
|
| 566 |
"Rows": int(len(line_df)),
|
|
|
|
| 576 |
row[f"{side.title()} Book"] = str(best_row.get("sportsbook") or "-")
|
| 577 |
rows.append(row)
|
| 578 |
summary_df = pd.DataFrame(rows)
|
| 579 |
+
if not summary_df.empty and "Variant" in summary_df.columns:
|
| 580 |
+
_variant_order = {"Standard": 0, "Mixed": 1, "Alt": 2}
|
| 581 |
+
summary_df = summary_df.sort_values(
|
| 582 |
+
"Variant", key=lambda s: s.map(lambda v: _variant_order.get(v, 99))
|
| 583 |
+
).reset_index(drop=True)
|
| 584 |
+
preferred = ["Line", "Variant", "Pitchers", "Books", "Rows", "Over Best", "Over Book", "Under Best", "Under Book"]
|
| 585 |
return summary_df[[col for col in preferred if col in summary_df.columns]] if not summary_df.empty else summary_df
|
| 586 |
|
| 587 |
|
|
|
|
| 592 |
with controls[1]:
|
| 593 |
teams = sorted({team for col in ("away_team", "home_team") if col in df.columns for team in df[col].dropna().astype(str).unique()})
|
| 594 |
team_sel = st.selectbox("team", ["All teams"] + teams, key=f"team_{family}", label_visibility="collapsed")
|
| 595 |
+
_thresh_src = df[df["market_variant"] != "alternate"] if (family == "k" and "market_variant" in df.columns) else df
|
| 596 |
+
thresholds = sorted(pd.to_numeric(_thresh_src["threshold"], errors="coerce").dropna().unique().tolist()) if "threshold" in _thresh_src.columns else []
|
| 597 |
selected_threshold: float | None = None
|
| 598 |
with controls[2]:
|
| 599 |
if len(thresholds) > 1:
|
| 600 |
selected_threshold = float(st.selectbox("thresh", thresholds, key=f"thresh_{family}", label_visibility="collapsed", format_func=lambda value: f"{float(value):.1f} K" if family == "k" else f"{float(value):.1f}+"))
|
| 601 |
elif thresholds:
|
| 602 |
st.caption(f"Line: {float(thresholds[0]):.1f} K" if family == "k" else f"Line: {float(thresholds[0]):.1f}+")
|
| 603 |
+
k_view = "Standard"
|
| 604 |
+
if family == "k":
|
| 605 |
+
k_view = st.radio("View", ["Standard", "Alt Lines", "Both"], horizontal=True, key=f"k_view_{family}", label_visibility="collapsed")
|
| 606 |
if search_q:
|
| 607 |
df = df[df["player_name"].astype(str).str.contains(search_q, case=False, na=False)].copy()
|
| 608 |
if team_sel != "All teams":
|
|
|
|
| 612 |
st.info("No lines match the current filter.")
|
| 613 |
return
|
| 614 |
grouped_source_df = df.copy()
|
| 615 |
+
alt_source_df = pd.DataFrame()
|
| 616 |
+
if family == "k" and "market_variant" in df.columns:
|
| 617 |
+
alt_source_df = df[df["market_variant"] == "alternate"].copy()
|
| 618 |
+
df = df[df["market_variant"] != "alternate"].copy()
|
| 619 |
+
if k_view == "Standard":
|
| 620 |
+
std_df = df.copy()
|
| 621 |
+
if selected_threshold is not None:
|
| 622 |
+
std_df = std_df[pd.to_numeric(std_df["threshold"], errors="coerce") == selected_threshold].copy()
|
| 623 |
+
if std_df.empty:
|
| 624 |
+
st.info("No lines match the selected line.")
|
| 625 |
+
return
|
| 626 |
+
if std_df.empty:
|
| 627 |
+
st.info("No lines match the current filter.")
|
| 628 |
return
|
| 629 |
+
books = [book for book in std_df["sportsbook"].dropna().unique() if book] if "sportsbook" in std_df.columns else []
|
| 630 |
+
book_cols = sorted(books, key=lambda book: (_BOOK_PRIORITY.index(book) if book in _BOOK_PRIORITY else 99, book))
|
| 631 |
+
if not book_cols:
|
| 632 |
+
st.info("No sportsbook columns found.")
|
| 633 |
+
return
|
| 634 |
+
sides_present = std_df["selection_side"].dropna().astype(str).str.lower().unique().tolist() if "selection_side" in std_df.columns else ["over"]
|
| 635 |
+
over_numeric = pd.DataFrame()
|
| 636 |
+
under_numeric = pd.DataFrame()
|
| 637 |
+
for side in ["over", "under"]:
|
| 638 |
+
if side not in sides_present or (family == "hr" and side == "under"):
|
| 639 |
+
continue
|
| 640 |
+
side_df = std_df[std_df["selection_side"].astype(str).str.lower() == side].copy()
|
| 641 |
+
if side_df.empty:
|
| 642 |
+
continue
|
| 643 |
+
if "over" in sides_present and "under" in sides_present:
|
| 644 |
+
st.markdown(f"**{'Over' if side == 'over' else 'Under'}**")
|
| 645 |
+
numeric_df, display_df = _build_side_table(side_df, book_cols, prev_snap, steam_set, model_probs)
|
| 646 |
+
css_df = _build_css_df(display_df, numeric_df, [col for col in book_cols if col in display_df.columns])
|
| 647 |
+
display_df, numeric_df, css_df = _render_sortable_grid(display_df, numeric_df, css_df, family=family, side=side)
|
| 648 |
+
if side == "over":
|
| 649 |
+
over_numeric = numeric_df
|
| 650 |
+
else:
|
| 651 |
+
under_numeric = numeric_df
|
| 652 |
+
market_label = next((label for market, label in _MARKETS if market == family), family)
|
| 653 |
+
side_label = f"{side.title()} {selected_threshold:.1f} K" if family == "k" and selected_threshold is not None else side.title()
|
| 654 |
+
try:
|
| 655 |
+
png_bytes = _render_table_png(display_df, css_df, market_label=market_label, side_label=side_label)
|
| 656 |
+
st.download_button(
|
| 657 |
+
label="Export PNG",
|
| 658 |
+
data=png_bytes,
|
| 659 |
+
file_name=f"kasper_odds_{family}_{side}_{datetime.now().strftime('%Y%m%d_%H%M')}.png",
|
| 660 |
+
mime="image/png",
|
| 661 |
+
key=f"export_{family}_{side}_{selected_threshold}_{id(display_df)}",
|
| 662 |
+
)
|
| 663 |
+
except Exception:
|
| 664 |
+
pass
|
| 665 |
+
if not over_numeric.empty and not under_numeric.empty:
|
| 666 |
+
_render_vig_section(over_numeric, under_numeric, book_cols)
|
| 667 |
+
elif family == "k" and k_view == "Alt Lines":
|
| 668 |
+
if alt_source_df.empty:
|
| 669 |
+
st.info("No alt line data available for the current filters.")
|
| 670 |
else:
|
| 671 |
+
alt_books = [b for b in alt_source_df["sportsbook"].dropna().unique() if b] if "sportsbook" in alt_source_df.columns else []
|
| 672 |
+
alt_book_cols = sorted(alt_books, key=lambda b: (_BOOK_PRIORITY.index(b) if b in _BOOK_PRIORITY else 99, b))
|
| 673 |
+
if alt_book_cols:
|
| 674 |
+
alt_over_df = alt_source_df[alt_source_df["selection_side"].astype(str).str.lower() == "over"].copy() if "selection_side" in alt_source_df.columns else alt_source_df
|
| 675 |
+
if not alt_over_df.empty:
|
| 676 |
+
alt_numeric_df, alt_display_df = _build_side_table(alt_over_df, alt_book_cols, prev_snap, steam_set, model_probs)
|
| 677 |
+
alt_css_df = _build_css_df(alt_display_df, alt_numeric_df, [col for col in alt_book_cols if col in alt_display_df.columns])
|
| 678 |
+
_render_sortable_grid(alt_display_df, alt_numeric_df, alt_css_df, family=family, side="alt_over")
|
| 679 |
+
else:
|
| 680 |
+
st.info("No alt line data available for the current filters.")
|
| 681 |
+
elif family == "k" and k_view == "Both":
|
| 682 |
+
combined_df = pd.concat([df, alt_source_df], ignore_index=True) if not alt_source_df.empty else df.copy()
|
| 683 |
+
if combined_df.empty:
|
| 684 |
+
st.info("No lines match the current filter.")
|
| 685 |
+
else:
|
| 686 |
+
all_books = [b for b in combined_df["sportsbook"].dropna().unique() if b] if "sportsbook" in combined_df.columns else []
|
| 687 |
+
all_book_cols = sorted(all_books, key=lambda b: (_BOOK_PRIORITY.index(b) if b in _BOOK_PRIORITY else 99, b))
|
| 688 |
+
if all_book_cols:
|
| 689 |
+
combined_numeric_df, combined_display_df = _build_side_table(combined_df, all_book_cols, prev_snap, steam_set, model_probs)
|
| 690 |
+
combined_css_df = _build_css_df(combined_display_df, combined_numeric_df, [col for col in all_book_cols if col in combined_display_df.columns])
|
| 691 |
+
_render_sortable_grid(combined_display_df, combined_numeric_df, combined_css_df, family=family, side="both")
|
| 692 |
+
else:
|
| 693 |
+
st.info("No sportsbook columns found.")
|
| 694 |
if family == "k":
|
| 695 |
summary_df = _build_strikeout_ladder_summary(grouped_source_df)
|
| 696 |
if not summary_df.empty:
|