From 4edc527458b4cd14458a6f1e5c3e9bae742d6c3b Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 15:05:58 -0400 Subject: [PATCH] 05/23 Phase 15 --- CLAUDE.md | 14 +++++ core/odds.py | 105 +++++++++++++++++++++++++++++++++ data/lottosight.db | Bin 557056 -> 557056 bytes tests/test_odds.py | 142 +++++++++++++++++++++++++++++++++++++++++++++ ui/analysis.py | 45 ++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 core/odds.py create mode 100644 tests/test_odds.py diff --git a/CLAUDE.md b/CLAUDE.md index 69ef410..e2cebdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -341,6 +341,20 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 15 — Odds Calculator +- [x] Write `core/odds.py` + - [x] `total_combinations(main_count, main_max, bonus_count, bonus_max) -> int` + - [x] `prize_odds(main_count, main_max, bonus_count, bonus_max) -> list[dict]` — generates all prize tiers dynamically, sorted hardest-first by odds + - [x] `game_odds(game_id) -> list[dict]` — DB-backed convenience wrapper + - [x] Handles 0-bonus and 1-bonus games; bonus_count ≥ 2 returns jackpot only + - [x] Verified against known Powerball (1 in 292,201,338) and Mega Millions (1 in 302,575,350) jackpot odds +- [x] Add "Odds" tab to `ui/analysis.py` (8th tab, ttk.Treeview — no matplotlib) + - [x] Columns: Prize Tier | Odds (1 in X) | Probability + - [x] Refreshes when game dropdown changes +- [x] Write `tests/test_odds.py` — 22 tests (296/296 total passing) + +--- + ### ✅ Phase 14 — Custom Game Management - [x] Add `_BUILTIN_GAMES = {"Powerball", "Mega Millions"}` constant to `db/models.py` - [x] Add `add_game(name, main_count, main_max, bonus_count, bonus_max) -> int` diff --git a/core/odds.py b/core/odds.py new file mode 100644 index 0000000..2c9a279 --- /dev/null +++ b/core/odds.py @@ -0,0 +1,105 @@ +""" +core/odds.py +------------ +Mathematical prize-odds calculator using combinatorics. +Supports games with 0 or 1 bonus ball; bonus_count >= 2 returns jackpot only. +""" + +import math +from db.models import get_game_by_id + + +def _comb(n: int, r: int) -> int: + if r < 0 or r > n: + return 0 + return math.comb(n, r) + + +def total_combinations(main_count: int, main_max: int, + bonus_count: int, bonus_max: int) -> int: + """Total distinct tickets for the given game parameters.""" + main = _comb(main_max, main_count) + if bonus_count == 0: + return main + if bonus_count == 1: + return main * bonus_max + return main * _comb(bonus_max, bonus_count) + + +def _tier_ways(main_count: int, main_max: int, + bonus_count: int, bonus_max: int, + req_main: int, req_bonus: bool) -> int: + """Tickets matching exactly req_main main balls and the given bonus state.""" + main_ways = _comb(main_count, req_main) * _comb(main_max - main_count, main_count - req_main) + if bonus_count == 0: + return main_ways if not req_bonus else 0 + return main_ways * (1 if req_bonus else bonus_max - 1) + + +def _fmt_odds(odds: float) -> str: + rounded = round(odds) + if abs(odds - rounded) < 0.01: + return f"1 in {rounded:,}" + return f"1 in {odds:,.2f}" + + +def prize_odds(main_count: int, main_max: int, + bonus_count: int, bonus_max: int) -> list[dict]: + """ + Returns a list of dicts for each prize tier: + {tier, ways, total, odds, odds_str, probability} + Tiers are generated dynamically; bonus_count > 1 returns jackpot only. + """ + total = total_combinations(main_count, main_max, bonus_count, bonus_max) + + if bonus_count > 1: + return [{"tier": "Jackpot", "ways": 1, "total": total, + "odds": float(total), "odds_str": _fmt_odds(float(total)), + "probability": 1.0 / total}] + + rows = [] + for k in range(main_count, -1, -1): + tier_defs = [] + + if bonus_count == 1: + if k == main_count: + label = "Jackpot" + elif k > 0: + label = f"Match {k} + Bonus" + else: + label = "Bonus Only" + tier_defs.append((k, True, label)) + + if bonus_count == 0 and k == main_count: + tier_defs.append((k, False, "Jackpot")) + elif k > 0: + tier_defs.append((k, False, f"Match {k}")) + # k == 0 no-bonus → No Prize, skip + + for req_main, req_bonus, label in tier_defs: + ways = _tier_ways(main_count, main_max, bonus_count, bonus_max, req_main, req_bonus) + if ways <= 0: + continue + odds = total / ways + rows.append({ + "tier": label, + "ways": ways, + "total": total, + "odds": odds, + "odds_str": _fmt_odds(odds), + "probability": ways / total, + }) + + rows.sort(key=lambda r: r["odds"], reverse=True) + return rows + + +def game_odds(game_id: int) -> list[dict]: + """Compute prize odds for a game looked up by ID.""" + game = get_game_by_id(game_id) + if game is None: + return [] + return prize_odds( + game["main_count"], game["main_max"], + game["bonus_count"], game["bonus_max"], + ) diff --git a/data/lottosight.db b/data/lottosight.db index 1f247f9c5c95af915124f733f16a71841b3b892f..129f091c5b12f688123d4f6086b5e0fbc295194c 100644 GIT binary patch delta 168 zcmZo@P-ZEQZu$Vdfbf}o23Q%+N+m80P9gINC1jNih%mT!$K+LxNZ3nyXcK{~2I1&H= delta 101 zcmZo@P-2E{=S1<@H+rN)+7=D diff --git a/tests/test_odds.py b/tests/test_odds.py new file mode 100644 index 0000000..89d2850 --- /dev/null +++ b/tests/test_odds.py @@ -0,0 +1,142 @@ +""" +tests/test_odds.py +------------------ +Tests for core/odds.py — combinatorics-based prize-odds calculator. + +Reference values: + Powerball 5/69 + 1/26 → total 292,201,338 + Mega Millions 5/70 + 1/25 → total 302,575,350 + UK-style 6/49 no bonus → total 13,983,816 +""" + +import pytest +from core.odds import total_combinations, prize_odds, game_odds +from db.models import get_game_by_name, add_game + +# ── total_combinations ──────────────────────────────────────────────────────── + +def test_total_powerball(tmp_db): + assert total_combinations(5, 69, 1, 26) == 292_201_338 + +def test_total_mega_millions(tmp_db): + assert total_combinations(5, 70, 1, 25) == 302_575_350 + +def test_total_no_bonus(tmp_db): + # C(49, 6) = 13,983,816 + assert total_combinations(6, 49, 0, 0) == 13_983_816 + +def test_total_single_ball_no_bonus(tmp_db): + # C(10, 1) = 10 + assert total_combinations(1, 10, 0, 0) == 10 + + +# ── prize_odds — Powerball (5/69 + 1/26) ───────────────────────────────────── + +def test_powerball_jackpot_odds(tmp_db): + rows = prize_odds(5, 69, 1, 26) + jackpot = next(r for r in rows if r["tier"] == "Jackpot") + assert jackpot["ways"] == 1 + assert jackpot["odds"] == pytest.approx(292_201_338, rel=1e-6) + +def test_powerball_match5_odds(tmp_db): + rows = prize_odds(5, 69, 1, 26) + m5 = next(r for r in rows if r["tier"] == "Match 5") + assert m5["ways"] == 25 + assert m5["odds"] == pytest.approx(11_688_053.52, rel=1e-4) + +def test_powerball_match4_bonus_odds(tmp_db): + rows = prize_odds(5, 69, 1, 26) + m4b = next(r for r in rows if r["tier"] == "Match 4 + Bonus") + assert m4b["ways"] == 320 + assert m4b["odds"] == pytest.approx(913_129.18, rel=1e-4) + +def test_powerball_bonus_only_odds(tmp_db): + rows = prize_odds(5, 69, 1, 26) + bo = next(r for r in rows if r["tier"] == "Bonus Only") + # C(5,0)*C(64,5)*1 = 7,624,512 ways + assert bo["ways"] == 7_624_512 + assert bo["odds"] == pytest.approx(38.32, rel=1e-3) + +def test_powerball_tier_count(tmp_db): + rows = prize_odds(5, 69, 1, 26) + # Jackpot, Match5, M4+B, M4, M3+B, M3, M2+B, M2, M1+B, M1, BonusOnly = 11 + assert len(rows) == 11 + +def test_powerball_tiers_sorted_hardest_first(tmp_db): + rows = prize_odds(5, 69, 1, 26) + odds_list = [r["odds"] for r in rows] + assert odds_list == sorted(odds_list, reverse=True) + +def test_powerball_probabilities_sum_to_less_than_one(tmp_db): + rows = prize_odds(5, 69, 1, 26) + total_prob = sum(r["probability"] for r in rows) + assert total_prob < 1.0 + +def test_powerball_ways_sum_lte_total(tmp_db): + rows = prize_odds(5, 69, 1, 26) + total = rows[0]["total"] + assert sum(r["ways"] for r in rows) <= total + + +# ── prize_odds — no-bonus game (6/49) ──────────────────────────────────────── + +def test_no_bonus_jackpot_odds(tmp_db): + rows = prize_odds(6, 49, 0, 0) + jackpot = next(r for r in rows if r["tier"] == "Jackpot") + assert jackpot["ways"] == 1 + assert jackpot["odds"] == pytest.approx(13_983_816, rel=1e-6) + +def test_no_bonus_tier_count(tmp_db): + # Jackpot + Match5 + Match4 + Match3 + Match2 + Match1 = 6 + rows = prize_odds(6, 49, 0, 0) + assert len(rows) == 6 + +def test_no_bonus_no_bonus_tiers(tmp_db): + rows = prize_odds(6, 49, 0, 0) + assert all("Bonus" not in r["tier"] for r in rows) + +def test_no_bonus_tier_labels(tmp_db): + rows = prize_odds(6, 49, 0, 0) + labels = [r["tier"] for r in rows] + assert labels == ["Jackpot", "Match 5", "Match 4", "Match 3", "Match 2", "Match 1"] + + +# ── prize_odds — Mega Millions (5/70 + 1/25) ───────────────────────────────── + +def test_mega_millions_jackpot_odds(tmp_db): + rows = prize_odds(5, 70, 1, 25) + jackpot = next(r for r in rows if r["tier"] == "Jackpot") + assert jackpot["odds"] == pytest.approx(302_575_350, rel=1e-6) + + +# ── game_odds (DB-backed) ───────────────────────────────────────────────────── + +def test_game_odds_powerball(tmp_db): + game = get_game_by_name("Powerball") + rows = game_odds(game["id"]) + assert len(rows) == 11 + jackpot = next(r for r in rows if r["tier"] == "Jackpot") + assert jackpot["odds"] == pytest.approx(292_201_338, rel=1e-6) + +def test_game_odds_invalid_id(tmp_db): + assert game_odds(99999) == [] + +def test_game_odds_custom_no_bonus(tmp_db): + gid = add_game("UK Style", 6, 49, bonus_count=0, bonus_max=0) + rows = game_odds(gid) + assert len(rows) == 6 + jackpot = next(r for r in rows if r["tier"] == "Jackpot") + assert jackpot["odds"] == pytest.approx(13_983_816, rel=1e-6) + + +# ── odds_str formatting ─────────────────────────────────────────────────────── + +def test_jackpot_odds_str_integer(tmp_db): + rows = prize_odds(5, 69, 1, 26) + jackpot = next(r for r in rows if r["tier"] == "Jackpot") + assert jackpot["odds_str"] == "1 in 292,201,338" + +def test_match5_odds_str_decimal(tmp_db): + rows = prize_odds(5, 69, 1, 26) + m5 = next(r for r in rows if r["tier"] == "Match 5") + assert m5["odds_str"].startswith("1 in 11,688,053") diff --git a/ui/analysis.py b/ui/analysis.py index d9e2a70..1fb82a8 100644 --- a/ui/analysis.py +++ b/ui/analysis.py @@ -28,6 +28,7 @@ from core.analyzer import ( pair_analysis, odd_even_ratio, sum_range_analysis, delta_analysis, ) from core.exporter import export_frequency_excel, ensure_exports_dir +from core.odds import game_odds _LAST_N_OPTIONS = { "All draws": None, @@ -83,6 +84,34 @@ class AnalysisScreen(ttk.Frame): self._oe_fig, self._oe_canvas = self._make_tab(nb, "Odd/Even") self._sum_fig, self._sum_canvas = self._make_tab(nb, "Sum Range") self._delta_fig, self._delta_canvas = self._make_tab(nb, "Deltas") + self._make_odds_tab(nb) + + def _make_odds_tab(self, notebook): + frame = ttk.Frame(notebook) + notebook.add(frame, text="Odds") + + ttk.Label( + frame, + text="Mathematical odds based on the game's pool size. " + "Not all tiers pay prizes in every game.", + foreground="#666666", padding=(8, 6, 8, 2), + ).pack(anchor="w") + + cols = ("tier", "odds", "probability") + tv = ttk.Treeview(frame, columns=cols, show="headings", selectmode="none") + tv.heading("tier", text="Prize Tier") + tv.heading("odds", text="Odds (1 in X)") + tv.heading("probability", text="Probability") + tv.column("tier", width=210, anchor="w") + tv.column("odds", width=210, anchor="e") + tv.column("probability", width=160, anchor="e") + + vsb = ttk.Scrollbar(frame, orient="vertical", command=tv.yview) + tv.configure(yscrollcommand=vsb.set) + vsb.pack(side="right", fill="y") + tv.pack(fill="both", expand=True, padx=8, pady=(0, 8)) + + self._odds_tv = tv def _make_tab(self, notebook, title): frame = ttk.Frame(notebook) @@ -132,6 +161,7 @@ class AnalysisScreen(ttk.Frame): self._redraw_odd_even() self._redraw_sum_range() self._redraw_deltas() + self._redraw_odds() def _redraw_frequency(self): self._freq_fig.clear() @@ -196,6 +226,21 @@ class AnalysisScreen(ttk.Frame): _draw_deltas(ax, self._game_id) self._delta_canvas.draw() + def _redraw_odds(self): + for row in self._odds_tv.get_children(): + self._odds_tv.delete(row) + if self._game_id is None: + return + for r in game_odds(self._game_id): + p = r["probability"] + if p >= 0.001: + pct = f"{p * 100:.4f}%" + elif p >= 0.000001: + pct = f"{p * 100:.6f}%" + else: + pct = f"{p:.2e}" + self._odds_tv.insert("", "end", values=(r["tier"], r["odds_str"], pct)) + def _export_frequency(self): if self._game_id is None: messagebox.showinfo("Export", "Select a game first.")