From 7506e94d61cb36f81f5d65db142b61ce486c749e Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 16:18:08 -0400 Subject: [PATCH] 05/23 Phase 16 --- CLAUDE.md | 14 ++++ core/predictor.py | 107 ++++++++++++++++++++----------- data/lottosight.db | Bin 557056 -> 557056 bytes tests/test_quick_pick.py | 135 +++++++++++++++++++++++++++++++++++++++ ui/predictor_ui.py | 48 +++++++++++++- 5 files changed, 265 insertions(+), 39 deletions(-) create mode 100644 tests/test_quick_pick.py 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 129f091c5b12f688123d4f6086b5e0fbc295194c..300aa5a79e2bf69fa0303cd80178933279d14851 100644 GIT binary patch delta 171 zcmZo@P-K>-VPCPS^s1|3R*Mg~@fhE~R=#ihx~sl~;{nv)H?l!T$8 vCY&dr;szR%y5(Vtjo7C{g`4#{+VwgZftU%1nSq!Eh*^P{ZM$9v`=##ydo44& delta 94 zcmZo@P-= 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):