05/23 Phase 16
This commit is contained in:
@@ -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
@@ -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.
@@ -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
|
||||||
+45
-3
@@ -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 (1–5).",
|
"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)]
|
_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):
|
||||||
|
|||||||
Reference in New Issue
Block a user