05/23 Phase 16

This commit is contained in:
2026-05-23 16:18:08 -04:00
parent 57c777dc82
commit 7506e94d61
5 changed files with 265 additions and 39 deletions
+14
View File
@@ -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 ### ✅ Phase 16 — Lottery Ball Display
- [x] Write `ui/widgets.py` - [x] Write `ui/widgets.py`
- [x] `ball_color(number, is_bonus) -> (bg, fg)` — range-based colour lookup (pure, no Tk) - [x] `ball_color(number, is_bonus) -> (bg, fg)` — range-based colour lookup (pure, no Tk)
+67 -32
View File
@@ -1,11 +1,16 @@
""" """
core/predictor.py core/predictor.py
----------------- -----------------
Five prediction strategies for LottoSight. Six prediction strategies for LottoSight.
Every function accepts game_id and returns: Every function accepts game_id and returns:
{"numbers": [int, ...], "bonus": int | None} {"numbers": [int, ...], "bonus": int | None}
where numbers is sorted, length == game.main_count, where numbers is sorted, length == game.main_count,
all values in 1..main_max, and bonus in 1..bonus_max (or None). 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 import random
@@ -19,18 +24,26 @@ from core.analyzer import frequency_analysis, gap_analysis, positional_frequency
# ── Internal helpers ────────────────────────────────────────────────────────── # ── Internal helpers ──────────────────────────────────────────────────────────
def _random_ticket(game): def _safe_pool(game: dict, exclude: set) -> list[int]:
"""Fully random fallback ticket.""" """Full main-ball pool minus excluded numbers; falls back to full pool if too few."""
numbers = sorted(random.sample(range(1, game["main_max"] + 1), game["main_count"])) 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 bonus = random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None
return {"numbers": numbers, "bonus": bonus} 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 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.""" """Most frequent historical bonus ball, or random if no data."""
if game["bonus_count"] == 0: if game["bonus_count"] == 0:
return None return None
@@ -40,27 +53,32 @@ def _hot_bonus(draws, game):
return random.randint(1, game["bonus_max"]) 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.""" """Pad chosen with random unused numbers if fewer than main_count."""
excl = exclude or set()
needed = game["main_count"] - len(chosen) needed = game["main_count"] - len(chosen)
if needed > 0: if needed > 0:
pool = [n for n in range(1, game["main_max"] + 1) if n not in set(chosen)] used = set(chosen)
chosen = chosen + random.sample(pool, needed) 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"]]) return sorted(chosen[: game["main_count"]])
# ── Strategy 1: Hot Numbers ─────────────────────────────────────────────────── # ── 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. Top main_count most-frequent numbers from the last last_n draws.
Bonus: most frequent historical bonus ball. Bonus: most frequent historical bonus ball.
Falls back to random if there is no history. Falls back to random if there is no history.
""" """
excl = exclude or set()
game = get_game_by_id(game_id) game = get_game_by_id(game_id)
draws = get_all_draws_numbers(game_id) draws = get_all_draws_numbers(game_id)
if not draws: 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 recent = draws[-last_n:] if last_n and last_n > 0 else draws
@@ -68,44 +86,46 @@ def hot_numbers(game_id, last_n=100):
for draw in recent: for draw in recent:
counter.update(draw["numbers"]) 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]))
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) numbers = _fill_to_count(top[: game["main_count"]], game, excl)
bonus = _hot_bonus(draws, game) bonus = _hot_bonus(draws, game)
return {"numbers": numbers, "bonus": bonus} return {"numbers": numbers, "bonus": bonus}
# ── Strategy 2: Due Numbers ─────────────────────────────────────────────────── # ── 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. Numbers with the largest gap (most overdue) based on historical frequency.
Falls back to random if there is no history. Falls back to random if there is no history.
""" """
excl = exclude or set()
game = get_game_by_id(game_id) 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: 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]))
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) numbers = _fill_to_count(top[: game["main_count"]], game, excl)
bonus = _random_bonus(game) bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus} return {"numbers": numbers, "bonus": bonus}
# ── Strategy 3: Weighted Random ─────────────────────────────────────────────── # ── 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. Random draw with probability proportional to historical frequency.
Numbers that have never appeared receive a minimum weight of 1 Numbers that have never appeared receive a minimum weight of 1
so they remain in contention. so they remain in contention.
""" """
excl = exclude or set()
game = get_game_by_id(game_id) 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 = np.array([freq.get(n, 1) for n in pool], dtype=float)
weights /= weights.sum() weights /= weights.sum()
@@ -116,15 +136,17 @@ def weighted_random(game_id):
# ── Strategy 4: Monte Carlo ─────────────────────────────────────────────────── # ── 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 Run `simulations` weighted-random draws; tally how often each number
is selected; return the top main_count by tally count. is selected; return the top main_count by tally count.
""" """
excl = exclude or set()
game = get_game_by_id(game_id) game = get_game_by_id(game_id)
freq = frequency_analysis(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 = np.array([freq.get(n, 1) for n in pool], dtype=float)
weights /= weights.sum() weights /= weights.sum()
@@ -134,35 +156,37 @@ def monte_carlo(game_id, simulations=10_000):
tally.update(ticket.tolist()) tally.update(ticket.tolist())
top = [n for n, _ in tally.most_common(game["main_count"])] 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) bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus} return {"numbers": numbers, "bonus": bonus}
# ── Strategy 5: Positional Pick ─────────────────────────────────────────────── # ── 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 For each draw position, select the most frequently appearing number
that has not already been chosen for a previous position. that has not already been chosen for a previous position.
""" """
excl = exclude or set()
game = get_game_by_id(game_id) 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()): if not pos_freq or not any(pos_freq.values()):
return _random_ticket(game) return _random_ticket(game, excl)
selected = [] selected = []
used = set() used = set()
for pos in range(1, game["main_count"] + 1): for pos in range(1, game["main_count"] + 1):
freqs = pos_freq.get(pos, {}) 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])) 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: 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 and n not in excl]
if not available:
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]
picked = random.choice(available) picked = random.choice(available)
@@ -171,3 +195,14 @@ def positional_pick(game_id):
bonus = _random_bonus(game) bonus = _random_bonus(game)
return {"numbers": sorted(selected), "bonus": bonus} 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())
Binary file not shown.
+135
View File
@@ -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 6569 (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
+45 -3
View File
@@ -19,7 +19,7 @@ from db.models import (
delete_prediction, delete_all_predictions, delete_prediction, delete_all_predictions,
) )
from core.predictor import ( 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.checker import check_ticket, parse_numbers
from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir
@@ -33,6 +33,7 @@ _STRATEGIES = {
"Weighted Random": weighted_random, "Weighted Random": weighted_random,
"Monte Carlo": monte_carlo, "Monte Carlo": monte_carlo,
"Positional": positional_pick, "Positional": positional_pick,
"Quick Pick": quick_pick,
} }
_DESCRIPTIONS = { _DESCRIPTIONS = {
@@ -41,6 +42,7 @@ _DESCRIPTIONS = {
"Weighted Random": "Random pick weighted by each number's historical frequency.", "Weighted Random": "Random pick weighted by each number's historical frequency.",
"Monte Carlo": "10,000 simulated draws — pick the most often-selected numbers.", "Monte Carlo": "10,000 simulated draws — pick the most often-selected numbers.",
"Positional": "Most frequent number at each draw position (15).", "Positional": "Most frequent number at each draw position (15).",
"Quick Pick": "Pure random selection — no historical data required.",
} }
_TICKET_COUNTS = [str(n) for n in range(1, 11)] _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, values=_TICKET_COUNTS, state="readonly", width=4,
).pack(side="left", padx=(4, 0)) ).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 = 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 # Results treeview
tree_frame = ttk.Frame(parent) tree_frame = ttk.Frame(parent)
@@ -164,6 +170,11 @@ class PredictorScreen(ttk.Frame):
) )
self._save_btn.pack(side="right") 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 ttk.Button(bottom, text="Clear", command=self._clear
).pack(side="right", padx=(0, 4)) ).pack(side="right", padx=(0, 4))
ttk.Button(bottom, text="Export CSV", command=self._export_csv ttk.Button(bottom, text="Export CSV", command=self._export_csv
@@ -325,10 +336,12 @@ class PredictorScreen(ttk.Frame):
self.update_idletasks() self.update_idletasks()
try: 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._tickets = tickets
self._display(tickets) self._display(tickets)
self._save_btn.config(state="normal") 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.") 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()) logger.info("[PREDICT] %d ticket(s) generated via %s", count, self._strategy_var.get())
except Exception as e: except Exception as e:
@@ -360,6 +373,34 @@ class PredictorScreen(ttk.Frame):
self._refresh_saved() self._refresh_saved()
logger.info("[PREDICT] Saved %d prediction(s) to DB", 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): def _on_gen_row_select(self, _event=None):
for w in self._gen_detail.winfo_children(): for w in self._gen_detail.winfo_children():
w.destroy() w.destroy()
@@ -383,6 +424,7 @@ class PredictorScreen(ttk.Frame):
for w in self._gen_detail.winfo_children(): for w in self._gen_detail.winfo_children():
w.destroy() w.destroy()
self._save_btn.config(state="disabled") self._save_btn.config(state="disabled")
self._copy_btn.config(state="disabled")
self._status_var.set("") self._status_var.set("")
def _export_excel(self): def _export_excel(self):