- weight_evolver.evaluate_weekly: save_base_history 직전에 current_base를 previous_base로 캡처해 return dict에 포함 → formatter가 진짜 diff 표시 가능 - evaluate_weekly: same effective_from row 이미 존재 시 save skip + idempotent return (토 22:00 lotto cron과 agent-office 22:15 재호출 중복 row 방지) - main._run_weight_evolver_daily: 일요일(weekday=6) 도 skip — 토요일 trial을 INSERT OR REPLACE로 덮어쓰는 문제 방지 - telegram_lotto._format_evolution_report: eval_result.previous_base 우선 사용 (없으면 current_base 폴백) → diff 자기 자신 비교 버그 수정 - test_lotto_evolution_format: previous_base 키 추가 + 새 diff 검증 테스트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
315 lines
9.6 KiB
Python
315 lines
9.6 KiB
Python
# lotto/app/weight_evolver.py
|
||
"""5종 시뮬 점수 가중치 자율 학습 루프.
|
||
|
||
순수 함수 (clamp/perturb/Dirichlet/score/base-rule) + DB 진입점은 별도 섹션.
|
||
"""
|
||
from __future__ import annotations
|
||
import math
|
||
import random
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import numpy as np
|
||
|
||
MIN_WEIGHT = 0.05
|
||
N_METRICS = 5
|
||
DEFAULT_UNIFORM = [0.2] * N_METRICS # cold start
|
||
|
||
RANK_BY_CORRECT = {6: 1, 5: 3, 4: 4, 3: 5}
|
||
RANK_BONUS = {1: 1.0, 2: 0.8, 3: 0.6, 4: 0.3, 5: 0.1}
|
||
|
||
|
||
def clamp_and_normalize(W: List[float], min_w: float = MIN_WEIGHT) -> List[float]:
|
||
"""각 값 ≥ min_w + 합=1.0. 보장 안 되면 raise."""
|
||
if len(W) != N_METRICS:
|
||
raise ValueError(f"W must have {N_METRICS} elements")
|
||
# Iteratively clamp then normalize until all values satisfy min_w floor.
|
||
# (Normalizing after clamping can reduce some already-floored values below
|
||
# min_w when the denominator is large — iterate to convergence.)
|
||
vals = [float(w) for w in W]
|
||
for _ in range(100): # converges in a few iterations in practice
|
||
clamped = [max(min_w, v) for v in vals]
|
||
total = sum(clamped)
|
||
vals = [v / total for v in clamped]
|
||
if all(v >= min_w - 1e-12 for v in vals):
|
||
break
|
||
return vals
|
||
|
||
|
||
def perturb_weights(
|
||
base: List[float],
|
||
sigma: float = 0.05,
|
||
seed: Optional[int] = None,
|
||
) -> List[float]:
|
||
"""base에 정규분포 noise(σ) 추가 → clamp+normalize."""
|
||
if seed is not None:
|
||
np.random.seed(seed)
|
||
noise = np.random.normal(0, sigma, size=N_METRICS)
|
||
perturbed = [b + n for b, n in zip(base, noise)]
|
||
return clamp_and_normalize(perturbed)
|
||
|
||
|
||
def dirichlet_weights(
|
||
alpha: float = 2.0,
|
||
seed: Optional[int] = None,
|
||
) -> List[float]:
|
||
"""Dirichlet(α, α, α, α, α) 샘플 → clamp+normalize."""
|
||
if seed is not None:
|
||
np.random.seed(seed)
|
||
sample = np.random.dirichlet([alpha] * N_METRICS).tolist()
|
||
return clamp_and_normalize(sample)
|
||
|
||
|
||
def generate_weekly_candidates(
|
||
base: Optional[List[float]] = None,
|
||
seed: Optional[int] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""6개 후보 — 4 perturb + 2 dirichlet. day_of_week 0..5 매핑.
|
||
|
||
Returns:
|
||
[{"day_of_week": 0, "weight": [...], "source": "perturb"}, ...]
|
||
"""
|
||
if base is None:
|
||
base = DEFAULT_UNIFORM[:]
|
||
if seed is not None:
|
||
np.random.seed(seed)
|
||
|
||
trials = []
|
||
for i in range(4):
|
||
trials.append({
|
||
"day_of_week": i,
|
||
"weight": perturb_weights(base, sigma=0.05),
|
||
"source": "perturb",
|
||
})
|
||
for i in range(4, 6):
|
||
trials.append({
|
||
"day_of_week": i,
|
||
"weight": dirichlet_weights(alpha=2.0),
|
||
"source": "dirichlet",
|
||
})
|
||
return trials
|
||
|
||
|
||
def count_match(pick: List[int], winning: List[int]) -> int:
|
||
"""본번호 6개 일치 개수. 보너스 제외."""
|
||
return len(set(pick) & set(winning[:6]))
|
||
|
||
|
||
def calc_pick_score(pick_numbers: List[int], winning_numbers: List[int]) -> float:
|
||
"""correct/6 + RANK_BONUS. 보너스 번호 미고려."""
|
||
correct = count_match(pick_numbers, winning_numbers)
|
||
base = correct / 6.0
|
||
rank = RANK_BY_CORRECT.get(correct)
|
||
bonus = RANK_BONUS.get(rank, 0) if rank else 0
|
||
return base + bonus
|
||
|
||
|
||
def decide_base_update(
|
||
winner_max_correct: int,
|
||
winner_W: List[float],
|
||
current_base: Optional[List[float]],
|
||
) -> Tuple[List[float], str]:
|
||
"""Hybrid base update rule.
|
||
|
||
Returns:
|
||
(new_base, reason) — reason ∈ {'winner_4plus','ema_blend','unchanged','cold_start'}
|
||
"""
|
||
if winner_max_correct >= 4:
|
||
return list(winner_W), "winner_4plus"
|
||
if winner_max_correct == 3 and current_base is not None:
|
||
blended = [0.3 * w + 0.7 * c for w, c in zip(winner_W, current_base)]
|
||
return clamp_and_normalize(blended), "ema_blend"
|
||
if current_base is None:
|
||
return DEFAULT_UNIFORM[:], "cold_start"
|
||
return list(current_base), "unchanged"
|
||
|
||
|
||
# ---------- DB-touching entry points ----------
|
||
|
||
KST = timezone(timedelta(hours=9))
|
||
|
||
|
||
def _db():
|
||
from . import db as _db_mod
|
||
return _db_mod
|
||
|
||
|
||
def _today_kst():
|
||
return datetime.now(KST).date()
|
||
|
||
|
||
def get_week_start(d=None) -> str:
|
||
"""주어진 날짜의 월요일 ISO 'YYYY-MM-DD'."""
|
||
if d is None:
|
||
d = _today_kst()
|
||
ws = d - timedelta(days=d.weekday())
|
||
return ws.isoformat()
|
||
|
||
|
||
def get_active_weight() -> Optional[List[float]]:
|
||
"""오늘 적용 중인 W. 없으면 None (균등 폴백)."""
|
||
today = _today_kst()
|
||
week_start = get_week_start(today)
|
||
dow = today.weekday()
|
||
if dow == 6:
|
||
dow = 5 # 일요일은 토요일 W 유지
|
||
trial = _db().get_weight_trial(week_start, dow)
|
||
if trial:
|
||
return trial["weight"]
|
||
return None
|
||
|
||
|
||
def generate_weekly_candidates_and_save(seed: Optional[int] = None) -> List[Dict[str, Any]]:
|
||
"""월요일 09:00 cron 진입점. 6 trials 생성 후 DB 저장."""
|
||
db = _db()
|
||
base = db.get_current_base()
|
||
if base is None:
|
||
base = DEFAULT_UNIFORM[:]
|
||
db.save_base_history(
|
||
effective_from=get_week_start(),
|
||
weight=base,
|
||
source_trial_id=None,
|
||
update_reason="cold_start",
|
||
winner_score=None,
|
||
winner_max_correct=None,
|
||
)
|
||
|
||
candidates = generate_weekly_candidates(base, seed=seed)
|
||
week_start = get_week_start()
|
||
for c in candidates:
|
||
db.save_weight_trial(
|
||
week_start=week_start,
|
||
day_of_week=c["day_of_week"],
|
||
weight=c["weight"],
|
||
source=c["source"],
|
||
base_at_gen=base,
|
||
)
|
||
return candidates
|
||
|
||
|
||
def apply_today_and_pick(n: int = 5) -> Dict[str, Any]:
|
||
"""매일 09:00 cron 진입점. 오늘 W로 N=5 세트 추출 후 auto_picks 저장."""
|
||
db = _db()
|
||
from . import analyzer, recommender
|
||
today = _today_kst()
|
||
week_start = get_week_start(today)
|
||
dow = min(today.weekday(), 5)
|
||
|
||
trial = db.get_weight_trial(week_start, dow)
|
||
if trial is None:
|
||
return {"ok": False, "reason": "no_trial_for_today"}
|
||
|
||
W = trial["weight"]
|
||
draws = db.get_all_draw_numbers()
|
||
cache = analyzer.build_analysis_cache(draws)
|
||
|
||
picks_saved = []
|
||
for i in range(1, n + 1):
|
||
try:
|
||
r = recommender.recommend_numbers(draws)
|
||
nums = r["numbers"]
|
||
s = analyzer.score_combination(nums, cache, weights=W)
|
||
pid = db.save_auto_pick(trial["id"], i, nums, meta_score=s["score_total"])
|
||
picks_saved.append({"id": pid, "numbers": nums, "score": s["score_total"]})
|
||
except Exception:
|
||
continue
|
||
|
||
return {
|
||
"ok": True,
|
||
"trial_id": trial["id"],
|
||
"weight": W,
|
||
"picks": picks_saved,
|
||
}
|
||
|
||
|
||
def evaluate_weekly() -> Dict[str, Any]:
|
||
"""토 22:00 cron 진입점. 6일 trials × N picks 채점 + base 갱신."""
|
||
db = _db()
|
||
today = _today_kst()
|
||
week_start = get_week_start(today)
|
||
|
||
trials = db.get_weekly_trials(week_start)
|
||
if not trials:
|
||
return {"ok": False, "reason": "no_trials"}
|
||
|
||
latest = db.get_latest_draw()
|
||
if latest is None:
|
||
return {"ok": False, "reason": "no_latest_draw"}
|
||
winning = [
|
||
latest["n1"], latest["n2"], latest["n3"],
|
||
latest["n4"], latest["n5"], latest["n6"],
|
||
]
|
||
|
||
per_day = []
|
||
for trial in trials:
|
||
picks = db.get_auto_picks(trial["id"])
|
||
if not picks:
|
||
continue
|
||
day_scores = []
|
||
max_c = 0
|
||
for p in picks:
|
||
correct = count_match(p["numbers"], winning)
|
||
rank = RANK_BY_CORRECT.get(correct)
|
||
db.update_auto_pick_grade(p["id"], correct, rank)
|
||
day_scores.append(calc_pick_score(p["numbers"], winning))
|
||
if correct > max_c:
|
||
max_c = correct
|
||
avg_score = sum(day_scores) / len(day_scores)
|
||
per_day.append({
|
||
"trial_id": trial["id"],
|
||
"day_of_week": trial["day_of_week"],
|
||
"weight": trial["weight"],
|
||
"avg_score": avg_score,
|
||
"max_correct": max_c,
|
||
"n_picks": len(picks),
|
||
})
|
||
|
||
if not per_day:
|
||
return {"ok": False, "reason": "no_picks_graded"}
|
||
|
||
winner = max(per_day, key=lambda d: d["avg_score"])
|
||
|
||
current_base = db.get_current_base()
|
||
new_base, reason = decide_base_update(
|
||
winner_max_correct=winner["max_correct"],
|
||
winner_W=winner["weight"],
|
||
current_base=current_base,
|
||
)
|
||
|
||
next_monday = today + timedelta(days=(7 - today.weekday()) % 7 or 7)
|
||
next_monday_iso = next_monday.isoformat()
|
||
|
||
# Idempotent guard: 같은 effective_from으로 이미 저장된 row가 있으면 skip
|
||
existing = db.get_base_history(limit=1)
|
||
if existing and existing[0]["effective_from"] == next_monday_iso:
|
||
return {
|
||
"ok": True,
|
||
"draw_no": latest["drw_no"],
|
||
"week_start": week_start,
|
||
"previous_base": existing[0].get("weight"),
|
||
"winner": winner,
|
||
"new_base": existing[0]["weight"], # 이미 저장된 값
|
||
"update_reason": existing[0].get("update_reason", "idempotent_skip"),
|
||
"per_day": per_day,
|
||
}
|
||
|
||
db.save_base_history(
|
||
effective_from=next_monday_iso,
|
||
weight=new_base,
|
||
source_trial_id=winner["trial_id"],
|
||
update_reason=reason,
|
||
winner_score=winner["avg_score"],
|
||
winner_max_correct=winner["max_correct"],
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"draw_no": latest["drw_no"],
|
||
"week_start": week_start,
|
||
"previous_base": current_base, # save 이전에 캡처한 값 — diff 계산용
|
||
"winner": winner,
|
||
"new_base": new_base,
|
||
"update_reason": reason,
|
||
"per_day": per_day,
|
||
}
|