From 732d78becc8b97fc21adc68debc03946daa2f188 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 19 Mar 2026 23:59:07 +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=202=20=E2=80=94=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20+=20=EA=B0=9C=EC=9D=B8=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EB=B6=84=EC=84=9D=20+=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=BA=90=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - purchase_history 테이블 추가 (draw_no, amount, sets, prize, note) - weekly_reports 캐시 테이블 추가 (drw_no UNIQUE, report JSON) - GET /api/lotto/purchase 구매 이력 조회 (draw_no, days 필터) - POST /api/lotto/purchase 구매 이력 추가 - PUT /api/lotto/purchase/:id 구매 이력 수정 (당첨금 업데이트) - DELETE /api/lotto/purchase/:id 구매 이력 삭제 - GET /api/lotto/purchase/stats 투자 수익률 통계 - GET /api/lotto/analysis/personal 개인 패턴 분석 (top/least picks, 홀짝/구간/연속번호) - GET /api/lotto/report/history 저장된 주간 리포트 목록 - GET /api/lotto/report/:drw_no 캐시 우선 조회 + cached 플래그 - 스케줄러: 토요일 09:00 주간 리포트 자동 생성 및 DB 캐싱 Co-Authored-By: Claude Sonnet 4.6 --- backend/app/analyzer.py | 70 +++++++++++++++++ backend/app/db.py | 166 ++++++++++++++++++++++++++++++++++++++++ backend/app/main.py | 105 +++++++++++++++++++++++-- 3 files changed, 336 insertions(+), 5 deletions(-) diff --git a/backend/app/analyzer.py b/backend/app/analyzer.py index df67c1d..6382d2f 100644 --- a/backend/app/analyzer.py +++ b/backend/app/analyzer.py @@ -355,6 +355,76 @@ def get_statistical_report(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any] } +def analyze_personal_patterns( + all_numbers: List[List[int]], + draws: List[Tuple[int, List[int]]], +) -> Dict[str, Any]: + """ + 사용자 추천 이력 기반 개인 패턴 분석. + all_numbers: 저장된 모든 추천 번호 리스트 (각 원소는 6개짜리 리스트) + draws: 역대 당첨번호 (홀짝/합계 평균 비교용) + """ + if not all_numbers: + return {"total_analyzed": 0, "message": "추천 이력이 없습니다"} + + total = len(all_numbers) + flat = [n for nums in all_numbers for n in nums] + freq = Counter(flat) + + # 번호별 선택 빈도 + number_frequency = {n: freq.get(n, 0) for n in range(1, 46)} + top_picks = sorted(range(1, 46), key=lambda n: -freq.get(n, 0))[:10] + least_picks = [n for n in sorted(range(1, 46), key=lambda n: freq.get(n, 0)) if freq.get(n, 0) == 0][:10] + + # 패턴 지표 + odd_counts = [sum(1 for n in nums if n % 2 == 1) for nums in all_numbers] + sums = [sum(nums) for nums in all_numbers] + ranges = [max(nums) - min(nums) for nums in all_numbers] + consecutive_count = sum( + 1 for nums in all_numbers + if any(sorted(nums)[i + 1] - sorted(nums)[i] == 1 for i in range(5)) + ) + + zone_totals = {k: 0 for k in ["1-9", "10-19", "20-29", "30-39", "40-45"]} + zone_ranges = [("1-9", 1, 9), ("10-19", 10, 19), ("20-29", 20, 29), ("30-39", 30, 39), ("40-45", 40, 45)] + for nums in all_numbers: + for label, lo, hi in zone_ranges: + zone_totals[label] += sum(1 for n in nums if lo <= n <= hi) + zone_avg = {k: round(v / total, 2) for k, v in zone_totals.items()} + + avg_odd = sum(odd_counts) / total + avg_sum = sum(sums) / total + avg_range = sum(ranges) / total + + # 역대 당첨번호 평균과 비교 + if draws: + draw_odd_avg = sum(sum(1 for n in nums if n % 2 == 1) for _, nums in draws) / len(draws) + draw_sum_avg = sum(sum(nums) for _, nums in draws) / len(draws) + else: + draw_odd_avg = 3.0 + draw_sum_avg = 138.0 + + return { + "total_analyzed": total, + "number_frequency": number_frequency, + "top_picks": top_picks, + "least_picks": least_picks, + "pattern": { + "avg_odd_count": round(avg_odd, 2), + "avg_sum": round(avg_sum, 1), + "avg_range": round(avg_range, 1), + "consecutive_rate": round(consecutive_count / total, 3), + "zone_avg": zone_avg, + }, + "vs_draw_avg": { + "odd_diff": round(avg_odd - draw_odd_avg, 2), + "sum_diff": round(avg_sum - draw_sum_avg, 1), + "odd_tendency": "홀수 선호" if avg_odd > draw_odd_avg + 0.2 else ("짝수 선호" if avg_odd < draw_odd_avg - 0.2 else "균형"), + "sum_tendency": "고합계 선호" if avg_sum > draw_sum_avg + 5 else ("저합계 선호" if avg_sum < draw_sum_avg - 5 else "균형"), + }, + } + + def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]: """ 특정 회차 공략 리포트 생성. diff --git a/backend/app/db.py b/backend/app/db.py index 114b3e8..455c751 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -251,6 +251,34 @@ def init_db() -> None: "CREATE INDEX IF NOT EXISTS idx_sub_items_created ON subscription_items(created_at DESC);" ) + # ── purchase_history 테이블 ──────────────────────────────────────────── + conn.execute( + """ + CREATE TABLE IF NOT EXISTS purchase_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + draw_no INTEGER NOT NULL, + amount INTEGER NOT NULL, + sets INTEGER NOT NULL DEFAULT 1, + prize INTEGER NOT NULL DEFAULT 0, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_draw ON purchase_history(draw_no DESC);") + + # ── weekly_reports 캐시 테이블 ────────────────────────────────────────── + conn.execute( + """ + CREATE TABLE IF NOT EXISTS weekly_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drw_no INTEGER UNIQUE NOT NULL, + report TEXT NOT NULL, + generated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + """ + ) + # ── subscription_profile 테이블 (싱글톤 id=1) ────────────────────────── conn.execute( """ @@ -1109,6 +1137,144 @@ def get_subscription_profile() -> Optional[Dict[str, Any]]: return _profile_row_to_dict(r) if r else None +# ── purchase_history CRUD ───────────────────────────────────────────────────── + +def _purchase_row_to_dict(r) -> Dict[str, Any]: + return { + "id": r["id"], + "draw_no": r["draw_no"], + "amount": r["amount"], + "sets": r["sets"], + "prize": r["prize"], + "note": r["note"], + "created_at": r["created_at"], + } + + +def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "") -> Dict[str, Any]: + with _conn() as conn: + conn.execute( + "INSERT INTO purchase_history (draw_no, amount, sets, prize, note) VALUES (?, ?, ?, ?, ?)", + (draw_no, amount, sets, prize, note), + ) + row = conn.execute("SELECT * FROM purchase_history WHERE rowid = last_insert_rowid()").fetchone() + return _purchase_row_to_dict(row) + + +def get_purchases(draw_no: int = None, days: int = None) -> List[Dict[str, Any]]: + conditions, params = [], [] + if draw_no is not None: + conditions.append("draw_no = ?") + params.append(draw_no) + if days: + conditions.append("created_at >= datetime('now', ? || ' days')") + params.append(f"-{days}") + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + with _conn() as conn: + rows = conn.execute( + f"SELECT * FROM purchase_history {where} ORDER BY draw_no DESC, id DESC", + params, + ).fetchall() + return [_purchase_row_to_dict(r) for r in rows] + + +def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + allowed = {"draw_no", "amount", "sets", "prize", "note"} + updates = {k: v for k, v in data.items() if k in allowed} + if not updates: + with _conn() as conn: + row = conn.execute("SELECT * FROM purchase_history WHERE id = ?", (purchase_id,)).fetchone() + return _purchase_row_to_dict(row) if row else None + set_clause = ", ".join(f"{k} = ?" for k in updates) + with _conn() as conn: + cur = conn.execute( + f"UPDATE purchase_history SET {set_clause} WHERE id = ?", + list(updates.values()) + [purchase_id], + ) + if cur.rowcount == 0: + return None + row = conn.execute("SELECT * FROM purchase_history WHERE id = ?", (purchase_id,)).fetchone() + return _purchase_row_to_dict(row) + + +def delete_purchase(purchase_id: int) -> bool: + with _conn() as conn: + cur = conn.execute("DELETE FROM purchase_history WHERE id = ?", (purchase_id,)) + return cur.rowcount > 0 + + +def get_purchase_stats() -> Dict[str, Any]: + with _conn() as conn: + rows = conn.execute("SELECT amount, prize FROM purchase_history").fetchall() + if not rows: + return { + "total_records": 0, + "total_invested": 0, + "total_prize": 0, + "net": 0, + "return_rate": 0.0, + "prize_count": 0, + "max_prize": 0, + } + amounts = [r["amount"] for r in rows] + prizes = [r["prize"] for r in rows] + total_invested = sum(amounts) + total_prize = sum(prizes) + return { + "total_records": len(rows), + "total_invested": total_invested, + "total_prize": total_prize, + "net": total_prize - total_invested, + "return_rate": round((total_prize / total_invested * 100) if total_invested else 0.0, 2), + "prize_count": sum(1 for p in prizes if p > 0), + "max_prize": max(prizes), + } + + +# ── weekly_reports CRUD ─────────────────────────────────────────────────────── + +def save_weekly_report(drw_no: int, report_json: str) -> None: + with _conn() as conn: + conn.execute( + """ + INSERT INTO weekly_reports (drw_no, report) + VALUES (?, ?) + ON CONFLICT(drw_no) DO UPDATE SET + report = excluded.report, + generated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + """, + (drw_no, report_json), + ) + + +def get_weekly_report_list(limit: int = 10) -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute( + "SELECT drw_no, generated_at FROM weekly_reports ORDER BY drw_no DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + +def get_weekly_report(drw_no: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute( + "SELECT drw_no, report, generated_at FROM weekly_reports WHERE drw_no = ?", + (drw_no,), + ).fetchone() + if not row: + return None + import json as _json + return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **_json.loads(row["report"])} + + +def get_all_recommendation_numbers() -> List[List[int]]: + """개인 패턴 분석용: 저장된 모든 추천 번호 반환""" + with _conn() as conn: + rows = conn.execute("SELECT numbers FROM recommendations ORDER BY id DESC").fetchall() + return [json.loads(r["numbers"]) for r in rows] + + def upsert_subscription_profile(data: Dict[str, Any]) -> Dict[str, Any]: field_map = { "isHouseholdHead": "is_household_head", diff --git a/backend/app/main.py b/backend/app/main.py index 12753fb..1017d4d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,13 +22,19 @@ from .db import ( get_subscription_profile, upsert_subscription_profile, # 성과 통계 get_recommendation_performance, + # Phase 2: 구매 이력 + add_purchase, get_purchases, update_purchase, delete_purchase, get_purchase_stats, + # Phase 2: 주간 리포트 캐시 + save_weekly_report, get_weekly_report_list, get_weekly_report, + # Phase 2: 개인 패턴 분석 + get_all_recommendation_numbers, ) 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, generate_weekly_report +from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns app = FastAPI() scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) @@ -57,6 +63,20 @@ def on_startup(): scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5) + # 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱 + def _save_weekly_report_job(): + import json as _json + draws = get_all_draw_numbers() + latest = get_latest_draw() + if not draws or not latest: + return + target = latest["drw_no"] + 1 + report = generate_weekly_report(draws, target) + save_weekly_report(target, _json.dumps(report, ensure_ascii=False)) + print(f"[WeeklyReport] {target}회차 리포트 저장 완료") + + scheduler.add_job(_save_weekly_report_job, "cron", day_of_week="sat", hour=9, minute=0) + scheduler.start() @@ -179,20 +199,95 @@ def api_report_latest(): return generate_weekly_report(draws, target) +@app.get("/api/lotto/report/history") +def api_report_history(limit: int = 10): + """저장된 주간 리포트 목록 (자동 저장된 것만)""" + return {"reports": get_weekly_report_list(limit=min(limit, 52))} + + @app.get("/api/lotto/report/{drw_no}") def api_report_by_draw(drw_no: int): """ - 특정 회차 공략 리포트 (해당 회차 이전 데이터 기준). - drw_no: 공략 대상 회차 번호 + 특정 회차 공략 리포트 (캐시 우선, 없으면 실시간 생성). """ + cached = get_weekly_report(drw_no) + if cached: + return {**cached, "cached": True} + 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) + return {**generate_weekly_report(base_draws, drw_no), "cached": False} + + +# ── 개인 패턴 분석 (Phase 2) ───────────────────────────────────────────────── +@app.get("/api/lotto/analysis/personal") +def api_personal_analysis(): + """ + 저장된 추천 이력 기반 개인 패턴 분석. + - 자주 선택한 번호 TOP 10 / 한 번도 선택 안 한 번호 + - 홀짝 비율, 합계, 범위, 연속번호 포함률 + - 구간별 분포, 역대 당첨번호 평균과 비교 + """ + all_numbers = get_all_recommendation_numbers() + draws = get_all_draw_numbers() + return analyze_personal_patterns(all_numbers, draws) + + +# ── 구매 이력 API (Phase 2) ─────────────────────────────────────────────────── + +class PurchaseCreate(BaseModel): + draw_no: int + amount: int + sets: int = 1 + prize: int = 0 + note: str = "" + + +class PurchaseUpdate(BaseModel): + draw_no: Optional[int] = None + amount: Optional[int] = None + sets: Optional[int] = None + prize: Optional[int] = None + note: Optional[str] = None + + +@app.get("/api/lotto/purchase/stats") +def api_purchase_stats(): + """투자 수익률 통계 (총 투자금, 총 당첨금, 수익률 등)""" + return get_purchase_stats() + + +@app.get("/api/lotto/purchase") +def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None): + """구매 이력 조회 (draw_no, days 필터 선택)""" + return {"records": get_purchases(draw_no=draw_no, days=days)} + + +@app.post("/api/lotto/purchase", status_code=201) +def api_purchase_create(body: PurchaseCreate): + """구매 이력 추가""" + return add_purchase(body.draw_no, body.amount, body.sets, body.prize, body.note) + + +@app.put("/api/lotto/purchase/{purchase_id}") +def api_purchase_update(purchase_id: int, body: PurchaseUpdate): + """구매 이력 수정 (당첨금 업데이트 등)""" + updated = update_purchase(purchase_id, body.model_dump(exclude_none=True)) + if updated is None: + raise HTTPException(status_code=404, detail="Purchase not found") + return updated + + +@app.delete("/api/lotto/purchase/{purchase_id}") +def api_purchase_delete(purchase_id: int): + """구매 이력 삭제""" + if not delete_purchase(purchase_id): + raise HTTPException(status_code=404, detail="Purchase not found") + return {"ok": True} # ── 통계 분석 리포트 ────────────────────────────────────────────────────────