05/23 update prediction accuration
This commit is contained in:
@@ -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 10th–90th 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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user