Files
lottosight/core/predictor.py
T
2026-05-23 11:32:54 -04:00

174 lines
6.6 KiB
Python

"""
core/predictor.py
-----------------
Five 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).
"""
import random
from collections import Counter
import numpy as np
from db.models import get_all_draws_numbers, get_game_by_id
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"]))
bonus = random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None
return {"numbers": numbers, "bonus": bonus}
def _random_bonus(game):
return random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None
def _hot_bonus(draws, game):
"""Most frequent historical bonus ball, or random if no data."""
if game["bonus_count"] == 0:
return None
counter = Counter(d["bonus"] for d in draws if d["bonus"] is not None)
if counter:
return counter.most_common(1)[0][0]
return random.randint(1, game["bonus_max"])
def _fill_to_count(chosen: list, game: dict) -> list:
"""Pad chosen with random unused numbers if fewer than main_count."""
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)
return sorted(chosen[: game["main_count"]])
# ── Strategy 1: Hot Numbers ───────────────────────────────────────────────────
def hot_numbers(game_id, last_n=100):
"""
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.
"""
game = get_game_by_id(game_id)
draws = get_all_draws_numbers(game_id)
if not draws:
return _random_ticket(game)
recent = draws[-last_n:] if last_n and last_n > 0 else draws
counter = Counter()
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)
bonus = _hot_bonus(draws, game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 2: Due Numbers ───────────────────────────────────────────────────
def due_numbers(game_id):
"""
Numbers with the largest gap (most overdue) based on historical frequency.
Falls back to random if there is no history.
"""
game = get_game_by_id(game_id)
gaps = gap_analysis(game_id) # {number: gap} — empty dict if no draws
if not gaps:
return _random_ticket(game)
# 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)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 3: Weighted Random ───────────────────────────────────────────────
def weighted_random(game_id):
"""
Random draw with probability proportional to historical frequency.
Numbers that have never appeared receive a minimum weight of 1
so they remain in contention.
"""
game = get_game_by_id(game_id)
freq = frequency_analysis(game_id) # {number: count}
pool = list(range(1, game["main_max"] + 1))
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)
return {"numbers": sorted(chosen.tolist()), "bonus": bonus}
# ── Strategy 4: Monte Carlo ───────────────────────────────────────────────────
def monte_carlo(game_id, simulations=10_000):
"""
Run `simulations` weighted-random draws; tally how often each number
is selected; return the top main_count by tally count.
"""
game = get_game_by_id(game_id)
freq = frequency_analysis(game_id)
pool = list(range(1, game["main_max"] + 1))
weights = np.array([freq.get(n, 1) for n in pool], dtype=float)
weights /= weights.sum()
tally = Counter()
for _ in range(simulations):
ticket = np.random.choice(pool, size=game["main_count"], replace=False, p=weights)
tally.update(ticket.tolist())
top = [n for n, _ in tally.most_common(game["main_count"])]
numbers = _fill_to_count(top, game)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 5: Positional Pick ───────────────────────────────────────────────
def positional_pick(game_id):
"""
For each draw position, select the most frequently appearing number
that has not already been chosen for a previous position.
"""
game = get_game_by_id(game_id)
pos_freq = positional_frequency(game_id) # {pos: {number: count}}
if not pos_freq or not any(pos_freq.values()):
return _random_ticket(game)
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)
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]
picked = random.choice(available)
selected.append(picked)
used.add(picked)
bonus = _random_bonus(game)
return {"numbers": sorted(selected), "bonus": bonus}