로또 프리미엄 Phase 1 — 추천 성과 통계 + 회차 공략 리포트 API
- GET /api/lotto/stats/performance: 채점 이력 기반 성과 통계
(평균 일치 수, 등수 분포, 무작위 대비 개선율)
- GET /api/lotto/report/latest: 다음 회차 공략 리포트 자동 생성
- GET /api/lotto/report/{drw_no}: 특정 회차 공략 리포트
(과출현/냉각/오버듀 번호, 최근 패턴, 3가지 전략 추천, 신뢰도 점수)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import List, Tuple, Dict, Any, Optional
|
from typing import List, Tuple, Dict, Any, Optional
|
||||||
|
|
||||||
# 구간 정의: (시작, 끝) 포함
|
# 구간 정의: (시작, 끝) 포함
|
||||||
@@ -352,3 +353,107 @@ def get_statistical_report(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]
|
|||||||
"overdue_numbers": [x["number"] for x in sorted_by_gap[:10]],
|
"overdue_numbers": [x["number"] for x in sorted_by_gap[:10]],
|
||||||
"sum_distribution": sum_buckets,
|
"sum_distribution": sum_buckets,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
특정 회차 공략 리포트 생성.
|
||||||
|
target_drw_no: 공략 대상 회차 (아직 추첨 안 된 회차)
|
||||||
|
draws: target_drw_no 이전까지의 당첨번호 (오름차순)
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {"error": "데이터 없음"}
|
||||||
|
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
total_draws = cache["total_draws"]
|
||||||
|
freq_all = cache["freq_all"]
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
|
||||||
|
recent_10 = draws[-10:] if len(draws) >= 10 else draws
|
||||||
|
recent_3 = draws[-3:] if len(draws) >= 3 else draws
|
||||||
|
|
||||||
|
# 과출현: 최근 10회에 2회 이상 출현 번호 (출현 많은 순)
|
||||||
|
r10_nums = [n for _, nums in recent_10 for n in nums]
|
||||||
|
r10_freq = Counter(r10_nums)
|
||||||
|
hot_numbers = [n for n, _ in sorted(r10_freq.items(), key=lambda x: -x[1]) if r10_freq[n] >= 2]
|
||||||
|
|
||||||
|
# 냉각: 역대 출현 빈도 낮은 번호
|
||||||
|
cold_numbers = sorted(range(1, 46), key=lambda n: freq_all.get(n, 0))[:10]
|
||||||
|
|
||||||
|
# 오버듀: 가장 오래 미출현 번호
|
||||||
|
overdue_numbers = sorted(range(1, 46), key=lambda n: -last_seen_gap.get(n, 0))[:10]
|
||||||
|
|
||||||
|
# 최근 3회 연속 출현 (2회 이상)
|
||||||
|
r3_nums = [n for _, nums in recent_3 for n in nums]
|
||||||
|
r3_freq = Counter(r3_nums)
|
||||||
|
triple_appear = sorted(n for n, cnt in r3_freq.items() if cnt >= 2)
|
||||||
|
|
||||||
|
recent_sums = [sum(nums) for _, nums in recent_10]
|
||||||
|
recent_odd = [sum(1 for n in nums if n % 2 == 1) for _, nums in recent_10]
|
||||||
|
|
||||||
|
# 갭 기반 가중치 (오래된 번호일수록 높음)
|
||||||
|
gap_w = {n: last_seen_gap.get(n, 0) for n in range(1, 46)}
|
||||||
|
|
||||||
|
def _pick(exclude=None, prefer=None, n=6):
|
||||||
|
ex = set(exclude or [])
|
||||||
|
chosen = []
|
||||||
|
# prefer에서 최대 3개 우선 선택
|
||||||
|
for p in (prefer or []):
|
||||||
|
if p not in ex and len(chosen) < 3:
|
||||||
|
chosen.append(p)
|
||||||
|
# 구간별 1개씩 (갭 우선)
|
||||||
|
for lo, hi in [(1, 9), (10, 19), (20, 29), (30, 39), (40, 45)]:
|
||||||
|
if len(chosen) >= n:
|
||||||
|
break
|
||||||
|
cands = [x for x in range(lo, hi + 1) if x not in ex and x not in chosen]
|
||||||
|
if cands:
|
||||||
|
chosen.append(max(cands, key=lambda x: gap_w.get(x, 0)))
|
||||||
|
# 부족하면 나머지에서 갭 순
|
||||||
|
rest = sorted(
|
||||||
|
[x for x in range(1, 46) if x not in ex and x not in chosen],
|
||||||
|
key=lambda x: -gap_w.get(x, 0),
|
||||||
|
)
|
||||||
|
while len(chosen) < n and rest:
|
||||||
|
chosen.append(rest.pop(0))
|
||||||
|
return sorted(chosen[:n])
|
||||||
|
|
||||||
|
set1 = _pick(exclude=hot_numbers[:5], prefer=overdue_numbers[:5])
|
||||||
|
set2 = _pick()
|
||||||
|
set3 = _pick(exclude=hot_numbers)
|
||||||
|
|
||||||
|
# 신뢰도 점수
|
||||||
|
data_vol = min(total_draws / 500, 1.0)
|
||||||
|
if len(recent_sums) > 1:
|
||||||
|
avg_s = sum(recent_sums) / len(recent_sums)
|
||||||
|
std_s = (sum((s - avg_s) ** 2 for s in recent_sums) / len(recent_sums)) ** 0.5
|
||||||
|
pattern = max(0.0, 1.0 - std_s / 60.0)
|
||||||
|
else:
|
||||||
|
pattern = 0.5
|
||||||
|
trend = max(0.0, 1.0 - len(hot_numbers) / max(len(r10_nums), 1))
|
||||||
|
confidence = round((data_vol * 0.4 + pattern * 0.35 + trend * 0.25) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target_drw_no": target_drw_no,
|
||||||
|
"based_on_draw": draws[-1][0],
|
||||||
|
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"hot_numbers": hot_numbers[:8],
|
||||||
|
"cold_numbers": cold_numbers,
|
||||||
|
"overdue_numbers": overdue_numbers,
|
||||||
|
"recent_pattern": {
|
||||||
|
"last3_numbers": sorted(set(r3_nums)),
|
||||||
|
"triple_appear": triple_appear,
|
||||||
|
"recent_sum_avg": round(sum(recent_sums) / len(recent_sums), 1) if recent_sums else 0,
|
||||||
|
"recent_odd_avg": round(sum(recent_odd) / len(recent_odd), 1) if recent_odd else 0,
|
||||||
|
},
|
||||||
|
"recommended_sets": [
|
||||||
|
{"strategy": "냉각번호 중심", "numbers": set1, "description": "오랫동안 미출현 번호 위주 + 과출현 제외"},
|
||||||
|
{"strategy": "균형형", "numbers": set2, "description": "구간 균형 + 갭 최적화"},
|
||||||
|
{"strategy": "과출현 피하기", "numbers": set3, "description": "최근 자주 나온 번호 완전 제외"},
|
||||||
|
],
|
||||||
|
"confidence_score": confidence,
|
||||||
|
"confidence_factors": {
|
||||||
|
"data_volume": round(data_vol * 100),
|
||||||
|
"pattern_consistency": round(pattern * 100),
|
||||||
|
"recent_trend": round(trend * 100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -596,6 +596,54 @@ def delete_recommendation(rec_id: int) -> bool:
|
|||||||
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
||||||
return cur.rowcount > 0
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def get_recommendation_performance() -> Dict[str, Any]:
|
||||||
|
"""채점된 추천 이력 기반 성과 통계"""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT correct_count, rank FROM recommendations WHERE checked = 1"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return {
|
||||||
|
"total_checked": 0,
|
||||||
|
"avg_correct": 0.0,
|
||||||
|
"distribution": {str(i): 0 for i in range(7)},
|
||||||
|
"rate_3plus": 0.0,
|
||||||
|
"rate_4plus": 0.0,
|
||||||
|
"by_rank": {"rank_1": 0, "rank_2": 0, "rank_3": 0, "rank_4": 0, "rank_5": 0, "no_prize": 0},
|
||||||
|
"vs_random": {"our_avg": 0.0, "random_avg": 0.8, "improvement_pct": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
corrects = [r["correct_count"] or 0 for r in rows]
|
||||||
|
ranks = [r["rank"] or 0 for r in rows]
|
||||||
|
avg_correct = sum(corrects) / total
|
||||||
|
|
||||||
|
RANDOM_AVG = 0.8 # 이론 기댓값: 6 * (6/45)
|
||||||
|
improvement = (avg_correct - RANDOM_AVG) / RANDOM_AVG * 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_checked": total,
|
||||||
|
"avg_correct": round(avg_correct, 3),
|
||||||
|
"distribution": {str(i): corrects.count(i) for i in range(7)},
|
||||||
|
"rate_3plus": round(sum(1 for c in corrects if c >= 3) / total, 4),
|
||||||
|
"rate_4plus": round(sum(1 for c in corrects if c >= 4) / total, 4),
|
||||||
|
"by_rank": {
|
||||||
|
"rank_1": ranks.count(1),
|
||||||
|
"rank_2": ranks.count(2),
|
||||||
|
"rank_3": ranks.count(3),
|
||||||
|
"rank_4": ranks.count(4),
|
||||||
|
"rank_5": ranks.count(5),
|
||||||
|
"no_prize": ranks.count(0),
|
||||||
|
},
|
||||||
|
"vs_random": {
|
||||||
|
"our_avg": round(avg_correct, 3),
|
||||||
|
"random_avg": RANDOM_AVG,
|
||||||
|
"improvement_pct": round(improvement, 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def update_recommendation_result(rec_id: int, rank: int, correct_count: int, has_bonus: bool) -> bool:
|
def update_recommendation_result(rec_id: int, rank: int, correct_count: int, has_bonus: bool) -> bool:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ from .db import (
|
|||||||
get_all_subscription_items, create_subscription_item,
|
get_all_subscription_items, create_subscription_item,
|
||||||
update_subscription_item, delete_subscription_item,
|
update_subscription_item, delete_subscription_item,
|
||||||
get_subscription_profile, upsert_subscription_profile,
|
get_subscription_profile, upsert_subscription_profile,
|
||||||
|
# 성과 통계
|
||||||
|
get_recommendation_performance,
|
||||||
)
|
)
|
||||||
from .recommender import recommend_numbers, recommend_with_heatmap
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
from .collector import sync_latest, sync_ensure_all
|
from .collector import sync_latest, sync_ensure_all
|
||||||
from .generator import run_simulation, generate_smart_recommendations
|
from .generator import run_simulation, generate_smart_recommendations
|
||||||
from .checker import check_results_for_draw
|
from .checker import check_results_for_draw
|
||||||
from .utils import calc_metrics, calc_recent_overlap
|
from .utils import calc_metrics, calc_recent_overlap
|
||||||
from .analyzer import get_statistical_report
|
from .analyzer import get_statistical_report, generate_weekly_report
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
@@ -148,6 +150,51 @@ def api_stats():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 추천 성과 통계 (Phase 1) ─────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/stats/performance")
|
||||||
|
def api_performance_stats():
|
||||||
|
"""
|
||||||
|
채점된 추천 이력 기반 성과 통계.
|
||||||
|
- 평균 일치 개수, 분포, 등수별 현황
|
||||||
|
- 무작위 대비 개선율 (이론 기댓값 0.8개 기준)
|
||||||
|
"""
|
||||||
|
return get_recommendation_performance()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 회차 공략 리포트 (Phase 1) ────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/report/latest")
|
||||||
|
def api_report_latest():
|
||||||
|
"""
|
||||||
|
다음 회차 공략 리포트 (최신 회차 기준으로 자동 계산).
|
||||||
|
- 과출현/냉각/오버듀 번호 분석
|
||||||
|
- 최근 3회 패턴
|
||||||
|
- 3가지 전략별 추천 번호
|
||||||
|
- AI 신뢰도 점수
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
latest = get_latest_draw()
|
||||||
|
target = latest["drw_no"] + 1
|
||||||
|
return generate_weekly_report(draws, target)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/report/{drw_no}")
|
||||||
|
def api_report_by_draw(drw_no: int):
|
||||||
|
"""
|
||||||
|
특정 회차 공략 리포트 (해당 회차 이전 데이터 기준).
|
||||||
|
drw_no: 공략 대상 회차 번호
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
# drw_no 이전 데이터만 사용
|
||||||
|
base_draws = [(no, nums) for no, nums in draws if no < drw_no]
|
||||||
|
if not base_draws:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{drw_no}회차 이전 데이터가 없습니다")
|
||||||
|
return generate_weekly_report(base_draws, drw_no)
|
||||||
|
|
||||||
|
|
||||||
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
|
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
|
||||||
@app.get("/api/lotto/analysis")
|
@app.get("/api/lotto/analysis")
|
||||||
def api_analysis():
|
def api_analysis():
|
||||||
|
|||||||
Reference in New Issue
Block a user