From 2ce118baba4fe1cfc345ee2d7e0d9965a4234c08 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 19 Mar 2026 23:48:28 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EB=98=90=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=AF=B8=EC=97=84=20Phase=201=20=E2=80=94=20=EC=B6=94=EC=B2=9C?= =?UTF-8?q?=20=EC=84=B1=EA=B3=BC=20=ED=86=B5=EA=B3=84=20+=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=20=EA=B3=B5=EB=9E=B5=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/lotto/stats/performance: 채점 이력 기반 성과 통계 (평균 일치 수, 등수 분포, 무작위 대비 개선율) - GET /api/lotto/report/latest: 다음 회차 공략 리포트 자동 생성 - GET /api/lotto/report/{drw_no}: 특정 회차 공략 리포트 (과출현/냉각/오버듀 번호, 최근 패턴, 3가지 전략 추천, 신뢰도 점수) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/analyzer.py | 105 ++++++++++++++++++++++++++++++++++++++++ backend/app/db.py | 48 ++++++++++++++++++ backend/app/main.py | 49 ++++++++++++++++++- 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/backend/app/analyzer.py b/backend/app/analyzer.py index ab987a8..df67c1d 100644 --- a/backend/app/analyzer.py +++ b/backend/app/analyzer.py @@ -18,6 +18,7 @@ import math from collections import Counter, defaultdict +from datetime import datetime, timezone 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]], "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), + }, + } diff --git a/backend/app/db.py b/backend/app/db.py index 41a3d7f..114b3e8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -596,6 +596,54 @@ def delete_recommendation(rec_id: int) -> bool: cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,)) 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: with _conn() as conn: cur = conn.execute( diff --git a/backend/app/main.py b/backend/app/main.py index 301ef5b..12753fb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,13 +20,15 @@ from .db import ( get_all_subscription_items, create_subscription_item, update_subscription_item, delete_subscription_item, get_subscription_profile, upsert_subscription_profile, + # 성과 통계 + get_recommendation_performance, ) from .recommender import recommend_numbers, recommend_with_heatmap from .collector import sync_latest, sync_ensure_all from .generator import run_simulation, generate_smart_recommendations from .checker import check_results_for_draw 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() 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") def api_analysis():