05/23 Phase 15
This commit is contained in:
@@ -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`
|
||||
|
||||
+105
@@ -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"],
|
||||
)
|
||||
Binary file not shown.
@@ -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")
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user