diff --git a/lotto/app/backtest.py b/lotto/app/backtest.py new file mode 100644 index 0000000..2f0305e --- /dev/null +++ b/lotto/app/backtest.py @@ -0,0 +1,103 @@ +"""로또 자가학습 백테스트 — 순수 연산 (FastAPI 의존성 0, Windows 이전 대비).""" +import random +from typing import Any, Dict, List, Optional, Tuple + +from .analyzer import build_analysis_cache, build_number_weights, score_combination +from .utils import weighted_sample_6 +from .weight_evolver import count_match + + +def grade_tickets(tickets: List[List[int]], winning6: List[int], bonus: int) -> Dict[str, Any]: + """티켓 묶음을 당첨번호로 채점 → 매칭 히스토그램 + 보너스 + best_match. + 2등 판정: 5일치 AND 보너스 번호를 티켓이 포함.""" + win = set(winning6) + hist = {"m3": 0, "m4": 0, "m5": 0, "m6": 0, "bonus_hits": 0} + best = 0 + for t in tickets: + c = len(set(t) & win) + if c > best: + best = c + if c == 6: + hist["m6"] += 1 + elif c == 5: + hist["m5"] += 1 + if bonus in t: + hist["bonus_hits"] += 1 + elif c == 4: + hist["m4"] += 1 + elif c == 3: + hist["m3"] += 1 + return {**hist, "best_match": best} + + +def prize_counts(hist: Dict[str, Any]) -> Dict[str, int]: + """매칭 히스토그램 → 등수 카운트. + 1등=m6, 2등=bonus_hits, 3등=m5−bonus_hits, 4등=m4, 5등=m3.""" + return { + "1st": hist.get("m6", 0), + "2nd": hist.get("bonus_hits", 0), + "3rd": hist.get("m5", 0) - hist.get("bonus_hits", 0), + "4th": hist.get("m4", 0), + "5th": hist.get("m3", 0), + } + + +def generate_pool(cache, number_weights, n: int = 20000, + seed: Optional[int] = None) -> List[List[int]]: + """가중 샘플링으로 distinct 후보 풀 생성.""" + if seed is not None: + random.seed(seed) + seen, pool = set(), [] + attempts, cap = 0, n * 4 + while len(pool) < n and attempts < cap: + attempts += 1 + nums = tuple(sorted(weighted_sample_6(number_weights))) + if nums in seen: + continue + seen.add(nums) + pool.append(list(nums)) + return pool + + +def purchase_tickets(pool, cache, W: List[float], k: int) -> List[List[int]]: + """풀을 score_combination(·, W)로 랭킹 → 상위 k장 distinct.""" + ranked = sorted(pool, key=lambda t: -score_combination(t, cache, W)["score_total"]) + return ranked[:k] + + +def random_null_tickets(k: int, seed: Optional[int] = None) -> List[List[int]]: + """무작위 distinct 티켓 k장 (null-model 대조군).""" + if seed is not None: + random.seed(seed) + seen, out = set(), [] + while len(out) < k: + nums = tuple(sorted(random.sample(range(1, 46), 6))) + if nums in seen: + continue + seen.add(nums) + out.append(list(nums)) + return out + + +def coverage_tickets(k: int, seed: Optional[int] = None) -> List[List[int]]: + """greedy 커버리지 — 아직 덜 쓰인 번호를 우선 배치해 번호를 넓게 분산. + (휠링/보장설계는 향후. 현재는 distinct + 번호 사용 균등화)""" + if seed is not None: + random.seed(seed) + usage = {n: 0 for n in range(1, 46)} + seen, out = set(), [] + guard = 0 + while len(out) < k and guard < k * 50: + guard += 1 + ranked = sorted(range(1, 46), key=lambda n: (usage[n], random.random())) + nums = tuple(sorted(ranked[:6])) + if nums in seen: + # 동점 흔들기: 약간 더 깊은 풀에서 샘플 + nums = tuple(sorted(random.sample(ranked[:12], 6))) + if nums in seen: + continue + seen.add(nums) + out.append(list(nums)) + for n in nums: + usage[n] += 1 + return out diff --git a/lotto/tests/test_backtest.py b/lotto/tests/test_backtest.py new file mode 100644 index 0000000..2b0a48a --- /dev/null +++ b/lotto/tests/test_backtest.py @@ -0,0 +1,25 @@ +from app import backtest as bt +from app.analyzer import build_analysis_cache, score_combination + + +def test_grade_tickets_histogram_and_prizes(): + winning6 = [1, 2, 3, 4, 5, 6] + bonus = 7 + tickets = [ + [1, 2, 3, 4, 5, 6], # 6일치 = 1등 + [1, 2, 3, 4, 5, 7], # 5일치 + 보너스 = 2등 + [1, 2, 3, 4, 5, 8], # 5일치 = 3등 + [1, 2, 3, 4, 9, 10], # 4일치 = 4등 + [1, 2, 3, 11, 12, 13], # 3일치 = 5등 + [40, 41, 42, 43, 44, 45], # 0일치 + ] + r = bt.grade_tickets(tickets, winning6, bonus) + assert r["m6"] == 1 + assert r["m5"] == 2 # 5일치 총 2장(보너스 포함) + assert r["bonus_hits"] == 1 # 그 중 보너스 1장 + assert r["m4"] == 1 + assert r["m3"] == 1 + assert r["best_match"] == 6 + # 등수 매핑 헬퍼 + prizes = bt.prize_counts(r) + assert prizes == {"1st": 1, "2nd": 1, "3rd": 1, "4th": 1, "5th": 1}