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
- [x] Write `ui/widgets.py`
- [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
-----------------
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,44 +86,46 @@ 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()
@@ -116,15 +136,17 @@ def weighted_random(game_id):
# ── 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,35 +156,37 @@ 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
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 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)
@@ -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())
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,
)
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 (15).",
"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):