diff --git a/backend/app/analyzer.py b/backend/app/analyzer.py index 6382d2f..36405fe 100644 --- a/backend/app/analyzer.py +++ b/backend/app/analyzer.py @@ -425,6 +425,132 @@ def analyze_personal_patterns( } +def generate_combined_recommendation(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]: + """ + 5가지 통계 기법을 종합한 추론 번호 추천. + + 각 기법이 상위 6개 번호를 추천하고, 기법별 가중치(score_combination 가중치와 동일)로 + 투표를 집계한 뒤 최종 6개 번호를 선정한다. + + 가중치: 빈도Z(25%) · 지문(30%) · 갭(20%) · 공동출현(15%) · 다양성(10%) + """ + if not draws: + return {"error": "데이터 없음"} + + cache = build_analysis_cache(draws) + z = cache["z_scores"] + gap = cache["last_seen_gap"] + freq = cache["freq_all"] + cooccur = cache["cooccur"] + zone_probs = cache["zone_probs"] + + # ── Method 1: 빈도 Z-score ──────────────────────────────────────────────── + # Z-score 내림차순 상위 6 (출현 빈도가 기댓값보다 높은 번호) + m_frequency = sorted(range(1, 46), key=lambda n: -z.get(n, 0))[:6] + + # ── Method 2: 갭 분석 ───────────────────────────────────────────────────── + # 가장 오래 미출현한 번호 6개 (오버듀) + m_gap = sorted(range(1, 46), key=lambda n: -gap.get(n, 0))[:6] + + # ── Method 3: 공동출현 ──────────────────────────────────────────────────── + # 각 번호의 총 공동출현 합산 점수 내림차순 6개 + cooccur_total: Dict[int, float] = defaultdict(float) + for (a, b), cnt in cooccur.items(): + cooccur_total[a] += cnt + cooccur_total[b] += cnt + m_cooccur = sorted(range(1, 46), key=lambda n: -cooccur_total.get(n, 0))[:6] + + # ── Method 4: 조합 지문 ─────────────────────────────────────────────────── + # 역대 당첨 조합의 구간별 최빈 분포에 맞게 각 구간에서 빈도 상위 번호 선택 + zone_targets: List[int] = [] + for zp in zone_probs: + zone_targets.append(max(zp, key=zp.get) if zp else 1) + + # 합이 정확히 6이 되도록 조정 + diff = sum(zone_targets) - 6 + if diff > 0: + idxs = sorted(range(5), key=lambda i: -zone_targets[i]) + for i in idxs: + if diff <= 0: + break + zone_targets[i] = max(0, zone_targets[i] - 1) + diff -= 1 + elif diff < 0: + idxs = sorted(range(5), key=lambda i: zone_targets[i]) + for i in idxs: + if diff >= 0: + break + zone_targets[i] += 1 + diff += 1 + + m_fingerprint: List[int] = [] + for (lo, hi), tgt in zip(ZONE_RANGES, zone_targets): + zone_nums = sorted(range(lo, hi + 1), key=lambda x: -freq.get(x, 0)) + m_fingerprint.extend(zone_nums[:tgt]) + m_fingerprint = sorted(m_fingerprint[:6]) + + # ── Method 5: 다양성 ────────────────────────────────────────────────────── + # 각 구간에서 갭 가장 큰 번호 1개씩 (5개) + 전체 갭 상위 1개 보충 + m_diversity: List[int] = [] + for lo, hi in ZONE_RANGES: + zone_nums = sorted(range(lo, hi + 1), key=lambda n: -gap.get(n, 0)) + if zone_nums: + m_diversity.append(zone_nums[0]) + if len(m_diversity) < 6: + rest = sorted( + [x for x in range(1, 46) if x not in m_diversity], + key=lambda n: -gap.get(n, 0), + ) + m_diversity.extend(rest[: 6 - len(m_diversity)]) + m_diversity = sorted(m_diversity[:6]) + + # ── 가중 투표 집계 ──────────────────────────────────────────────────────── + # score_combination 가중치와 동일: 빈도25, 지문30, 갭20, 공동출현15, 다양성10 + method_entries = [ + (m_frequency, 25, "frequency", "빈도 Z-score"), + (m_fingerprint, 30, "fingerprint", "조합 지문"), + (m_gap, 20, "gap", "갭 분석"), + (m_cooccur, 15, "cooccur", "공동 출현"), + (m_diversity, 10, "diversity", "다양성"), + ] + + vote_scores: Dict[int, float] = {n: 0.0 for n in range(1, 46)} + for method_nums, weight, _, _ in method_entries: + for rank, n in enumerate(method_nums): + # rank 0 = 1위: (6-0)×weight = 6w, rank 5 = 6위: (6-5)×weight = w + vote_scores[n] += (6 - rank) * weight + + # 상위 6개 — 동점 시 Z-score 타이브레이크 + final_numbers = sorted( + sorted(range(1, 46), key=lambda n: (-vote_scores[n], -z.get(n, 0)))[:6] + ) + + scores = score_combination(final_numbers, cache) + + # 각 번호가 몇 개 방법에서 채택됐는지 + vote_counts: Dict[str, int] = { + str(n): sum(1 for nums, _, _, _ in method_entries if n in nums) + for n in range(1, 46) + } + + methods_result: Dict[str, Any] = {} + for nums, weight, key, label in method_entries: + methods_result[key] = { + "label": label, + "weight_pct": weight, + "numbers": sorted(nums), + } + + return { + "methods": methods_result, + "final_numbers": final_numbers, + "scores": scores, + "vote_scores": {str(n): round(vote_scores[n], 1) for n in range(1, 46)}, + "vote_counts": vote_counts, + "total_draws": cache["total_draws"], + } + + def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]: """ 특정 회차 공략 리포트 생성. diff --git a/backend/app/main.py b/backend/app/main.py index aa83c8c..852a0a8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,7 +35,7 @@ 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, generate_weekly_report, analyze_personal_patterns +from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns, generate_combined_recommendation app = FastAPI() scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) @@ -389,6 +389,46 @@ def api_simulation(run_id: Optional[int] = None, runs_limit: int = 5): } +# ── 종합 추론 추천 ─────────────────────────────────────────────────────────── + +@app.get("/api/lotto/recommend/combined") +def api_recommend_combined(): + """5가지 통계 기법 종합 추론 추천 — 결과를 이력에 저장한다.""" + draws = get_all_draw_numbers() + if not draws: + raise HTTPException(status_code=404, detail="No data") + + latest = get_latest_draw() + result = generate_combined_recommendation(draws) + if "error" in result: + raise HTTPException(status_code=500, detail=result["error"]) + + # 추천 이력 저장 (태그: 종합추론) + params = {"method": "combined"} + saved = save_recommendation_dedup( + latest["drw_no"] if latest else None, + result["final_numbers"], + params, + ) + if saved["saved"]: + update_recommendation(saved["id"], tags=["종합추론"]) + + return { + **result, + "id": saved["id"], + "saved": saved["saved"], + "deduped": saved["deduped"], + "based_on_latest_draw": latest["drw_no"] if latest else None, + } + + +@app.get("/api/lotto/recommend/combined/history") +def api_combined_history(limit: int = 30): + """종합추론 추천 이력 조회.""" + items = list_recommendations_ex(limit=limit, tag="종합추론", sort="id_desc") + return {"items": items, "total": len(items)} + + # ── 기존 수동 추천 API (하위 호환 유지) ───────────────────────────────────── @app.get("/api/lotto/recommend") def api_recommend(