Syntrex Claude Sonnet 4.6 commited on
Commit
17be445
·
1 Parent(s): e6a8789

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 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"] = True
 
 
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
- preferred = ["Line", "Pitchers", "Books", "Rows", "Over Best", "Over Book", "Under Best", "Under Book"]
 
 
 
 
 
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
- thresholds = sorted(pd.to_numeric(df["threshold"], errors="coerce").dropna().unique().tolist()) if "threshold" in df.columns else []
 
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
- if selected_threshold is not None:
599
- df = df[pd.to_numeric(df["threshold"], errors="coerce") == selected_threshold].copy()
600
- if df.empty:
601
- st.info("No lines match the selected line.")
 
 
 
 
 
 
 
 
 
602
  return
603
- books = [book for book in df["sportsbook"].dropna().unique() if book] if "sportsbook" in df.columns else []
604
- book_cols = sorted(books, key=lambda book: (_BOOK_PRIORITY.index(book) if book in _BOOK_PRIORITY else 99, book))
605
- if not book_cols:
606
- st.info("No sportsbook columns found.")
607
- return
608
- sides_present = df["selection_side"].dropna().astype(str).str.lower().unique().tolist() if "selection_side" in df.columns else ["over"]
609
- over_numeric = pd.DataFrame()
610
- under_numeric = pd.DataFrame()
611
- for side in ["over", "under"]:
612
- if side not in sides_present or (family == "hr" and side == "under"):
613
- continue
614
- side_df = df[df["selection_side"].astype(str).str.lower() == side].copy()
615
- if side_df.empty:
616
- continue
617
- if "over" in sides_present and "under" in sides_present:
618
- st.markdown(f"**{'Over' if side == 'over' else 'Under'}**")
619
- numeric_df, display_df = _build_side_table(side_df, book_cols, prev_snap, steam_set, model_probs)
620
- css_df = _build_css_df(display_df, numeric_df, [col for col in book_cols if col in display_df.columns])
621
- display_df, numeric_df, css_df = _render_sortable_grid(display_df, numeric_df, css_df, family=family, side=side)
622
- if side == "over":
623
- over_numeric = numeric_df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  else:
625
- under_numeric = numeric_df
626
- market_label = next((label for market, label in _MARKETS if market == family), family)
627
- side_label = f"{side.title()} {selected_threshold:.1f} K" if family == "k" and selected_threshold is not None else side.title()
628
- try:
629
- png_bytes = _render_table_png(display_df, css_df, market_label=market_label, side_label=side_label)
630
- st.download_button(
631
- label="Export PNG",
632
- data=png_bytes,
633
- file_name=f"kasper_odds_{family}_{side}_{datetime.now().strftime('%Y%m%d_%H%M')}.png",
634
- mime="image/png",
635
- key=f"export_{family}_{side}_{selected_threshold}_{id(display_df)}",
636
- )
637
- except Exception:
638
- pass
639
- if not over_numeric.empty and not under_numeric.empty:
640
- _render_vig_section(over_numeric, under_numeric, book_cols)
 
 
 
 
 
 
 
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: