05/23 Phase 16
This commit is contained in:
+71
-36
@@ -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,63 +86,67 @@ 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()
|
||||
|
||||
chosen = np.random.choice(pool, size=game["main_count"], replace=False, p=weights)
|
||||
bonus = _random_bonus(game)
|
||||
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):
|
||||
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,36 +156,38 @@ 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
|
||||
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), 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]
|
||||
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)
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user