""" core/predictor.py ----------------- 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 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 _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: dict) -> int | None: return random.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None 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 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, 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: 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: 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, excl) recent = draws[-last_n:] if last_n and last_n > 0 else draws counter = Counter() for draw in recent: counter.update(draw["numbers"]) 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: 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) if not gaps: return _random_ticket(game, excl) 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: 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) 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) return {"numbers": sorted(chosen.tolist()), "bonus": bonus} # ── Strategy 4: Monte Carlo ─────────────────────────────────────────────────── 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 = _safe_pool(game, excl) 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, excl) bonus = _random_bonus(game) return {"numbers": numbers, "bonus": bonus} # ── Strategy 5: Positional Pick ─────────────────────────────────────────────── 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) if not pos_freq or not any(pos_freq.values()): return _random_ticket(game, excl) selected = [] used = set() for pos in range(1, game["main_count"] + 1): 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 and n not in excl), None) if picked is None: 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) used.add(picked) 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())