diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b104b05..eb3d5e2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(python -m pytest tests/test_fetcher.py -v)", "Bash(python -m pytest -v)", "Bash(python -m pytest tests/test_history.py -v)", - "Bash(python -m pytest tests/test_analyzer.py -v)" + "Bash(python -m pytest tests/test_analyzer.py -v)", + "Bash(python -m pytest tests/test_predictor.py -v)", + "Bash(python -m pytest)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index e6fb4fa..828c009 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,20 +276,21 @@ All actions are logged to console and optionally to a log file: --- -### πŸ”² Phase 5 β€” Prediction Engine -- [ ] Write `core/predictor.py` - - [ ] `hot_numbers(game_id, last_n)` - - [ ] `due_numbers(game_id)` - - [ ] `weighted_random(game_id)` - - [ ] `monte_carlo(game_id, simulations=10000)` - - [ ] `positional_pick(game_id)` -- [ ] Write `ui/predictor_ui.py` - - [ ] Strategy selector dropdown - - [ ] Number of tickets input - - [ ] Generate button - - [ ] Results display (generated tickets) - - [ ] Save prediction to DB -- [ ] Test all 5 strategies produce valid number sets +### βœ… Phase 5 β€” Prediction Engine +- [x] Write `core/predictor.py` + - [x] `hot_numbers(game_id, last_n=100)` β€” top-frequency + most-frequent bonus + - [x] `due_numbers(game_id)` β€” highest gap numbers from pool + - [x] `weighted_random(game_id)` β€” numpy weighted choice (min weight 1 for unseen) + - [x] `monte_carlo(game_id, simulations=10000)` β€” tally-based selection + - [x] `positional_pick(game_id)` β€” per-position best with dedup + - [x] All strategies: random fallback on empty DB +- [x] Write `ui/predictor_ui.py` + - [x] Game + strategy + ticket count dropdowns + - [x] Generate button (disables during generation) + - [x] Treeview results: #, zero-padded numbers, bonus + - [x] Save to DB (insert_prediction per ticket) + Clear + - [x] Strategy description label +- [x] 18 tests β€” all 5 strategies Γ— validity + empty DB + edge cases (124/124 total) --- diff --git a/core/predictor.py b/core/predictor.py new file mode 100644 index 0000000..2f2da0b --- /dev/null +++ b/core/predictor.py @@ -0,0 +1,173 @@ +""" +core/predictor.py +----------------- +Five prediction strategies for LottoSight. +Every function accepts game_id and returns: + {"numbers": [int, ...], "bonus": int | None} +where numbers is sorted, length == game.main_count, +all values in 1..main_max, and bonus in 1..bonus_max (or None). +""" + +import random +from collections import Counter + +import numpy as np + +from db.models import get_all_draws_numbers, get_game_by_id +from core.analyzer import frequency_analysis, gap_analysis, positional_frequency + + +# ── Internal helpers ────────────────────────────────────────────────────────── + +def _random_ticket(game): + """Fully random fallback ticket.""" + numbers = sorted(random.sample(range(1, game["main_max"] + 1), game["main_count"])) + bonus = random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None + return {"numbers": numbers, "bonus": bonus} + + +def _random_bonus(game): + return random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None + + +def _hot_bonus(draws, game): + """Most frequent historical bonus ball, or random if no data.""" + if game["bonus_count"] == 0: + return None + counter = Counter(d["bonus"] for d in draws if d["bonus"] is not None) + if counter: + return counter.most_common(1)[0][0] + return random.randint(1, game["bonus_max"]) + + +def _fill_to_count(chosen: list, game: dict) -> list: + """Pad chosen with random unused numbers if fewer than main_count.""" + needed = game["main_count"] - len(chosen) + if needed > 0: + pool = [n for n in range(1, game["main_max"] + 1) if n not in set(chosen)] + chosen = chosen + random.sample(pool, needed) + return sorted(chosen[: game["main_count"]]) + + +# ── Strategy 1: Hot Numbers ─────────────────────────────────────────────────── + +def hot_numbers(game_id, last_n=100): + """ + Top main_count most-frequent numbers from the last last_n draws. + Bonus: most frequent historical bonus ball. + Falls back to random if there is no history. + """ + game = get_game_by_id(game_id) + draws = get_all_draws_numbers(game_id) + if not draws: + return _random_ticket(game) + + recent = draws[-last_n:] if last_n and last_n > 0 else draws + + counter = Counter() + for draw in recent: + counter.update(draw["numbers"]) + + # Sort by (-count, number) for deterministic tie-breaking + top = [n for n, _ in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))] + numbers = _fill_to_count(top[: game["main_count"]], game) + bonus = _hot_bonus(draws, game) + return {"numbers": numbers, "bonus": bonus} + + +# ── Strategy 2: Due Numbers ─────────────────────────────────────────────────── + +def due_numbers(game_id): + """ + Numbers with the largest gap (most overdue) based on historical frequency. + Falls back to random if there is no history. + """ + game = get_game_by_id(game_id) + gaps = gap_analysis(game_id) # {number: gap} β€” empty dict if no draws + if not gaps: + return _random_ticket(game) + + # Sort by (-gap, number) β€” most overdue first, tie-break by number + top = [n for n, _ in sorted(gaps.items(), key=lambda kv: (-kv[1], kv[0]))] + numbers = _fill_to_count(top[: game["main_count"]], game) + bonus = _random_bonus(game) + return {"numbers": numbers, "bonus": bonus} + + +# ── Strategy 3: Weighted Random ─────────────────────────────────────────────── + +def weighted_random(game_id): + """ + Random draw with probability proportional to historical frequency. + Numbers that have never appeared receive a minimum weight of 1 + so they remain in contention. + """ + game = get_game_by_id(game_id) + freq = frequency_analysis(game_id) # {number: count} + + pool = list(range(1, game["main_max"] + 1)) + weights = np.array([freq.get(n, 1) for n in pool], dtype=float) + weights /= weights.sum() + + chosen = np.random.choice(pool, size=game["main_count"], replace=False, p=weights) + bonus = _random_bonus(game) + return {"numbers": sorted(chosen.tolist()), "bonus": bonus} + + +# ── Strategy 4: Monte Carlo ─────────────────────────────────────────────────── + +def monte_carlo(game_id, simulations=10_000): + """ + Run `simulations` weighted-random draws; tally how often each number + is selected; return the top main_count by tally count. + """ + game = get_game_by_id(game_id) + freq = frequency_analysis(game_id) + + pool = list(range(1, game["main_max"] + 1)) + weights = np.array([freq.get(n, 1) for n in pool], dtype=float) + weights /= weights.sum() + + tally = Counter() + for _ in range(simulations): + ticket = np.random.choice(pool, size=game["main_count"], replace=False, p=weights) + tally.update(ticket.tolist()) + + top = [n for n, _ in tally.most_common(game["main_count"])] + numbers = _fill_to_count(top, game) + bonus = _random_bonus(game) + return {"numbers": numbers, "bonus": bonus} + + +# ── Strategy 5: Positional Pick ─────────────────────────────────────────────── + +def positional_pick(game_id): + """ + For each draw position, select the most frequently appearing number + that has not already been chosen for a previous position. + """ + game = get_game_by_id(game_id) + pos_freq = positional_frequency(game_id) # {pos: {number: count}} + + if not pos_freq or not any(pos_freq.values()): + return _random_ticket(game) + + selected = [] + used = set() + + for pos in range(1, game["main_count"] + 1): + freqs = pos_freq.get(pos, {}) + # Sort candidates by count desc, then number asc for tie-breaking + ranked = sorted(freqs.items(), key=lambda kv: (-kv[1], kv[0])) + picked = next((n for n, _ in ranked if n not in used), None) + + if picked is None: + # All top numbers already used β€” pick any unused + available = [n for n in range(1, game["main_max"] + 1) if n not in used] + picked = random.choice(available) + + selected.append(picked) + used.add(picked) + + bonus = _random_bonus(game) + return {"numbers": sorted(selected), "bonus": bonus} diff --git a/data/lottosight.db b/data/lottosight.db index 0362e47..9089fa6 100644 Binary files a/data/lottosight.db and b/data/lottosight.db differ diff --git a/main.py b/main.py index fd7506d..f7c0040 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,7 @@ from core.fetcher import fetch_all from ui.statusbar import StatusBar from ui.history import HistoryScreen from ui.analysis import AnalysisScreen +from ui.predictor_ui import PredictorScreen logging.basicConfig( level=logging.INFO, @@ -101,6 +102,8 @@ class LottoSightApp(tk.Tk): return HistoryScreen(self._content) if name == "Analysis": return AnalysisScreen(self._content) + if name == "Predictor": + return PredictorScreen(self._content) # Placeholder for screens added in later phases placeholder = ttk.Label( self._content, text=f"{name} β€” coming soon", diff --git a/tests/test_predictor.py b/tests/test_predictor.py new file mode 100644 index 0000000..4f3dba6 --- /dev/null +++ b/tests/test_predictor.py @@ -0,0 +1,192 @@ +""" +tests/test_predictor.py +------------------------ +Tests for core/predictor.py. +Verifies that every strategy: + β€’ returns exactly main_count unique numbers + β€’ all numbers within 1..main_max + β€’ numbers are sorted + β€’ bonus within 1..bonus_max (or None when bonus_count == 0) + β€’ works on an empty DB (random fallback) + β€’ works on a populated DB +""" + +import pytest +from db.models import get_game_by_name, insert_draw +from core.predictor import ( + hot_numbers, + due_numbers, + weighted_random, + monte_carlo, + positional_pick, +) + +# ── Shared helpers ──────────────────────────────────────────────────────────── + +_DRAWS = [ + ("2024-01-01", [1, 13, 36, 61, 69], 7), + ("2024-01-03", [1, 2, 13, 45, 69], 15), + ("2024-01-05", [2, 13, 22, 36, 55], 3), + ("2024-01-08", [5, 18, 33, 50, 65], 22), + ("2024-01-10", [7, 14, 28, 42, 60], 11), +] + + +@pytest.fixture +def pb_game(tmp_db): + game = get_game_by_name("Powerball") + for date, nums, bonus in _DRAWS: + insert_draw(game["id"], date, nums, bonus=bonus, source="test") + return game + + +def _assert_valid(result, game): + """Shared validity assertions for any strategy output.""" + nums = result["numbers"] + bonus = result["bonus"] + + assert isinstance(nums, list), "numbers must be a list" + assert len(nums) == game["main_count"], f"expected {game['main_count']} numbers, got {len(nums)}" + assert nums == sorted(nums), "numbers must be sorted" + assert len(set(nums)) == len(nums), "numbers must be unique" + assert all(1 <= n <= game["main_max"] for n in nums), "all numbers must be in 1..main_max" + + if game["bonus_count"] > 0: + assert bonus is not None, "bonus must not be None" + assert 1 <= bonus <= game["bonus_max"], "bonus out of range" + else: + assert bonus is None, "bonus should be None when bonus_count == 0" + + +# ── Hot Numbers ─────────────────────────────────────────────────────────────── + +def test_hot_numbers_valid(pb_game): + result = hot_numbers(pb_game["id"]) + _assert_valid(result, get_game_by_name("Powerball")) + + +def test_hot_numbers_picks_most_frequent(pb_game): + result = hot_numbers(pb_game["id"]) + # 13 appears in all 5 draws β€” must be included + assert 13 in result["numbers"] + + +def test_hot_numbers_last_n_respected(pb_game): + # last_n=1 β†’ only draw 5: [7,14,28,42,60] + result = hot_numbers(pb_game["id"], last_n=1) + assert set(result["numbers"]) == {7, 14, 28, 42, 60} + + +def test_hot_numbers_empty_db_fallback(tmp_db): + game = get_game_by_name("Powerball") + result = hot_numbers(game["id"]) + _assert_valid(result, game) + + +# ── Due Numbers ─────────────────────────────────────────────────────────────── + +def test_due_numbers_valid(pb_game): + result = due_numbers(pb_game["id"]) + _assert_valid(result, get_game_by_name("Powerball")) + + +def test_due_numbers_picks_high_gap(pb_game): + result = due_numbers(pb_game["id"]) + # Numbers that never appeared have gap = total draws (5) + # and should be favoured; at minimum, recently appearing numbers + # (gap=0) should NOT all dominate the ticket. + # Verify the ticket is valid (structure is the key assertion here). + assert len(result["numbers"]) == 5 + + +def test_due_numbers_empty_db_fallback(tmp_db): + game = get_game_by_name("Powerball") + result = due_numbers(game["id"]) + _assert_valid(result, game) + + +# ── Weighted Random ─────────────────────────────────────────────────────────── + +def test_weighted_random_valid(pb_game): + result = weighted_random(pb_game["id"]) + _assert_valid(result, get_game_by_name("Powerball")) + + +def test_weighted_random_empty_db_still_valid(tmp_db): + # No history β†’ all weights equal to 1; should still produce valid ticket + game = get_game_by_name("Powerball") + result = weighted_random(game["id"]) + _assert_valid(result, game) + + +def test_weighted_random_different_runs(pb_game): + # Two runs are almost certainly different (1-in-C(69,5) β‰ˆ 1-in-11M chance of collision) + r1 = weighted_random(pb_game["id"]) + r2 = weighted_random(pb_game["id"]) + # Validate both; don't assert inequality (astronomically unlikely to collide) + _assert_valid(r1, get_game_by_name("Powerball")) + _assert_valid(r2, get_game_by_name("Powerball")) + + +# ── Monte Carlo ─────────────────────────────────────────────────────────────── + +def test_monte_carlo_valid(pb_game): + result = monte_carlo(pb_game["id"], simulations=200) + _assert_valid(result, get_game_by_name("Powerball")) + + +def test_monte_carlo_empty_db_still_valid(tmp_db): + game = get_game_by_name("Powerball") + result = monte_carlo(game["id"], simulations=100) + _assert_valid(result, game) + + +def test_monte_carlo_favours_frequent_numbers(pb_game): + # 13 appears in 4/5 draws β€” over many simulations it should be selected often. + # Run with enough simulations to make this deterministic. + result = monte_carlo(pb_game["id"], simulations=5000) + assert 13 in result["numbers"], "Monte Carlo should pick 13 (appears in 4/5 draws)" + + +# ── Positional Pick ─────────────────────────────────────────────────────────── + +def test_positional_pick_valid(pb_game): + result = positional_pick(pb_game["id"]) + _assert_valid(result, get_game_by_name("Powerball")) + + +def test_positional_pick_no_duplicates(pb_game): + # Each position contributes a unique number even if the same number + # is the most frequent at multiple positions. + result = positional_pick(pb_game["id"]) + assert len(set(result["numbers"])) == len(result["numbers"]) + + +def test_positional_pick_empty_db_fallback(tmp_db): + game = get_game_by_name("Powerball") + result = positional_pick(game["id"]) + _assert_valid(result, game) + + +# ── Mega Millions (no-bonus_count check is N/A; both games have bonus) ──────── + +def test_all_strategies_valid_for_megamillions(tmp_db): + mm = get_game_by_name("Mega Millions") + for date, nums, bonus in _DRAWS: + insert_draw(mm["id"], date, nums, bonus=bonus, source="test") + + for fn in (hot_numbers, due_numbers, weighted_random, + lambda gid: monte_carlo(gid, simulations=100), + positional_pick): + result = fn(mm["id"]) + _assert_valid(result, mm) + + +# ── Multiple tickets ────────────────────────────────────────────────────────── + +def test_generate_multiple_tickets(pb_game): + game = get_game_by_name("Powerball") + tickets = [hot_numbers(pb_game["id"]) for _ in range(5)] + assert len(tickets) == 5 + for t in tickets: + _assert_valid(t, game) diff --git a/ui/predictor_ui.py b/ui/predictor_ui.py new file mode 100644 index 0000000..a7513a9 --- /dev/null +++ b/ui/predictor_ui.py @@ -0,0 +1,200 @@ +""" +ui/predictor_ui.py +------------------ +Prediction generator screen. +Pick a strategy + game + ticket count β†’ generate β†’ save to DB. +""" + +import tkinter as tk +from tkinter import ttk +import logging + +from db.models import get_all_games, get_game_by_name, insert_prediction +from core.predictor import ( + hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, +) + +logger = logging.getLogger(__name__) + +_STRATEGIES = { + "Hot Numbers": hot_numbers, + "Due Numbers": due_numbers, + "Weighted Random": weighted_random, + "Monte Carlo": monte_carlo, + "Positional": positional_pick, +} + +_DESCRIPTIONS = { + "Hot Numbers": "Top 5 most frequent numbers from the last 100 draws.", + "Due Numbers": "Numbers most overdue based on expected frequency gap.", + "Weighted Random": "Random pick weighted by each number's historical frequency.", + "Monte Carlo": "10,000 simulated draws β€” pick the most often-selected numbers.", + "Positional": "Most frequent number at each draw position (1–5).", +} + +_TICKET_COUNTS = [str(n) for n in range(1, 11)] + + +class PredictorScreen(ttk.Frame): + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + self._game_id: int | None = None + self._tickets: list[dict] = [] # [{"numbers": [...], "bonus": int|None}] + self._strategy_name: str = "Hot Numbers" + self._build_ui() + + # ── UI construction ─────────────────────────────────────────────────────── + + def _build_ui(self): + # ── Controls bar ────────────────────────────────────────────────────── + bar = ttk.Frame(self, padding=(6, 6, 6, 4)) + bar.pack(fill="x") + + ttk.Label(bar, text="Game:").pack(side="left") + self._game_var = tk.StringVar() + self._game_cb = ttk.Combobox( + bar, textvariable=self._game_var, state="readonly", width=15 + ) + self._game_cb.pack(side="left", padx=(4, 14)) + self._game_cb.bind("<>", lambda _: self._on_game_change()) + + ttk.Label(bar, text="Strategy:").pack(side="left") + self._strategy_var = tk.StringVar(value="Hot Numbers") + strategy_cb = ttk.Combobox( + bar, textvariable=self._strategy_var, + values=list(_STRATEGIES.keys()), + state="readonly", width=16, + ) + strategy_cb.pack(side="left", padx=(4, 14)) + strategy_cb.bind("<>", lambda _: self._on_strategy_change()) + + ttk.Label(bar, text="Tickets:").pack(side="left") + self._count_var = tk.StringVar(value="1") + ttk.Combobox( + bar, textvariable=self._count_var, + values=_TICKET_COUNTS, state="readonly", width=4, + ).pack(side="left", padx=(4, 0)) + + self._gen_btn = ttk.Button(bar, text="Generate", command=self._generate) + self._gen_btn.pack(side="left", padx=(14, 0)) + + # ── Results Treeview ────────────────────────────────────────────────── + tree_frame = ttk.Frame(self) + tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 0)) + + cols = ("#", "numbers", "bonus") + self._tree = ttk.Treeview( + tree_frame, columns=cols, show="headings", selectmode="browse" + ) + self._tree.heading("#", text="#") + self._tree.heading("numbers", text="Numbers") + self._tree.heading("bonus", text="Bonus") + self._tree.column("#", width=40, anchor="center", stretch=False) + self._tree.column("numbers", width=280, anchor="w") + self._tree.column("bonus", width=70, anchor="center", stretch=False) + + vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) + self._tree.configure(yscrollcommand=vsb.set) + self._tree.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + + # ── Bottom bar ──────────────────────────────────────────────────────── + bottom = ttk.Frame(self, padding=(6, 4)) + bottom.pack(fill="x") + + self._desc_var = tk.StringVar(value=_DESCRIPTIONS["Hot Numbers"]) + ttk.Label( + bottom, textvariable=self._desc_var, + foreground="#555555", anchor="w", + ).pack(side="left", fill="x", expand=True) + + self._status_var = tk.StringVar() + ttk.Label(bottom, textvariable=self._status_var, + foreground="#27ae60").pack(side="left", padx=(8, 0)) + + self._save_btn = ttk.Button( + bottom, text="Save to DB", command=self._save, state="disabled" + ) + self._save_btn.pack(side="right") + + ttk.Button( + bottom, text="Clear", command=self._clear + ).pack(side="right", padx=(0, 4)) + + # ── Callbacks ───────────────────────────────────────────────────────────── + + def refresh(self): + self._load_games() + + def _load_games(self): + games = get_all_games(active_only=True) + names = [g["name"] for g in games] + self._game_cb["values"] = names + if not self._game_var.get() or self._game_var.get() not in names: + if names: + self._game_var.set(names[0]) + game = get_game_by_name(self._game_var.get()) + self._game_id = game["id"] if game else None + + def _on_game_change(self): + game = get_game_by_name(self._game_var.get()) + self._game_id = game["id"] if game else None + self._clear() + + def _on_strategy_change(self): + self._strategy_name = self._strategy_var.get() + self._desc_var.set(_DESCRIPTIONS.get(self._strategy_name, "")) + self._clear() + + def _generate(self): + if self._game_id is None: + self._status_var.set("Select a game first.") + return + + strategy_fn = _STRATEGIES[self._strategy_var.get()] + count = int(self._count_var.get()) + + self._clear() + self._gen_btn.config(state="disabled", text="Generating…") + self.update_idletasks() + + try: + tickets = [strategy_fn(self._game_id) for _ in range(count)] + self._tickets = tickets + self._display(tickets) + self._save_btn.config(state="normal") + self._status_var.set(f"{len(tickets)} ticket{'s' if len(tickets) != 1 else ''} generated.") + logger.info("[PREDICT] %d ticket(s) generated via %s", count, self._strategy_var.get()) + except Exception as e: + self._status_var.set(f"Error: {e}") + logger.error("[ERROR] Prediction failed: %s", e, exc_info=True) + finally: + self._gen_btn.config(state="normal", text="Generate") + + def _display(self, tickets): + self._tree.delete(*self._tree.get_children()) + game = get_game_by_name(self._game_var.get()) + show_bonus = game is not None and game["bonus_count"] > 0 + + for i, t in enumerate(tickets, start=1): + nums_str = " ".join(f"{n:02d}" for n in t["numbers"]) + bonus_str = str(t["bonus"]) if show_bonus and t["bonus"] is not None else "β€”" + self._tree.insert("", "end", values=(i, nums_str, bonus_str)) + + def _save(self): + if not self._tickets or self._game_id is None: + return + strategy_name = self._strategy_var.get() + saved = 0 + for t in self._tickets: + insert_prediction(self._game_id, strategy_name, t["numbers"], t["bonus"]) + saved += 1 + self._status_var.set(f"Saved {saved} prediction{'s' if saved != 1 else ''} to DB.") + self._save_btn.config(state="disabled") + logger.info("[PREDICT] Saved %d prediction(s) to DB", saved) + + def _clear(self): + self._tickets = [] + self._tree.delete(*self._tree.get_children()) + self._save_btn.config(state="disabled") + self._status_var.set("")