From c61c09db94c019773b02405dd674373da5a3a704 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 17:50:15 -0400 Subject: [PATCH] 05/23 update prediction accuration --- .claude/settings.local.json | 3 +- core/filters.py | 73 ++++++++++++ core/predictor.py | 126 +++++++++++++++++--- data/lottosight.db | Bin 2002944 -> 2002944 bytes tests/test_filters.py | 223 ++++++++++++++++++++++++++++++++++++ 5 files changed, 408 insertions(+), 17 deletions(-) create mode 100644 core/filters.py create mode 100644 tests/test_filters.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 68d2596..7dd9eae 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,8 @@ "WebFetch(domain:www.valottery.com)", "Bash(pip install *)", "Bash(python -m pytest tests/ -x -q)", - "Bash(python -m pytest tests/test_phase22.py -v)" + "Bash(python -m pytest tests/test_phase22.py -v)", + "Bash(python -m pytest tests/test_filters.py -v)" ] } } diff --git a/core/filters.py b/core/filters.py new file mode 100644 index 0000000..ac3e486 --- /dev/null +++ b/core/filters.py @@ -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 diff --git a/core/predictor.py b/core/predictor.py index 4af10ed..a4f8112 100644 --- a/core/predictor.py +++ b/core/predictor.py @@ -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} diff --git a/data/lottosight.db b/data/lottosight.db index a1bff4f2f143e8ca790d3cd1765c9e7f4d805652..bc9336a9af484e8935755fe5913229baca82e27b 100644 GIT binary patch delta 867 zcmZXQ%TE(Q0LEw9F5TVkgDO&JMJ5Vq%R{IIF z!K0=JPBmy^;?*<(55&ZqM_&m?4xWsEfL=_Tg_cLyYD2ZESpGHkCnsf3UXYoNsc-e_^e5LHY^Z@)}@dj*;)kU*xDFdOoKy{k=90_z#BC+QOl>$&9z39;^N9{N33$!`tj0 z+q|JJn^$%@X!pPcbx|`%*D%45U>m`9f*k}i1UC`vB$y?bBbX=HMX*4yxP>K0DeU3n zKzc2iQe1L~Kg2iUqL>f`;hRtu3c`?JckQ}fxeBhh%fWx;-|{*ir@z|H^U4ehdLu0B z0~=5kI$EN!#9-8Xkj-kQ8N0=T80|JjK1$uKRuic=J<}}csK51rZI!GpT6HFMTU-+V-v@ntfsNhlRW8k zyi;yAoM;-2H=J(lJ6X~2k<)d{saZ)_@z9~udUZ#l$dqP^n{tM7meN8wM`@*aC~cH> QikIS}oZqhQbY#E(2N~D#3jhEB delta 379 zcmYMw&q@M890%~3b=RHM-5IwmU9EO6p@RK^5YkJ7fj6N;FR-PUwCd^}1W#fxU5gH# z7OW>I7VIKHZx9vu0G+BA=n^z|s2_Yz-|zSG8;%FV@!%q@7Xg4D;Qzvx`5jUOn=Gf1 zZ-C?pz^VIoR|+h527`%Q3yWvlP!87cJJk71A|pUP$uoH*J{bkN>ymXNhd>TO1uA8` zeQMcOwdpv`PCJ~sb-aE1#C-^bH%w#1Zgc3rnXZO9mnF2!z*sxo5Z+Ao)g^UV}h8PtYl^6{( zDl>{0RTzyh8f8>vRGUyNx<*v>0lvW=E@Dx8({8nvR?<}UMZNCcsd@f_L-pLBVvcYz ePUjZ5MJ~=Qam!qSOL7L6;?ms8gFjt8e*Fb0rfbyz diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..b0fbca0 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,223 @@ +""" +tests/test_filters.py +--------------------- +Tests for core/filters.py and the filter integration in core/predictor.py. +""" + +import pytest +from db.models import get_game_by_name, insert_draw +from core.filters import _has_consecutive_run, sum_range_percentiles, passes_filters +from core.predictor import ( + hot_numbers, due_numbers, weighted_random, + monte_carlo, positional_pick, quick_pick, + _swap_filter, _retry_filter, +) + + +# ── _has_consecutive_run ────────────────────────────────────────────────────── + +def test_no_consecutive(): + assert _has_consecutive_run([1, 5, 10, 20, 30]) is False + + +def test_run_of_three_not_flagged(): + assert _has_consecutive_run([1, 2, 3, 10, 20]) is False # run=3, threshold=4 + + +def test_run_of_four_flagged(): + assert _has_consecutive_run([1, 2, 3, 4, 20]) is True + + +def test_run_of_five_flagged(): + assert _has_consecutive_run([10, 11, 12, 13, 14]) is True + + +def test_run_at_end(): + assert _has_consecutive_run([5, 20, 30, 31, 32, 33]) is True + + +def test_run_not_contiguous(): + assert _has_consecutive_run([1, 3, 5, 7, 9]) is False + + +# ── passes_filters — odd/even ───────────────────────────────────────────────── + +def test_all_even_rejected(): + assert passes_filters([2, 14, 28, 42, 60]) is False + + +def test_all_odd_rejected(): + assert passes_filters([1, 7, 13, 29, 69]) is False + + +def test_mixed_odd_even_accepted(): + assert passes_filters([1, 14, 28, 42, 60]) is True + + +def test_all_even_under_4_accepted(): + # Odd/even filter only kicks in for 4+ numbers + assert passes_filters([2, 4, 6]) is True + + +# ── passes_filters — consecutive ───────────────────────────────────────────── + +def test_four_consecutive_rejected(): + assert passes_filters([5, 6, 7, 8, 20]) is False + + +def test_three_consecutive_accepted(): + assert passes_filters([5, 6, 7, 20, 35]) is True + + +# ── passes_filters — sum range ──────────────────────────────────────────────── + +def test_sum_in_range_accepted(): + assert passes_filters([1, 14, 28, 42, 60], sum_range=(100, 200)) is True + + +def test_sum_below_range_rejected(): + assert passes_filters([1, 2, 3, 4, 10], sum_range=(100, 200)) is False + + +def test_sum_above_range_rejected(): + assert passes_filters([60, 62, 64, 66, 69], sum_range=(100, 200)) is False + + +def test_sum_range_none_skips_check(): + # All-even but sum would be out of range if range were set — only even check applies + assert passes_filters([2, 4, 6, 8, 10], sum_range=None) is False # all-even fails + + +def test_sum_at_boundary_accepted(): + assert passes_filters([1, 14, 28, 42, 15], sum_range=(100, 100)) is True # sum=100 + + +# ── sum_range_percentiles ───────────────────────────────────────────────────── + +def test_sum_range_none_with_few_draws(tmp_db): + pb = get_game_by_name("Powerball") + for i in range(5): + insert_draw(pb["id"], f"2024-01-{i+1:02d}", [1, 2, 3, 4, i+5], bonus=1) + result = sum_range_percentiles(pb["id"]) + assert result is None # < 10 draws + + +def test_sum_range_returns_tuple_with_enough_draws(tmp_db): + pb = get_game_by_name("Powerball") + for i in range(20): + insert_draw(pb["id"], f"2024-02-{i+1:02d}", [1+i, 2+i, 3+i, 4+i, 5+i], bonus=1) + result = sum_range_percentiles(pb["id"]) + assert result is not None + lo, hi = result + assert lo < hi + assert isinstance(lo, int) + assert isinstance(hi, int) + + +def test_sum_range_bounds_reasonable(tmp_db): + pb = get_game_by_name("Powerball") + # Insert draws with sums ranging 15–25 + for i in range(15, 26): + insert_draw(pb["id"], f"2024-03-{i-14:02d}", [1, 2, 3, 4, i-10], bonus=1) + lo, hi = sum_range_percentiles(pb["id"]) + assert lo >= 10 # 10th pct of sums around 15 + assert hi <= 25 # 90th pct of sums around 25 + + +# ── _swap_filter ────────────────────────────────────────────────────────────── + +def test_swap_filter_passes_already(): + numbers = [1, 14, 28, 42, 60] + assert _swap_filter(numbers, [2, 3, 5], None) == numbers + + +def test_swap_filter_fixes_all_even(): + # all-even → swap last (weakest-ranked) for an odd + numbers = [2, 14, 28, 42, 60] + pool = [1, 3, 5, 7, 9] # odd alternates + result = _swap_filter(numbers, pool, None) + assert passes_filters(result), f"Expected filtered result, got {result}" + assert len(result) == 5 + assert len(set(result)) == 5 + + +def test_swap_filter_fixes_four_consecutive(): + numbers = [10, 11, 12, 13, 25] + pool = [1, 5, 8, 20, 30, 35] + result = _swap_filter(numbers, pool, None) + assert passes_filters(result), f"Expected filtered result, got {result}" + + +def test_swap_filter_graceful_fallback(): + # If no swap fixes the ticket, return original + numbers = [2, 4, 6, 8, 10] # all-even + pool = [12, 14, 16] # all-even alternates — can't fix + result = _swap_filter(numbers, pool, None) + assert result == numbers # graceful fallback + + +# ── Filter integration in strategies ───────────────────────────────────────── + +def _large_draw_set(game_id): + """Insert 30 varied Powerball draws so sum_range_percentiles returns a range.""" + import random as rng + rng.seed(42) + from db.models import get_game_by_id + game = get_game_by_id(game_id) + for i in range(30): + nums = sorted(rng.sample(range(1, game["main_max"] + 1), game["main_count"])) + bonus = rng.randint(1, game["bonus_max"]) if game["bonus_count"] > 0 else None + insert_draw(game_id, f"2020-{(i//30)+1:02d}-{(i%28)+1:02d}", nums, bonus=bonus) + + +def _assert_filtered(result, game): + nums = result["numbers"] + assert len(nums) == game["main_count"] + assert nums == sorted(nums) + assert len(set(nums)) == len(nums) + assert all(1 <= n <= game["main_max"] for n in nums) + assert not (len(nums) >= 4 and all(n % 2 == 0 for n in nums)), "all-even" + assert not (len(nums) >= 4 and all(n % 2 != 0 for n in nums)), "all-odd" + assert not _has_consecutive_run(nums, 4), "4+ consecutive" + + +def test_hot_numbers_filter_integration(tmp_db): + pb = get_game_by_name("Powerball") + _large_draw_set(pb["id"]) + for _ in range(10): + _assert_filtered(hot_numbers(pb["id"]), pb) + + +def test_due_numbers_filter_integration(tmp_db): + pb = get_game_by_name("Powerball") + _large_draw_set(pb["id"]) + for _ in range(10): + _assert_filtered(due_numbers(pb["id"]), pb) + + +def test_weighted_random_filter_integration(tmp_db): + pb = get_game_by_name("Powerball") + _large_draw_set(pb["id"]) + for _ in range(20): + _assert_filtered(weighted_random(pb["id"]), pb) + + +def test_monte_carlo_filter_integration(tmp_db): + pb = get_game_by_name("Powerball") + _large_draw_set(pb["id"]) + for _ in range(5): + _assert_filtered(monte_carlo(pb["id"], simulations=500), pb) + + +def test_quick_pick_filter_integration(tmp_db): + pb = get_game_by_name("Powerball") + _large_draw_set(pb["id"]) + for _ in range(20): + _assert_filtered(quick_pick(pb["id"]), pb) + + +def test_positional_pick_filter_integration(tmp_db): + pb = get_game_by_name("Powerball") + _large_draw_set(pb["id"]) + for _ in range(10): + _assert_filtered(positional_pick(pb["id"]), pb)