Files
lottosight/tests/test_recency_ensemble.py
nngo c05867a642
LottoSight CI / Syntax Check & Tests (push) Has been cancelled
05/23 update codes, add build scripts
2026-05-23 18:16:56 -04:00

229 lines
8.3 KiB
Python

"""
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"