# backend/app/db.py import os import sqlite3 import json import hashlib from typing import Any, Dict, Optional, List DB_PATH = "/app/data/lotto.db" def _conn() -> sqlite3.Connection: os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def _ensure_column(conn: sqlite3.Connection, table: str, col: str, ddl: str) -> None: cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} if col not in cols: conn.execute(ddl) def init_db() -> None: with _conn() as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS draws ( drw_no INTEGER PRIMARY KEY, drw_date TEXT NOT NULL, n1 INTEGER NOT NULL, n2 INTEGER NOT NULL, n3 INTEGER NOT NULL, n4 INTEGER NOT NULL, n5 INTEGER NOT NULL, n6 INTEGER NOT NULL, bonus INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_draws_date ON draws(drw_date);") conn.execute( """ CREATE TABLE IF NOT EXISTS recommendations ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), based_on_draw INTEGER, numbers TEXT NOT NULL, params TEXT NOT NULL ); """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_created ON recommendations(created_at DESC);") # ✅ 확장 컬럼들(기존 DB에도 자동 추가) _ensure_column(conn, "recommendations", "numbers_sorted", "ALTER TABLE recommendations ADD COLUMN numbers_sorted TEXT;") _ensure_column(conn, "recommendations", "dedup_hash", "ALTER TABLE recommendations ADD COLUMN dedup_hash TEXT;") _ensure_column(conn, "recommendations", "favorite", "ALTER TABLE recommendations ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;") _ensure_column(conn, "recommendations", "note", "ALTER TABLE recommendations ADD COLUMN note TEXT NOT NULL DEFAULT '';") _ensure_column(conn, "recommendations", "tags", "ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';") # ✅ 결과 채점용 컬럼 추가 _ensure_column(conn, "recommendations", "rank", "ALTER TABLE recommendations ADD COLUMN rank INTEGER;") _ensure_column(conn, "recommendations", "correct_count", "ALTER TABLE recommendations ADD COLUMN correct_count INTEGER DEFAULT 0;") _ensure_column(conn, "recommendations", "has_bonus", "ALTER TABLE recommendations ADD COLUMN has_bonus INTEGER DEFAULT 0;") _ensure_column(conn, "recommendations", "checked", "ALTER TABLE recommendations ADD COLUMN checked INTEGER DEFAULT 0;") # ✅ UNIQUE 인덱스(중복 저장 방지) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);") # ── 시뮬레이션 테이블 ───────────────────────────────────────────────── conn.execute( """ CREATE TABLE IF NOT EXISTS simulation_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_at TEXT NOT NULL DEFAULT (datetime('now')), strategy TEXT NOT NULL DEFAULT 'monte_carlo', total_generated INTEGER NOT NULL DEFAULT 0, top_k_selected INTEGER NOT NULL DEFAULT 0, avg_score REAL, notes TEXT DEFAULT '' ); """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_simrun_at ON simulation_runs(run_at DESC);" ) conn.execute( """ CREATE TABLE IF NOT EXISTS simulation_candidates ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, numbers TEXT NOT NULL, score_total REAL NOT NULL, score_frequency REAL, score_fingerprint REAL, score_gap REAL, score_cooccur REAL, score_diversity REAL, is_best INTEGER DEFAULT 0, based_on_draw INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY(run_id) REFERENCES simulation_runs(id) ); """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_simcand_run " "ON simulation_candidates(run_id, score_total DESC);" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_simcand_best " "ON simulation_candidates(is_best, score_total DESC);" ) conn.execute( """ CREATE TABLE IF NOT EXISTS best_picks ( id INTEGER PRIMARY KEY AUTOINCREMENT, numbers TEXT NOT NULL, score_total REAL NOT NULL, rank_in_run INTEGER, source_run_id INTEGER, based_on_draw INTEGER, is_active INTEGER DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY(source_run_id) REFERENCES simulation_runs(id) ); """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_bestpicks_active " "ON best_picks(is_active, score_total DESC);" ) # ── todos 테이블 ─────────────────────────────────────────────────────── conn.execute( """ CREATE TABLE IF NOT EXISTS todos ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))), title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'todo' CHECK(status IN ('todo','in_progress','done')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC);" ) # ── blog_posts 테이블 ────────────────────────────────────────────────── conn.execute( """ CREATE TABLE IF NOT EXISTS blog_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', excerpt TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', date TEXT NOT NULL DEFAULT (date('now','localtime')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date 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);") # ── purchase_history 컬럼 확장 (기존 데이터 보존) ────────────────────── _ensure_column(conn, "purchase_history", "numbers", "ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]'") _ensure_column(conn, "purchase_history", "is_real", "ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1") _ensure_column(conn, "purchase_history", "source_strategy", "ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual'") _ensure_column(conn, "purchase_history", "source_detail", "ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}'") _ensure_column(conn, "purchase_history", "checked", "ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "purchase_history", "results", "ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]'") _ensure_column(conn, "purchase_history", "total_prize", "ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0") # ── strategy_performance 테이블 ──────────────────────────────────────── conn.execute( """ CREATE TABLE IF NOT EXISTS strategy_performance ( id INTEGER PRIMARY KEY AUTOINCREMENT, strategy TEXT NOT NULL, draw_no INTEGER NOT NULL, sets_count INTEGER NOT NULL DEFAULT 0, total_correct INTEGER NOT NULL DEFAULT 0, max_correct INTEGER NOT NULL DEFAULT 0, prize_total INTEGER NOT NULL DEFAULT 0, avg_score REAL NOT NULL DEFAULT 0.0, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(strategy, draw_no) ); """ ) # ── strategy_weights 테이블 ──────────────────────────────────────────── conn.execute( """ CREATE TABLE IF NOT EXISTS strategy_weights ( id INTEGER PRIMARY KEY AUTOINCREMENT, strategy TEXT NOT NULL UNIQUE, weight REAL NOT NULL DEFAULT 0.2, ema_score REAL NOT NULL DEFAULT 0.15, total_sets INTEGER NOT NULL DEFAULT 0, total_hits_3plus INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); """ ) # strategy_weights 초기값 시드 (이미 있으면 무시) _INIT_WEIGHTS = [ ("combined", 0.30, 0.15), ("simulation", 0.25, 0.15), ("heatmap", 0.20, 0.15), ("manual", 0.15, 0.15), ("custom", 0.10, 0.15), ] for strat, w, ema in _INIT_WEIGHTS: conn.execute( "INSERT OR IGNORE INTO strategy_weights (strategy, weight, ema_score) VALUES (?, ?, ?)", (strat, w, ema), ) # ── 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')) ); """ ) # ── todos CRUD ─────────────────────────────────────────────────────────────── def _todo_row_to_dict(r) -> Dict[str, Any]: return { "id": r["id"], "title": r["title"], "description": r["description"], "status": r["status"], "created_at": r["created_at"], "updated_at": r["updated_at"], } def get_all_todos() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute( "SELECT * FROM todos ORDER BY created_at DESC" ).fetchall() return [_todo_row_to_dict(r) for r in rows] def create_todo(title: str, description: Optional[str], status: str) -> Dict[str, Any]: with _conn() as conn: conn.execute( "INSERT INTO todos (title, description, status) VALUES (?, ?, ?)", (title, description, status), ) row = conn.execute( "SELECT * FROM todos WHERE rowid = last_insert_rowid()" ).fetchone() return _todo_row_to_dict(row) def update_todo(todo_id: str, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]: """fields에 있는 항목만 업데이트 (PATCH 방식), updated_at 자동 갱신""" allowed = {"title", "description", "status"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: with _conn() as conn: row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone() return _todo_row_to_dict(row) if row else None set_clauses = ", ".join(f"{k} = ?" for k in updates) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" args = list(updates.values()) + [todo_id] with _conn() as conn: conn.execute( f"UPDATE todos SET {set_clauses} WHERE id = ?", args, ) row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone() return _todo_row_to_dict(row) if row else None def delete_todo(todo_id: str) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,)) return cur.rowcount > 0 def delete_done_todos() -> int: with _conn() as conn: cur = conn.execute("DELETE FROM todos WHERE status = 'done'") return cur.rowcount # ── blog_posts CRUD ────────────────────────────────────────────────────────── def _post_row_to_dict(r) -> Dict[str, Any]: return { "id": r["id"], "title": r["title"], "body": r["body"], "excerpt": r["excerpt"], "tags": json.loads(r["tags"]) if r["tags"] else [], "date": r["date"], "created_at": r["created_at"], "updated_at": r["updated_at"], } def get_all_posts() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute( "SELECT * FROM blog_posts ORDER BY date DESC, id DESC" ).fetchall() return [_post_row_to_dict(r) for r in rows] def create_post(title: str, body: str, excerpt: str, tags: List[str], date: str) -> Dict[str, Any]: with _conn() as conn: conn.execute( "INSERT INTO blog_posts (title, body, excerpt, tags, date) VALUES (?, ?, ?, ?, ?)", (title, body, excerpt, json.dumps(tags), date), ) row = conn.execute( "SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()" ).fetchone() return _post_row_to_dict(row) def update_post(post_id: int, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]: allowed = {"title", "body", "excerpt", "tags", "date"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: with _conn() as conn: row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone() return _post_row_to_dict(row) if row else None if "tags" in updates: updates["tags"] = json.dumps(updates["tags"]) set_clauses = ", ".join(f"{k} = ?" for k in updates) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" args = list(updates.values()) + [post_id] with _conn() as conn: conn.execute(f"UPDATE blog_posts SET {set_clauses} WHERE id = ?", args) row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone() return _post_row_to_dict(row) if row else None def delete_post(post_id: int) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,)) return cur.rowcount > 0 def upsert_draw(row: Dict[str, Any]) -> None: with _conn() as conn: conn.execute( """ INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(drw_no) DO UPDATE SET drw_date=excluded.drw_date, n1=excluded.n1, n2=excluded.n2, n3=excluded.n3, n4=excluded.n4, n5=excluded.n5, n6=excluded.n6, bonus=excluded.bonus, updated_at=datetime('now') """, ( int(row["drw_no"]), str(row["drw_date"]), int(row["n1"]), int(row["n2"]), int(row["n3"]), int(row["n4"]), int(row["n5"]), int(row["n6"]), int(row["bonus"]), ), ) def upsert_many_draws(rows: List[Dict[str, Any]]) -> None: data = [ ( int(r["drw_no"]), str(r["drw_date"]), int(r["n1"]), int(r["n2"]), int(r["n3"]), int(r["n4"]), int(r["n5"]), int(r["n6"]), int(r["bonus"]) ) for r in rows ] with _conn() as conn: conn.executemany( """ INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(drw_no) DO UPDATE SET drw_date=excluded.drw_date, n1=excluded.n1, n2=excluded.n2, n3=excluded.n3, n4=excluded.n4, n5=excluded.n5, n6=excluded.n6, bonus=excluded.bonus, updated_at=datetime('now') """, data ) def get_latest_draw() -> Optional[Dict[str, Any]]: with _conn() as conn: r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone() return dict(r) if r else None def get_draw(drw_no: int) -> Optional[Dict[str, Any]]: with _conn() as conn: r = conn.execute("SELECT * FROM draws WHERE drw_no = ?", (drw_no,)).fetchone() return dict(r) if r else None def count_draws() -> int: with _conn() as conn: r = conn.execute("SELECT COUNT(*) AS c FROM draws").fetchone() return int(r["c"]) def get_all_draw_numbers(): with _conn() as conn: rows = conn.execute( "SELECT drw_no, n1, n2, n3, n4, n5, n6 FROM draws ORDER BY drw_no ASC" ).fetchall() return [(int(r["drw_no"]), [int(r["n1"]), int(r["n2"]), int(r["n3"]), int(r["n4"]), int(r["n5"]), int(r["n6"])]) for r in rows] # ---------- ✅ recommendation helpers ---------- def _canonical_params(params: dict) -> str: return json.dumps(params, sort_keys=True, separators=(",", ":")) def _numbers_sorted_str(numbers: List[int]) -> str: return ",".join(str(x) for x in sorted(numbers)) def _dedup_hash(based_on_draw: Optional[int], numbers: List[int], params: dict) -> str: s = f"{based_on_draw or ''}|{_numbers_sorted_str(numbers)}|{_canonical_params(params)}" return hashlib.sha1(s.encode("utf-8")).hexdigest() def save_recommendation_dedup(based_on_draw: Optional[int], numbers: List[int], params: dict) -> Dict[str, Any]: """ ✅ 동일 추천(번호+params+based_on_draw)이면 중복 저장 없이 기존 id 반환 """ ns = _numbers_sorted_str(numbers) h = _dedup_hash(based_on_draw, numbers, params) with _conn() as conn: # 이미 있으면 반환 r = conn.execute("SELECT id FROM recommendations WHERE dedup_hash = ?", (h,)).fetchone() if r: return {"id": int(r["id"]), "saved": False, "deduped": True} cur = conn.execute( """ INSERT INTO recommendations (based_on_draw, numbers, params, numbers_sorted, dedup_hash) VALUES (?, ?, ?, ?, ?) """, (based_on_draw, json.dumps(numbers), json.dumps(params), ns, h), ) return {"id": int(cur.lastrowid), "saved": True, "deduped": False} def list_recommendations_ex( limit: int = 30, offset: int = 0, favorite: Optional[bool] = None, tag: Optional[str] = None, q: Optional[str] = None, sort: str = "id_desc", # id_desc|created_desc|favorite_desc ) -> List[Dict[str, Any]]: import json where = [] args: list[Any] = [] if favorite is not None: where.append("favorite = ?") args.append(1 if favorite else 0) if q: where.append("note LIKE ?") args.append(f"%{q}%") # tags는 JSON 문자열이므로 단순 LIKE로 처리(가볍게 시작) if tag: where.append("tags LIKE ?") args.append(f"%{tag}%") where_sql = ("WHERE " + " AND ".join(where)) if where else "" if sort == "created_desc": order = "created_at DESC" elif sort == "favorite_desc": # favorite(1)이 먼저, 그 다음 최신 order = "favorite DESC, id DESC" else: order = "id DESC" sql = f""" SELECT id, created_at, based_on_draw, numbers, params, favorite, note, tags FROM recommendations {where_sql} ORDER BY {order} LIMIT ? OFFSET ? """ args.extend([int(limit), int(offset)]) with _conn() as conn: rows = conn.execute(sql, args).fetchall() out = [] for r in rows: out.append({ "id": int(r["id"]), "created_at": r["created_at"], "based_on_draw": r["based_on_draw"], "numbers": json.loads(r["numbers"]), "params": json.loads(r["params"]), "favorite": bool(r["favorite"]) if r["favorite"] is not None else False, "note": r["note"], "tags": json.loads(r["tags"]) if r["tags"] else [], }) return out def update_recommendation(rec_id: int, favorite: Optional[bool] = None, note: Optional[str] = None, tags: Optional[List[str]] = None) -> bool: fields = [] args: list[Any] = [] if favorite is not None: fields.append("favorite = ?") args.append(1 if favorite else 0) if note is not None: fields.append("note = ?") args.append(note) if tags is not None: fields.append("tags = ?") args.append(json.dumps(tags)) if not fields: return False args.append(rec_id) with _conn() as conn: cur = conn.execute( f"UPDATE recommendations SET {', '.join(fields)} WHERE id = ?", args, ) return cur.rowcount > 0 def delete_recommendation(rec_id: int) -> bool: with _conn() as conn: 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( """ UPDATE recommendations SET rank = ?, correct_count = ?, has_bonus = ?, checked = 1 WHERE id = ? """, (rank, correct_count, 1 if has_bonus else 0, rec_id) ) return cur.rowcount > 0 # ── 시뮬레이션 CRUD ───────────────────────────────────────────────────────── def save_simulation_run( strategy: str, total_generated: int, top_k_selected: int, avg_score: float, notes: str = "", ) -> int: """시뮬레이션 실행 기록 저장, 생성된 ID 반환""" with _conn() as conn: cur = conn.execute( """ INSERT INTO simulation_runs (strategy, total_generated, top_k_selected, avg_score, notes) VALUES (?, ?, ?, ?, ?) """, (strategy, total_generated, top_k_selected, round(avg_score, 6), notes), ) return int(cur.lastrowid) def save_simulation_candidates_bulk( run_id: int, candidates: List[Dict[str, Any]], based_on_draw: Optional[int], ) -> None: """ 상위 후보들을 simulation_candidates 테이블에 일괄 저장. candidates 각 항목: {"numbers": [...], "score_total": ..., "score_*": ..., "is_best": bool} """ data = [ ( run_id, json.dumps(sorted(c["numbers"])), c["score_total"], c.get("score_frequency"), c.get("score_fingerprint"), c.get("score_gap"), c.get("score_cooccur"), c.get("score_diversity"), 1 if c.get("is_best") else 0, based_on_draw, ) for c in candidates ] with _conn() as conn: conn.executemany( """ INSERT INTO simulation_candidates (run_id, numbers, score_total, score_frequency, score_fingerprint, score_gap, score_cooccur, score_diversity, is_best, based_on_draw) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, data, ) def replace_best_picks( picks: List[Dict[str, Any]], run_id: int, based_on_draw: Optional[int], ) -> None: """ 기존 활성 best_picks를 비활성화하고 새 picks로 교체. picks 각 항목: {"numbers": [...], "score_total": ..., "rank_in_run": int} """ with _conn() as conn: conn.execute("UPDATE best_picks SET is_active = 0 WHERE is_active = 1") data = [ ( json.dumps(sorted(p["numbers"])), p["score_total"], p.get("rank_in_run"), run_id, based_on_draw, ) for p in picks ] conn.executemany( """ INSERT INTO best_picks (numbers, score_total, rank_in_run, source_run_id, based_on_draw, is_active) VALUES (?, ?, ?, ?, ?, 1) """, data, ) def get_best_picks(limit: int = 20) -> List[Dict[str, Any]]: """현재 활성화된 best_picks 조회 (점수 내림차순)""" with _conn() as conn: rows = conn.execute( """ SELECT id, numbers, score_total, rank_in_run, source_run_id, based_on_draw, created_at FROM best_picks WHERE is_active = 1 ORDER BY score_total DESC LIMIT ? """, (limit,), ).fetchall() return [ { "id": int(r["id"]), "numbers": json.loads(r["numbers"]), "score_total": r["score_total"], "rank_in_run": r["rank_in_run"], "source_run_id": r["source_run_id"], "based_on_draw": r["based_on_draw"], "created_at": r["created_at"], } for r in rows ] def get_simulation_runs(limit: int = 10) -> List[Dict[str, Any]]: """최근 시뮬레이션 실행 기록 조회""" with _conn() as conn: rows = conn.execute( """ SELECT id, run_at, strategy, total_generated, top_k_selected, avg_score, notes FROM simulation_runs ORDER BY id DESC LIMIT ? """, (limit,), ).fetchall() return [dict(r) for r in rows] def get_simulation_candidates(run_id: int, limit: int = 100) -> List[Dict[str, Any]]: """특정 시뮬레이션 실행의 후보 목록 조회 (점수 내림차순)""" with _conn() as conn: rows = conn.execute( """ SELECT id, numbers, score_total, score_frequency, score_fingerprint, score_gap, score_cooccur, score_diversity, is_best, based_on_draw, created_at FROM simulation_candidates WHERE run_id = ? ORDER BY score_total DESC LIMIT ? """, (run_id, limit), ).fetchall() return [ {**dict(r), "numbers": json.loads(r["numbers"])} for r in rows ] # ── purchase_history CRUD ───────────────────────────────────────────────────── def _purchase_row_to_dict(r) -> Dict[str, Any]: import json as _json keys = r.keys() numbers_raw = r["numbers"] if "numbers" in keys else "[]" detail_raw = r["source_detail"] if "source_detail" in keys else "{}" results_raw = r["results"] if "results" in keys else "[]" 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"], "numbers": _json.loads(numbers_raw) if numbers_raw else [], "is_real": r["is_real"] if "is_real" in keys else 1, "source_strategy": r["source_strategy"] if "source_strategy" in keys else "manual", "source_detail": _json.loads(detail_raw) if detail_raw else {}, "checked": r["checked"] if "checked" in keys else 0, "results": _json.loads(results_raw) if results_raw else [], "total_prize": r["total_prize"] if "total_prize" in keys else 0, } def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "", numbers: list = None, is_real: bool = True, source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]: import json as _json numbers_json = _json.dumps(numbers or [], ensure_ascii=False) detail_json = _json.dumps(source_detail or {}, ensure_ascii=False) is_real_int = 1 if is_real else 0 with _conn() as conn: conn.execute( """INSERT INTO purchase_history (draw_no, amount, sets, prize, note, numbers, is_real, source_strategy, source_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (draw_no, amount, sets, prize, note, numbers_json, is_real_int, source_strategy, detail_json), ) 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, is_real: bool = None, strategy: str = None, checked: bool = 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}") if is_real is not None: conditions.append("is_real = ?") params.append(1 if is_real else 0) if strategy is not None: conditions.append("source_strategy = ?") params.append(strategy) if checked is not None: conditions.append("checked = ?") params.append(1 if checked else 0) 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]]: import json as _json allowed = {"draw_no", "amount", "sets", "prize", "note", "numbers", "is_real", "source_strategy"} 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 # SQLite에 전달 전 타입 변환 if "numbers" in updates: updates["numbers"] = _json.dumps(updates["numbers"], ensure_ascii=False) if "is_real" in updates: updates["is_real"] = 1 if updates["is_real"] else 0 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]: import json as _json def _calc_group(rows): if not rows: return {"sets": 0, "invested": 0, "prize": 0, "roi": 0.0, "win_rate": 0.0} invested = sum(r["amount"] for r in rows) prize = sum(r.get("total_prize") or r["prize"] for r in rows) wins = sum(1 for r in rows if (r.get("total_prize") or r["prize"]) > 0) return { "sets": sum(r["sets"] for r in rows), "invested": invested, "prize": prize, "roi": round((prize / invested * 100 - 100) if invested else 0.0, 2), "win_rate": round(wins / len(rows) * 100, 2) if rows else 0.0, } with _conn() as conn: rows = conn.execute("SELECT * FROM purchase_history").fetchall() all_rows = [dict(r) for r in rows] real_rows = [r for r in all_rows if r.get("is_real", 1) == 1] virtual_rows = [r for r in all_rows if r.get("is_real", 1) == 0] # 전략별 집계 by_strategy: Dict[str, list] = {} for r in all_rows: strat = r.get("source_strategy", "manual") if strat not in by_strategy: by_strategy[strat] = [] by_strategy[strat].append(r) strategy_stats: Dict[str, Any] = {} for strat, srows in by_strategy.items(): s = _calc_group(srows) total_correct = 0 count_sets = 0 hits_3plus = 0 for r in srows: results_raw = r.get("results", "[]") try: results = _json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or []) except Exception: results = [] for res in results: count_sets += 1 c = res.get("correct", 0) total_correct += c if c >= 3: hits_3plus += 1 s["avg_correct"] = round(total_correct / count_sets, 2) if count_sets else 0.0 s["hits_3plus"] = hits_3plus strategy_stats[strat] = s total_invested = sum(r["amount"] for r in all_rows) total_prize_sum = sum(r.get("total_prize") or r["prize"] for r in all_rows) return { "total": _calc_group(all_rows), "real": _calc_group(real_rows), "virtual": _calc_group(virtual_rows), "by_strategy": strategy_stats, # 하위호환 "total_records": len(all_rows), "total_invested": total_invested, "total_prize": total_prize_sum, "net": total_prize_sum - total_invested, "return_rate": round((total_prize_sum / total_invested * 100) if total_invested else 0.0, 2), "prize_count": sum(1 for r in all_rows if (r.get("total_prize") or r["prize"]) > 0), "max_prize": max((r.get("total_prize") or r["prize"] for r in all_rows), default=0), } # ── 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] # ── strategy_performance CRUD ───────────────────────────────────────────────── def upsert_strategy_performance(strategy: str, draw_no: int, sets_count: int = 0, total_correct: int = 0, max_correct: int = 0, prize_total: int = 0, avg_score: float = 0.0) -> None: with _conn() as conn: conn.execute( """INSERT INTO strategy_performance (strategy, draw_no, sets_count, total_correct, max_correct, prize_total, avg_score) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(strategy, draw_no) DO UPDATE SET sets_count=excluded.sets_count, total_correct=excluded.total_correct, max_correct=excluded.max_correct, prize_total=excluded.prize_total, avg_score=excluded.avg_score, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')""", (strategy, draw_no, sets_count, total_correct, max_correct, prize_total, avg_score), ) def get_strategy_performance(strategy: str = None, days: int = None) -> List[Dict[str, Any]]: conditions, params = [], [] if strategy: conditions.append("strategy = ?") params.append(strategy) if days: conditions.append("updated_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 strategy_performance {where} ORDER BY draw_no ASC", params, ).fetchall() return [dict(r) for r in rows] # ── strategy_weights CRUD ───────────────────────────────────────────────────── def get_strategy_weights() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM strategy_weights ORDER BY weight DESC").fetchall() return [dict(r) for r in rows] def update_strategy_weight(strategy: str, weight: float, ema_score: float, total_sets: int = None, total_hits_3plus: int = None) -> None: with _conn() as conn: fields = "weight=?, ema_score=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')" params = [weight, ema_score] if total_sets is not None: fields += ", total_sets=?" params.append(total_sets) if total_hits_3plus is not None: fields += ", total_hits_3plus=?" params.append(total_hits_3plus) params.append(strategy) conn.execute(f"UPDATE strategy_weights SET {fields} WHERE strategy=?", params) def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None: """구매 건의 결과를 갱신 (체커 호출 후)""" import json as _json with _conn() as conn: conn.execute( "UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?", (_json.dumps(results, ensure_ascii=False), total_prize, purchase_id), )