""" tests/test_recency_ensemble.py ------------------------------- Tests for: - Recency-weighted frequency_analysis (decay parameter) - Ensemble prediction strategy """ import pytest from db.models import get_game_by_name, insert_draw from core.analyzer import frequency_analysis from core.predictor import ( hot_numbers, weighted_random, monte_carlo, ensemble, ) # ── Helpers ──────────────────────────────────────────────────────────────────── def _insert_draws(game_id, draws): """Insert list of (date, numbers, bonus) tuples.""" for date, nums, bonus in draws: insert_draw(game_id, date, nums, bonus=bonus) # ── frequency_analysis with decay ───────────────────────────────────────────── def test_decay_zero_matches_no_decay(tmp_db): """decay=0.0 must return identical results to the default (no decay).""" pb = get_game_by_name("Powerball") _insert_draws(pb["id"], [ ("2024-01-01", [1, 13, 36, 61, 69], 7), ("2024-01-03", [1, 2, 13, 45, 69], 15), ("2024-01-05", [2, 13, 22, 36, 55], 3), ]) no_decay = frequency_analysis(pb["id"]) with_zero = frequency_analysis(pb["id"], decay=0.0) assert no_decay == with_zero def test_decay_returns_floats(tmp_db): pb = get_game_by_name("Powerball") _insert_draws(pb["id"], [ ("2024-01-01", [1, 13, 36, 61, 69], 7), ("2024-01-03", [5, 10, 20, 30, 40], 15), ]) freq = frequency_analysis(pb["id"], decay=0.01) assert all(isinstance(v, float) for v in freq.values()) def test_decay_recent_number_ranked_higher(tmp_db): """ A number appearing only in the most recent draw should rank higher than a number that appeared only in an older draw when decay is applied. """ pb = get_game_by_name("Powerball") # Number 99→use 69 appears only in the oldest draw # Number 1 appears only in the newest draw _insert_draws(pb["id"], [ ("2024-01-01", [69, 13, 36, 61, 55], 7), # oldest — 69 appears here ("2024-01-03", [5, 10, 20, 30, 40], 15), ("2024-01-05", [5, 10, 20, 30, 40], 3), ("2024-01-07", [5, 10, 20, 30, 40], 8), ("2024-01-09", [5, 10, 20, 30, 40], 11), ("2024-01-11", [1, 13, 22, 45, 50], 4), # newest — 1 appears here ]) freq = frequency_analysis(pb["id"], decay=0.1) # high decay for clear separation assert freq[1] > freq[69], ( f"Recent number (1) should outrank older number (69): {freq[1]:.3f} vs {freq[69]:.3f}" ) def test_decay_with_last_n(tmp_db): """last_n windows the draws before decay is applied.""" pb = get_game_by_name("Powerball") _insert_draws(pb["id"], [ ("2024-01-01", [1, 13, 36, 61, 69], 7), ("2024-01-03", [2, 4, 13, 45, 69], 15), ("2024-01-05", [5, 10, 20, 30, 40], 3), ]) freq_all = frequency_analysis(pb["id"], decay=0.01) freq_last = frequency_analysis(pb["id"], last_n=1, decay=0.01) # last_n=1 only sees the third draw assert set(freq_last.keys()) == {5, 10, 20, 30, 40} assert set(freq_all.keys()) == {1, 2, 4, 5, 10, 13, 20, 30, 36, 40, 45, 61, 69} def test_decay_sum_of_weights_reasonable(tmp_db): """ Total weight with decay should be less than raw count (some weight lost to decay), but each number's weight should be positive. """ pb = get_game_by_name("Powerball") _insert_draws(pb["id"], [ ("2024-01-01", [1, 2, 3, 4, 5], 7), ("2024-01-02", [1, 2, 3, 4, 5], 7), ("2024-01-03", [1, 2, 3, 4, 5], 7), ]) freq = frequency_analysis(pb["id"], decay=0.05) # All 5 numbers appeared 3 times each; with decay the total weight < 3 per number for n in [1, 2, 3, 4, 5]: assert 0 < freq[n] < 3.0 def test_decay_empty_db_returns_empty(tmp_db): pb = get_game_by_name("Powerball") assert frequency_analysis(pb["id"], decay=0.01) == {} # ── hot_numbers / weighted_random / monte_carlo with decay ──────────────────── def _pb_with_draws(tmp_db): pb = get_game_by_name("Powerball") draws = [ ("2024-01-01", [1, 13, 36, 61, 69], 7), ("2024-01-03", [1, 2, 13, 45, 69], 15), ("2024-01-05", [2, 13, 22, 36, 55], 3), ("2024-01-08", [5, 18, 33, 50, 65], 22), ("2024-01-10", [7, 14, 28, 42, 60], 11), ] _insert_draws(pb["id"], draws) return pb def test_hot_numbers_with_decay_valid(tmp_db): pb = _pb_with_draws(tmp_db) result = hot_numbers(pb["id"]) nums = result["numbers"] assert len(nums) == pb["main_count"] assert nums == sorted(nums) assert len(set(nums)) == len(nums) assert all(1 <= n <= pb["main_max"] for n in nums) def test_weighted_random_with_decay_valid(tmp_db): pb = _pb_with_draws(tmp_db) for _ in range(5): result = weighted_random(pb["id"]) assert len(result["numbers"]) == pb["main_count"] assert all(1 <= n <= pb["main_max"] for n in result["numbers"]) def test_monte_carlo_with_decay_valid(tmp_db): pb = _pb_with_draws(tmp_db) result = monte_carlo(pb["id"], simulations=200) assert len(result["numbers"]) == pb["main_count"] assert all(1 <= n <= pb["main_max"] for n in result["numbers"]) # ── Ensemble strategy ───────────────────────────────────────────────────────── def test_ensemble_valid_structure(tmp_db): pb = _pb_with_draws(tmp_db) result = ensemble(pb["id"]) nums = result["numbers"] bonus = result["bonus"] assert len(nums) == pb["main_count"] assert nums == sorted(nums) assert len(set(nums)) == len(nums) assert all(1 <= n <= pb["main_max"] for n in nums) assert bonus is not None assert 1 <= bonus <= pb["bonus_max"] def test_ensemble_empty_db_fallback(tmp_db): pb = get_game_by_name("Powerball") result = ensemble(pb["id"]) assert len(result["numbers"]) == pb["main_count"] def test_ensemble_no_duplicates_across_strategies(tmp_db): pb = _pb_with_draws(tmp_db) result = ensemble(pb["id"]) assert len(set(result["numbers"])) == pb["main_count"] def test_ensemble_with_exclude(tmp_db): pb = _pb_with_draws(tmp_db) exclude = {1, 2, 3, 4, 5} result = ensemble(pb["id"], exclude=exclude) assert not any(n in exclude for n in result["numbers"]) def test_ensemble_picks_consensus_number(tmp_db): """ 13 appears in 3 of 5 draws in the fixture and should be picked by multiple strategies; the ensemble should include it. """ pb = _pb_with_draws(tmp_db) # Run several times — consensus picks should be stable hits = sum(1 for _ in range(10) if 13 in ensemble(pb["id"])["numbers"]) assert hits >= 7, f"13 should appear in most ensemble tickets, got {hits}/10" def test_ensemble_bonus_is_valid(tmp_db): pb = _pb_with_draws(tmp_db) for _ in range(5): result = ensemble(pb["id"]) assert result["bonus"] is not None assert 1 <= result["bonus"] <= pb["bonus_max"] def test_ensemble_valid_for_no_bonus_game(tmp_db): cash5 = get_game_by_name("Cash 5") _insert_draws(cash5["id"], [ ("2024-01-01", [1, 5, 10, 20, 30], None), ("2024-01-02", [2, 6, 11, 21, 31], None), ("2024-01-03", [3, 7, 12, 22, 32], None), ]) result = ensemble(cash5["id"]) assert len(result["numbers"]) == cash5["main_count"] assert result["bonus"] is None def test_ensemble_passes_combination_filters(tmp_db): """Ensemble should respect combination filters on its output.""" import random as rng from core.filters import _has_consecutive_run rng.seed(99) pb = get_game_by_name("Powerball") for i in range(30): nums = sorted(rng.sample(range(1, pb["main_max"] + 1), pb["main_count"])) insert_draw(pb["id"], f"2020-{(i//28)+1:02d}-{(i%28)+1:02d}", nums, bonus=rng.randint(1, 26)) for _ in range(10): result = ensemble(pb["id"]) nums = result["numbers"] 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"