05/23 update prediction accuration

This commit is contained in:
2026-05-23 17:50:15 -04:00
parent 59789f3cfe
commit c61c09db94
5 changed files with 408 additions and 17 deletions
+73
View File
@@ -0,0 +1,73 @@
"""
core/filters.py
---------------
Combination-quality filters for generated lottery tickets.
A ticket is considered "weak" if it falls into a pattern that is
statistically underrepresented in real draws:
• All main numbers are even (when count >= 4)
• All main numbers are odd (when count >= 4)
• 4 or more consecutive numbers (e.g. 12-13-14-15)
• Sum of main numbers is outside the historical 10th90th percentile
None of these filters improve expected-value (all draws are IID), but
they remove tickets that players and statisticians alike would call "weak"
and shift the distribution toward historically common patterns.
"""
import numpy as np
from db.models import get_all_draws_numbers
def _has_consecutive_run(numbers: list[int], min_len: int = 4) -> bool:
"""Return True if `numbers` contains a run of at least min_len consecutive integers."""
s = sorted(numbers)
run = 1
for i in range(1, len(s)):
if s[i] == s[i - 1] + 1:
run += 1
if run >= min_len:
return True
else:
run = 1
return False
def sum_range_percentiles(
game_id: int,
low_pct: float = 10.0,
high_pct: float = 90.0,
) -> tuple[int, int] | None:
"""
Return (low, high) sum bounds derived from historical draw data.
Returns None when fewer than 10 draws exist (not enough data).
"""
draws = get_all_draws_numbers(game_id)
if len(draws) < 10:
return None
sums = [sum(d["numbers"]) for d in draws]
return int(np.percentile(sums, low_pct)), int(np.percentile(sums, high_pct))
def passes_filters(
numbers: list[int],
sum_range: tuple[int, int] | None = None,
) -> bool:
"""
Return True if the ticket passes all combination-quality checks.
Pass a pre-computed sum_range (from sum_range_percentiles) to avoid
re-querying the database on every retry.
"""
if len(numbers) >= 4:
if all(n % 2 == 0 for n in numbers):
return False
if all(n % 2 != 0 for n in numbers):
return False
if _has_consecutive_run(numbers, 4):
return False
if sum_range is not None:
lo, hi = sum_range
if not (lo <= sum(numbers) <= hi):
return False
return True
+110 -16
View File
@@ -20,6 +20,9 @@ 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
from core.filters import passes_filters, sum_range_percentiles
_MAX_FILTER_TRIES = 50
# ── Internal helpers ──────────────────────────────────────────────────────────
@@ -53,6 +56,46 @@ def _hot_bonus(draws, game: dict) -> int | None:
return random.randint(1, game["bonus_max"])
def _retry_filter(generate_fn, sum_range, max_tries: int = _MAX_FILTER_TRIES) -> list[int]:
"""
Call generate_fn() up to max_tries times; return the first numbers list
that passes combination filters, or the last generated if none do.
Used for stochastic strategies where each call produces a new candidate.
"""
last = generate_fn()
if passes_filters(last, sum_range):
return last
for _ in range(max_tries - 1):
attempt = generate_fn()
if passes_filters(attempt, sum_range):
return attempt
return last
def _swap_filter(numbers: list[int], ranked_pool: list[int],
sum_range) -> list[int]:
"""
For deterministic strategies: try swapping the weakest-ranked number in
the ticket for the best available alternative until filters pass.
ranked_pool must contain candidates sorted best-first, excluding numbers
already in the ticket.
Returns the first passing combination, or the original if none found.
"""
if passes_filters(numbers, sum_range):
return numbers
ticket = list(numbers)
for swap_idx in range(len(ticket) - 1, -1, -1):
original = ticket[swap_idx]
for alt in ranked_pool:
if alt not in ticket:
ticket[swap_idx] = alt
candidate = sorted(ticket)
if passes_filters(candidate, sum_range):
return candidate
ticket[swap_idx] = original
return numbers # graceful fallback: return original if no swap helped
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()
@@ -73,6 +116,7 @@ def hot_numbers(game_id: int, last_n: int = 100, exclude: set | None = None) ->
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.
Applies combination filters; swaps the weakest pick if the ticket is weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
@@ -86,10 +130,15 @@ def hot_numbers(game_id: int, last_n: int = 100, exclude: set | None = None) ->
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)
ranked = [n for n, _ in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
if n not in excl]
numbers = _fill_to_count(ranked[: game["main_count"]], game, excl)
sum_range = sum_range_percentiles(game_id)
ranked_pool = [n for n in ranked if n not in numbers]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
bonus = _hot_bonus(draws, game)
return {"numbers": numbers, "bonus": bonus}
@@ -99,6 +148,7 @@ 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.
Applies combination filters; swaps the lowest-gap pick if the ticket is weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
@@ -106,10 +156,15 @@ def due_numbers(game_id: int, exclude: set | None = None) -> dict:
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)
ranked = [n for n, _ in sorted(gaps.items(), key=lambda kv: (-kv[1], kv[0]))
if n not in excl]
numbers = _fill_to_count(ranked[: game["main_count"]], game, excl)
sum_range = sum_range_percentiles(game_id)
ranked_pool = [n for n in ranked if n not in numbers]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
@@ -120,6 +175,7 @@ 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.
Retries up to _MAX_FILTER_TRIES times to find a combination-quality ticket.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
@@ -129,9 +185,15 @@ def weighted_random(game_id: int, exclude: set | None = None) -> dict:
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}
sum_range = sum_range_percentiles(game_id)
def _generate():
chosen = np.random.choice(pool, size=game["main_count"], replace=False, p=weights)
return sorted(chosen.tolist())
numbers = _retry_filter(_generate, sum_range)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 4: Monte Carlo ───────────────────────────────────────────────────
@@ -141,6 +203,7 @@ def monte_carlo(game_id: int, simulations: int = 10_000,
"""
Run `simulations` weighted-random draws; tally how often each number
is selected; return the top main_count by tally count.
Applies combination filters; swaps the lowest-tally pick if ticket is weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
@@ -155,9 +218,14 @@ def monte_carlo(game_id: int, simulations: int = 10_000,
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)
ranked = [n for n, _ in tally.most_common()]
numbers = _fill_to_count(ranked[: game["main_count"]], game, excl)
sum_range = sum_range_percentiles(game_id)
ranked_pool = [n for n in ranked if n not in numbers]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}
@@ -167,6 +235,7 @@ 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.
Applies combination filters; swaps the lowest-positional-rank pick if weak.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
@@ -177,6 +246,8 @@ def positional_pick(game_id: int, exclude: set | None = None) -> dict:
selected = []
used = set()
# Also collect per-position alternates (next-best) for the swap pool
alternates_pool = []
for pos in range(1, game["main_count"] + 1):
freqs = pos_freq.get(pos, {})
@@ -189,12 +260,25 @@ def positional_pick(game_id: int, exclude: set | None = None) -> dict:
if not available:
available = [n for n in range(1, game["main_max"] + 1) if n not in used]
picked = random.choice(available)
else:
# Collect alternates for this position (for potential swap)
for n, _ in ranked:
if n != picked and n not in excl:
alternates_pool.append(n)
selected.append(picked)
used.add(picked)
numbers = sorted(selected)
sum_range = sum_range_percentiles(game_id)
# Alternates sorted by first-occurrence (positional best-first)
seen = set()
ranked_pool = [n for n in alternates_pool
if n not in numbers and not (seen.add(n) or n in seen)]
numbers = _swap_filter(numbers, ranked_pool, sum_range)
bonus = _random_bonus(game)
return {"numbers": sorted(selected), "bonus": bonus}
return {"numbers": numbers, "bonus": bonus}
# ── Strategy 6: Quick Pick ────────────────────────────────────────────────────
@@ -203,6 +287,16 @@ def quick_pick(game_id: int, exclude: set | None = None) -> dict:
"""
Pure random selection from the full number pool.
Requires no historical draw data.
Retries up to _MAX_FILTER_TRIES times to find a combination-quality ticket.
"""
excl = exclude or set()
game = get_game_by_id(game_id)
return _random_ticket(game, exclude or set())
sum_range = sum_range_percentiles(game_id)
def _generate():
pool = _safe_pool(game, excl)
return sorted(random.sample(pool, game["main_count"]))
numbers = _retry_filter(_generate, sum_range)
bonus = _random_bonus(game)
return {"numbers": numbers, "bonus": bonus}