diff --git a/CLAUDE.md b/CLAUDE.md index fa429da..224b781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -341,6 +341,20 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 17 — Predictor Power Features +- [x] Add `quick_pick(game_id, exclude=None)` to `core/predictor.py` — pure random, no draw history required +- [x] Add `exclude: set | None = None` parameter to all 5 existing strategies + `_random_ticket` + `_fill_to_count` + - [x] Falls back silently to full pool if excluded numbers would leave fewer candidates than `main_count` + - [x] Add `_safe_pool(game, exclude)` helper used by weighted_random and monte_carlo +- [x] Update `ui/predictor_ui.py` + - [x] Add "Quick Pick" to `_STRATEGIES` and `_DESCRIPTIONS` + - [x] Add "Exclude:" entry field to Generate tab bar (comma/space-separated integers) + - [x] Pass parsed exclusion set to strategy on generate + - [x] Add "Copy" button — copies all generated tickets to clipboard as formatted text; enabled/disabled with generate/clear +- [x] Write `tests/test_quick_pick.py` — 20 tests (326/326 total passing) + +--- + ### ✅ Phase 16 — Lottery Ball Display - [x] Write `ui/widgets.py` - [x] `ball_color(number, is_bonus) -> (bg, fg)` — range-based colour lookup (pure, no Tk) diff --git a/core/predictor.py b/core/predictor.py index 2f2da0b..4af10ed 100644 --- a/core/predictor.py +++ b/core/predictor.py @@ -1,11 +1,16 @@ """ core/predictor.py ----------------- -Five prediction strategies for LottoSight. +Six 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). + +All strategies accept an optional `exclude` keyword argument (set[int]) +that removes specific main-ball numbers from consideration. If excluding +those numbers would leave fewer candidates than main_count, the exclusion +is silently ignored (fallback to the full pool). """ import random @@ -19,18 +24,26 @@ 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"])) +def _safe_pool(game: dict, exclude: set) -> list[int]: + """Full main-ball pool minus excluded numbers; falls back to full pool if too few.""" + full = list(range(1, game["main_max"] + 1)) + filtered = [n for n in full if n not in exclude] + return filtered if len(filtered) >= game["main_count"] else full + + +def _random_ticket(game: dict, exclude: set | None = None) -> dict: + """Fully random fallback ticket, respecting exclusions.""" + pool = _safe_pool(game, exclude or set()) + numbers = sorted(random.sample(pool, 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): +def _random_bonus(game: dict) -> int | None: return random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None -def _hot_bonus(draws, game): +def _hot_bonus(draws, game: dict) -> int | None: """Most frequent historical bonus ball, or random if no data.""" if game["bonus_count"] == 0: return None @@ -40,27 +53,32 @@ def _hot_bonus(draws, game): return random.randint(1, game["bonus_max"]) -def _fill_to_count(chosen: list, game: dict) -> list: +def _fill_to_count(chosen: list, game: dict, exclude: set | None = None) -> list: """Pad chosen with random unused numbers if fewer than main_count.""" + excl = exclude or set() 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) + used = set(chosen) + pool = [n for n in range(1, game["main_max"] + 1) if n not in used and n not in excl] + if len(pool) < needed: + pool = [n for n in range(1, game["main_max"] + 1) if n not in used] + chosen = chosen + random.sample(pool, min(needed, len(pool))) return sorted(chosen[: game["main_count"]]) # ── Strategy 1: Hot Numbers ─────────────────────────────────────────────────── -def hot_numbers(game_id, last_n=100): +def hot_numbers(game_id: int, last_n: int = 100, exclude: set | None = None) -> dict: """ 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. """ + excl = exclude or set() game = get_game_by_id(game_id) draws = get_all_draws_numbers(game_id) if not draws: - return _random_ticket(game) + return _random_ticket(game, excl) recent = draws[-last_n:] if last_n and last_n > 0 else draws @@ -68,63 +86,67 @@ def hot_numbers(game_id, last_n=100): 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) + top = [n for n, _ in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0])) + if n not in excl] + numbers = _fill_to_count(top[: game["main_count"]], game, excl) bonus = _hot_bonus(draws, game) return {"numbers": numbers, "bonus": bonus} # ── Strategy 2: Due Numbers ─────────────────────────────────────────────────── -def due_numbers(game_id): +def due_numbers(game_id: int, exclude: set | None = None) -> dict: """ Numbers with the largest gap (most overdue) based on historical frequency. Falls back to random if there is no history. """ + excl = exclude or set() game = get_game_by_id(game_id) - gaps = gap_analysis(game_id) # {number: gap} — empty dict if no draws + gaps = gap_analysis(game_id) if not gaps: - return _random_ticket(game) + return _random_ticket(game, excl) - # 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) + top = [n for n, _ in sorted(gaps.items(), key=lambda kv: (-kv[1], kv[0])) + if n not in excl] + numbers = _fill_to_count(top[: game["main_count"]], game, excl) bonus = _random_bonus(game) return {"numbers": numbers, "bonus": bonus} # ── Strategy 3: Weighted Random ─────────────────────────────────────────────── -def weighted_random(game_id): +def weighted_random(game_id: int, exclude: set | None = None) -> dict: """ Random draw with probability proportional to historical frequency. Numbers that have never appeared receive a minimum weight of 1 so they remain in contention. """ + excl = exclude or set() game = get_game_by_id(game_id) - freq = frequency_analysis(game_id) # {number: count} + freq = frequency_analysis(game_id) - pool = list(range(1, game["main_max"] + 1)) + pool = _safe_pool(game, excl) 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) + 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): +def monte_carlo(game_id: int, simulations: int = 10_000, + exclude: set | None = None) -> dict: """ Run `simulations` weighted-random draws; tally how often each number is selected; return the top main_count by tally count. """ + excl = exclude or set() game = get_game_by_id(game_id) freq = frequency_analysis(game_id) - pool = list(range(1, game["main_max"] + 1)) + pool = _safe_pool(game, excl) weights = np.array([freq.get(n, 1) for n in pool], dtype=float) weights /= weights.sum() @@ -134,36 +156,38 @@ def monte_carlo(game_id, simulations=10_000): tally.update(ticket.tolist()) top = [n for n, _ in tally.most_common(game["main_count"])] - numbers = _fill_to_count(top, game) + numbers = _fill_to_count(top, game, excl) bonus = _random_bonus(game) return {"numbers": numbers, "bonus": bonus} # ── Strategy 5: Positional Pick ─────────────────────────────────────────────── -def positional_pick(game_id): +def positional_pick(game_id: int, exclude: set | None = None) -> dict: """ For each draw position, select the most frequently appearing number that has not already been chosen for a previous position. """ + excl = exclude or set() game = get_game_by_id(game_id) - pos_freq = positional_frequency(game_id) # {pos: {number: count}} + pos_freq = positional_frequency(game_id) if not pos_freq or not any(pos_freq.values()): - return _random_ticket(game) + return _random_ticket(game, excl) 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 + freqs = pos_freq.get(pos, {}) ranked = sorted(freqs.items(), key=lambda kv: (-kv[1], kv[0])) - picked = next((n for n, _ in ranked if n not in used), None) + picked = next((n for n, _ in ranked if n not in used and n not in excl), 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] + available = [n for n in range(1, game["main_max"] + 1) + if n not in used and n not in excl] + if not available: + available = [n for n in range(1, game["main_max"] + 1) if n not in used] picked = random.choice(available) selected.append(picked) @@ -171,3 +195,14 @@ def positional_pick(game_id): bonus = _random_bonus(game) return {"numbers": sorted(selected), "bonus": bonus} + + +# ── Strategy 6: Quick Pick ──────────────────────────────────────────────────── + +def quick_pick(game_id: int, exclude: set | None = None) -> dict: + """ + Pure random selection from the full number pool. + Requires no historical draw data. + """ + game = get_game_by_id(game_id) + return _random_ticket(game, exclude or set()) diff --git a/data/lottosight.db b/data/lottosight.db index 129f091..300aa5a 100644 Binary files a/data/lottosight.db and b/data/lottosight.db differ diff --git a/tests/test_quick_pick.py b/tests/test_quick_pick.py new file mode 100644 index 0000000..ee95ce3 --- /dev/null +++ b/tests/test_quick_pick.py @@ -0,0 +1,135 @@ +""" +tests/test_quick_pick.py +------------------------ +Tests for the quick_pick strategy and the exclude parameter +added to all predictor strategies in Phase 17. +""" + +import pytest +from db.models import get_game_by_name, add_game, insert_draw +from core.predictor import ( + quick_pick, hot_numbers, due_numbers, + weighted_random, monte_carlo, positional_pick, +) + + +@pytest.fixture +def pb(tmp_db): + game = get_game_by_name("Powerball") + insert_draw(game["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7) + insert_draw(game["id"], "2024-01-03", [1, 7, 13, 45, 61], bonus=15) + insert_draw(game["id"], "2024-01-05", [2, 13, 22, 36, 55], bonus=3) + return game + + +# ── quick_pick — basic validity ─────────────────────────────────────────────── + +def test_quick_pick_count(pb): + assert len(quick_pick(pb["id"])["numbers"]) == pb["main_count"] + +def test_quick_pick_range(pb): + result = quick_pick(pb["id"]) + assert all(1 <= n <= pb["main_max"] for n in result["numbers"]) + +def test_quick_pick_no_duplicates(pb): + nums = quick_pick(pb["id"])["numbers"] + assert len(nums) == len(set(nums)) + +def test_quick_pick_sorted(pb): + nums = quick_pick(pb["id"])["numbers"] + assert nums == sorted(nums) + +def test_quick_pick_bonus_in_range(pb): + bonus = quick_pick(pb["id"])["bonus"] + assert bonus is not None + assert 1 <= bonus <= pb["bonus_max"] + +def test_quick_pick_empty_db(tmp_db): + game = get_game_by_name("Powerball") + result = quick_pick(game["id"]) + assert len(result["numbers"]) == 5 + assert all(1 <= n <= 69 for n in result["numbers"]) + +def test_quick_pick_no_bonus_game(tmp_db): + gid = add_game("NoBonusLotto", 6, 49, bonus_count=0, bonus_max=0) + result = quick_pick(gid) + assert result["bonus"] is None + assert len(result["numbers"]) == 6 + assert all(1 <= n <= 49 for n in result["numbers"]) + + +# ── quick_pick — exclude parameter ─────────────────────────────────────────── + +def test_quick_pick_exclude_numbers(pb): + excl = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + result = quick_pick(pb["id"], exclude=excl) + assert not any(n in excl for n in result["numbers"]) + +def test_quick_pick_exclude_tight(pb): + # Exclude all but exactly main_count numbers — should still pick valid ticket + excl = set(range(1, 65)) # leaves 65–69 (exactly 5 for Powerball) + result = quick_pick(pb["id"], exclude=excl) + assert len(result["numbers"]) == 5 + assert all(n >= 65 for n in result["numbers"]) + +def test_quick_pick_exclude_too_many_falls_back(pb): + # Exclude so many that not enough remain — silently falls back + excl = set(range(1, 69)) # only [69] left, need 5 + result = quick_pick(pb["id"], exclude=excl) + assert len(result["numbers"]) == 5 # must always return a full ticket + +def test_quick_pick_exclude_empty_set(pb): + result = quick_pick(pb["id"], exclude=set()) + assert len(result["numbers"]) == 5 + +def test_quick_pick_exclude_none(pb): + result = quick_pick(pb["id"], exclude=None) + assert len(result["numbers"]) == 5 + + +# ── exclude parameter — all existing strategies ─────────────────────────────── + +def test_hot_numbers_exclude(pb): + excl = {1, 13, 61} # the most frequent numbers in our fixture + result = hot_numbers(pb["id"], exclude=excl) + assert not any(n in excl for n in result["numbers"]) + assert len(result["numbers"]) == 5 + +def test_due_numbers_exclude(pb): + excl = {36, 69} + result = due_numbers(pb["id"], exclude=excl) + assert not any(n in excl for n in result["numbers"]) + assert len(result["numbers"]) == 5 + +def test_weighted_random_exclude(pb): + excl = {1, 7, 13, 22, 36, 45, 55, 61, 69} + result = weighted_random(pb["id"], exclude=excl) + assert not any(n in excl for n in result["numbers"]) + assert len(result["numbers"]) == 5 + +def test_monte_carlo_exclude(pb): + excl = {1, 13} + result = monte_carlo(pb["id"], simulations=200, exclude=excl) + assert not any(n in excl for n in result["numbers"]) + assert len(result["numbers"]) == 5 + +def test_positional_pick_exclude(pb): + excl = {1, 2, 3, 4, 5} + result = positional_pick(pb["id"], exclude=excl) + assert not any(n in excl for n in result["numbers"]) + assert len(result["numbers"]) == 5 + +def test_exclude_none_unchanged(pb): + # All strategies accept exclude=None without breaking + for fn in (hot_numbers, due_numbers, weighted_random, positional_pick): + result = fn(pb["id"], exclude=None) + assert len(result["numbers"]) == 5 + +def test_exclude_empty_set_unchanged(pb): + for fn in (hot_numbers, due_numbers, weighted_random, positional_pick): + result = fn(pb["id"], exclude=set()) + assert len(result["numbers"]) == 5 + +def test_monte_carlo_exclude_none(pb): + result = monte_carlo(pb["id"], simulations=100, exclude=None) + assert len(result["numbers"]) == 5 diff --git a/ui/predictor_ui.py b/ui/predictor_ui.py index e35d764..3effb69 100644 --- a/ui/predictor_ui.py +++ b/ui/predictor_ui.py @@ -19,7 +19,7 @@ from db.models import ( delete_prediction, delete_all_predictions, ) from core.predictor import ( - hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, + hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, quick_pick, ) from core.checker import check_ticket, parse_numbers from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir @@ -33,6 +33,7 @@ _STRATEGIES = { "Weighted Random": weighted_random, "Monte Carlo": monte_carlo, "Positional": positional_pick, + "Quick Pick": quick_pick, } _DESCRIPTIONS = { @@ -41,6 +42,7 @@ _DESCRIPTIONS = { "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).", + "Quick Pick": "Pure random selection — no historical data required.", } _TICKET_COUNTS = [str(n) for n in range(1, 11)] @@ -115,8 +117,12 @@ class PredictorScreen(ttk.Frame): values=_TICKET_COUNTS, state="readonly", width=4, ).pack(side="left", padx=(4, 0)) + ttk.Label(bar, text="Exclude:", padding=(12, 0, 0, 0)).pack(side="left") + self._exclude_var = tk.StringVar() + ttk.Entry(bar, textvariable=self._exclude_var, width=14).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)) + self._gen_btn.pack(side="left", padx=(12, 0)) # Results treeview tree_frame = ttk.Frame(parent) @@ -164,6 +170,11 @@ class PredictorScreen(ttk.Frame): ) self._save_btn.pack(side="right") + self._copy_btn = ttk.Button( + bottom, text="Copy", command=self._copy_tickets, state="disabled" + ) + self._copy_btn.pack(side="right", padx=(0, 4)) + ttk.Button(bottom, text="Clear", command=self._clear ).pack(side="right", padx=(0, 4)) ttk.Button(bottom, text="Export CSV", command=self._export_csv @@ -325,10 +336,12 @@ class PredictorScreen(ttk.Frame): self.update_idletasks() try: - tickets = [strategy_fn(self._game_id) for _ in range(count)] + excl = self._parse_exclude() + tickets = [strategy_fn(self._game_id, exclude=excl) for _ in range(count)] self._tickets = tickets self._display(tickets) self._save_btn.config(state="normal") + self._copy_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: @@ -360,6 +373,34 @@ class PredictorScreen(ttk.Frame): self._refresh_saved() logger.info("[PREDICT] Saved %d prediction(s) to DB", saved) + def _parse_exclude(self) -> set: + raw = self._exclude_var.get().strip() + if not raw: + return set() + result = set() + for tok in raw.replace(",", " ").split(): + try: + result.add(int(tok)) + except ValueError: + pass + return result + + def _copy_tickets(self): + if not self._tickets: + return + game = get_game_by_name(self._game_var.get()) + show_bonus = game is not None and game["bonus_count"] > 0 + lines = [] + for i, t in enumerate(self._tickets, 1): + nums = " ".join(f"{n:02d}" for n in t["numbers"]) + line = f"Ticket {i}: {nums}" + if show_bonus and t["bonus"] is not None: + line += f" + {t['bonus']:02d}" + lines.append(line) + self.clipboard_clear() + self.clipboard_append("\n".join(lines)) + self._status_var.set("Copied to clipboard.") + def _on_gen_row_select(self, _event=None): for w in self._gen_detail.winfo_children(): w.destroy() @@ -383,6 +424,7 @@ class PredictorScreen(ttk.Frame): for w in self._gen_detail.winfo_children(): w.destroy() self._save_btn.config(state="disabled") + self._copy_btn.config(state="disabled") self._status_var.set("") def _export_excel(self):