feat(lotto): grade_tickets 매칭 채점 + 등수 매핑
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
103
lotto/app/backtest.py
Normal file
103
lotto/app/backtest.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user