Files
web-page-backend/docs/superpowers/plans/2026-04-05-lotto-purchase-strategy-evolution.md

53 KiB
Raw Blame History

Lotto 구매 연동 + 전략 진화 시스템 구현 계획

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 로또 추천 번호의 실제/가상 구매, 자동 결과 체크, EMA 기반 전략 진화 메타 추천 시스템 구현

Architecture: 기존 lotto-backend(backend/) 서비스에 purchase_manager.py, strategy_evolver.py 2개 모듈 추가. 기존 purchase_history 테이블을 ALTER TABLE로 확장하고, strategy_performance, strategy_weights 2개 테이블 신규 생성. checker.py에서 purchase 체크 연동하여 자동 순환 파이프라인 완성.

Tech Stack: Python 3.12, FastAPI, SQLite, APScheduler (기존 스택 그대로)

Spec: docs/superpowers/specs/2026-04-05-lotto-purchase-strategy-evolution-design.md


파일 구조

신규 파일

파일 역할
backend/app/purchase_manager.py 구매 이력 관리 + 결과 체크 로직
backend/app/strategy_evolver.py EMA 계산 + Softmax 가중치 + 스마트 추천
backend/tests/test_purchase_manager.py purchase_manager 단위 테스트
backend/tests/test_strategy_evolver.py strategy_evolver 단위 테스트
backend/tests/test_integration.py 체커 연동 통합 테스트

수정 파일

파일 변경 내용
backend/app/db.py purchase_history ALTER + 신규 테이<ED858C><EC9DB4> 2개 + CRUD 함수
backend/app/checker.py check_results_for_draw() 끝에 purchase 체크 호출
backend/app/main.py 신규 API 엔드포인트 + Pydantic 모델 + import

Task 1: DB 스키마 확장 — purchase_history ALTER + 신규 테이블

Files:

  • Modify: backend/app/db.py:254-268 (purchase_history 생성 부분)

  • Modify: backend/app/db.py:21 (init_db 함수 내부에 신규 <20><>이블 추가)

  • Test: backend/tests/test_purchase_manager.py

  • Step 1: 테스트 파일 생성 — DB 스키마 검증 테스트

# backend/tests/test_purchase_manager.py
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))

import sqlite3
import pytest
from unittest.mock import patch

# 테스트용 임시 DB 경로
TEST_DB = ":memory:"

def _get_test_conn():
    conn = sqlite3.connect(TEST_DB)
    conn.row_factory = sqlite3.Row
    return conn

def test_purchase_history_has_new_columns():
    """purchase_history 테이블에 신규 컬럼이 존재하는지 검증"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        conn = db._conn()
        cols = {r["name"] for r in conn.execute("PRAGMA table_info(purchase_history)").fetchall()}
        assert "numbers" in cols
        assert "is_real" in cols
        assert "source_strategy" in cols
        assert "source_detail" in cols
        assert "checked" in cols
        assert "results" in cols
        assert "total_prize" in cols
        # 기존 컬럼도 유지
        assert "draw_no" in cols
        assert "amount" in cols
        assert "sets" in cols
        assert "prize" in cols
        assert "note" in cols
        conn.close()


def test_strategy_performance_table_exists():
    """strategy_performance 테이블이 생성되는지 검증"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        conn = db._conn()
        cols = {r["name"] for r in conn.execute("PRAGMA table_info(strategy_performance)").fetchall()}
        assert "strategy" in cols
        assert "draw_no" in cols
        assert "sets_count" in cols
        assert "total_correct" in cols
        assert "avg_score" in cols
        conn.close()


def test_strategy_weights_table_exists():
    """strategy_weights 테이블이 생성되고 초기값이 있는지 검증"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        conn = db._conn()
        rows = conn.execute("SELECT * FROM strategy_weights ORDER BY strategy").fetchall()
        strategies = {r["strategy"] for r in rows}
        assert strategies == {"combined", "simulation", "heatmap", "manual", "custom"}
        # 가중치 합이 1.0
        total_weight = sum(r["weight"] for r in rows)
        assert abs(total_weight - 1.0) < 0.01
        conn.close()
  • Step 2: 테스트 실행 — 실패 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v Expected: FAIL — 신규 컬럼/테이블 미존재

  • Step 3: db.py init_db()에 purchase_history ALTER 추가

backend/app/db.pyinit_db() 함수에서, 기존 purchase_history CREATE TABLE 이후(268행 근처)에 추가:

        # ── 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")
  • Step 4: db.py init_db()에 strategy_performance 테이블 추가

기존 weekly_reports 테이블 생성 부분(270행 근처) 이후에 추가:

        # ── 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),
            )
  • Step 5: 테스트 실행 — 통과 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v Expected: 3 tests PASS

  • Step 6: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add backend/app/db.py backend/tests/test_purchase_manager.py
git commit -m "lotto-lab: DB 스키마 확장 — purchase_history ALTER + strategy 테이블 추가"

Task 2: DB CRUD 함수 — 구매 이력 확장 + 전략 성과/가중치

Files:

  • Modify: backend/app/db.py:1140-1230 (기존 purchase CRUD 확장 + 신규 함수)

  • Test: backend/tests/test_purchase_manager.py (추가)

  • Step 1: 테스트 추가 — 확장된 purchase CRUD

backend/tests/test_purchase_manager.py에 추가:

def test_add_purchase_with_numbers():
    """번호 포함 구매 등록"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        result = db.add_purchase(
            draw_no=1125,
            amount=2000,
            sets=2,
            prize=0,
            note="테스트",
            numbers=[[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]],
            is_real=True,
            source_strategy="combined",
            source_detail={"recommendation_ids": [1, 2]},
        )
        assert result["draw_no"] == 1125
        assert result["is_real"] == 1
        assert result["source_strategy"] == "combined"
        assert result["numbers"] == [[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]]
        assert result["checked"] == 0


def test_get_purchases_filter_is_real():
    """is_real 필터 동작"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        db.add_purchase(draw_no=1125, amount=1000, sets=1, is_real=True, source_strategy="combined")
        db.add_purchase(draw_no=1125, amount=1000, sets=1, is_real=False, source_strategy="simulation")

        real = db.get_purchases(is_real=True)
        virtual = db.get_purchases(is_real=False)
        assert all(r["is_real"] == 1 for r in real)
        assert all(r["is_real"] == 0 for r in virtual)


def test_get_purchase_stats_by_type():
    """실제/가상 분리 통계"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        db.add_purchase(draw_no=1125, amount=2000, sets=2, prize=5000, is_real=True, source_strategy="combined")
        db.add_purchase(draw_no=1125, amount=1000, sets=1, prize=0, is_real=False, source_strategy="simulation")

        stats = db.get_purchase_stats()
        assert stats["total"]["invested"] == 3000
        assert stats["real"]["invested"] == 2000
        assert stats["virtual"]["invested"] == 1000


def test_upsert_strategy_performance():
    """전략 성과 upsert"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        db.upsert_strategy_performance("combined", 1125, sets_count=2, total_correct=5, max_correct=3, avg_score=0.42)
        rows = db.get_strategy_performance("combined")
        assert len(rows) == 1
        assert rows[0]["sets_count"] == 2

        # upsert: 같은 전략+회차 → 업데이트
        db.upsert_strategy_performance("combined", 1125, sets_count=3, total_correct=7, max_correct=4, avg_score=0.50)
        rows = db.get_strategy_performance("combined")
        assert len(rows) == 1
        assert rows[0]["sets_count"] == 3


def test_update_strategy_weight():
    """전략 가중치 업데이트"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        db.update_strategy_weight("combined", weight=0.35, ema_score=0.28, total_sets=15, total_hits_3plus=3)
        weights = db.get_strategy_weights()
        combined = next(w for w in weights if w["strategy"] == "combined")
        assert combined["weight"] == 0.35
        assert combined["ema_score"] == 0.28
  • Step 2: 테스트 실행 — 실패 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v Expected: 새 테스트들 FAIL — 함수 미존재

  • Step 3: db.py — add_purchase 함수 시그니처 확장

기존 add_purchase (db.py:1154) 를 확장:

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)
  • Step 4: db.py — _purchase_row_to_dict 확장

기존 _purchase_row_to_dict (db.py:1142) 를 확장:

def _purchase_row_to_dict(r) -> Dict[str, Any]:
    import json as _json
    numbers_raw = r["numbers"] if "numbers" in r.keys() else "[]"
    detail_raw = r["source_detail"] if "source_detail" in r.keys() else "{}"
    results_raw = r["results"] if "results" in r.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 r.keys() else 1,
        "source_strategy": r["source_strategy"] if "source_strategy" in r.keys() else "manual",
        "source_detail": _json.loads(detail_raw) if detail_raw else {},
        "checked": r["checked"] if "checked" in r.keys() else 0,
        "results": _json.loads(results_raw) if results_raw else [],
        "total_prize": r["total_prize"] if "total_prize" in r.keys() else 0,
    }
  • Step 5: db.py — get_purchases에 is_real, strategy 필터 추가

기존 get_purchases (db.py:1164) 확장:

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]
  • Step 6: db.py — get_purchase_stats 확장 (전체/실제/가상 + 전략별)

기존 get_purchase_stats (db.py:1206) 를 교체:

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["total_prize"] or r["prize"] for r in rows)
        wins = sum(1 for r in rows if (r["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 = {}
    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 = {}
    for strat, srows in by_strategy.items():
        s = _calc_group(srows)
        # results에서 correct 수 추출
        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
            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

    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": sum(r["amount"] for r in all_rows),
        "total_prize": sum(r.get("total_prize", 0) or r["prize"] for r in all_rows),
        "net": sum(r.get("total_prize", 0) or r["prize"] for r in all_rows) - sum(r["amount"] for r in all_rows),
        "return_rate": round((sum(r.get("total_prize", 0) or r["prize"] for r in all_rows) / sum(r["amount"] for r in all_rows) * 100) if sum(r["amount"] for r in all_rows) else 0.0, 2),
        "prize_count": sum(1 for r in all_rows if (r.get("total_prize", 0) or r["prize"]) > 0),
        "max_prize": max((r.get("total_prize", 0) or r["prize"] for r in all_rows), default=0),
    }
  • Step 7: db.py — strategy_performance CRUD 함수 추가

db.py 파일 끝에 추가:

# ── 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),
        )
  • Step 8: db.py — exports 업데이트 (main.py import용)

db.py 파일 상단이나 기존 함수들이 이미 직접 import되므로, main.py의 import 문에 새 함수를 추가할 준비만 해둠. (Task 5에서 실행)

  • Step 9: 테스트 실행 — 통과 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v Expected: ALL PASS

  • Step 10: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add backend/app/db.py backend/tests/test_purchase_manager.py
git commit -m "lotto-lab: 구매 CRUD 확장 + strategy_performance/weights CRUD 추가"

Task 3: purchase_manager.py — 구매 결과 체크 로직

Files:

  • Create: backend/app/purchase_manager.py

  • Test: backend/tests/test_purchase_manager.py (추가)

  • Step 1: 테스트 추가 — 구매 결과 체크

backend/tests/test_purchase_manager.py에 추가:

def test_check_purchases_for_draw():
    """특정 회차 구매 건들의 결과 체크"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        # draws 테이블에 테스트 데이터 삽입
        conn = db._conn()
        conn.execute(
            "INSERT OR IGNORE INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (1125, "2026-04-04", 3, 12, 23, 34, 38, 45, 7)
        )
        conn.commit()
        conn.close()

        # 1125회차 대상 구매 등록 (draw_no=1125)
        db.add_purchase(
            draw_no=1125, amount=2000, sets=2, numbers=[[3, 12, 23, 34, 38, 45], [1, 2, 3, 4, 5, 6]],
            is_real=True, source_strategy="combined",
        )

        from purchase_manager import check_purchases_for_draw
        checked = check_purchases_for_draw(1125)
        assert checked == 1  # 1건 체크됨

        purchases = db.get_purchases(draw_no=1125)
        p = purchases[0]
        assert p["checked"] == 1
        assert len(p["results"]) == 2
        # 첫 번째 세트: 6개 전부 맞음 = 1등
        assert p["results"][0]["rank"] == 1
        assert p["results"][0]["correct"] == 6
        # 두 번째 세트: 3개 맞음 (3, 34 -> 아니 3만 맞음... 확인)
        # [1,2,3,4,5,6] vs [3,12,23,34,38,45] → 3 하나만 맞음
        assert p["results"][1]["correct"] == 1
        assert p["results"][1]["rank"] == 0


def test_check_purchases_updates_strategy_performance():
    """결과 체크 후 strategy_performance가 갱신되는지 검증"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        conn = db._conn()
        conn.execute(
            "INSERT OR IGNORE INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (1126, "2026-04-11", 5, 15, 25, 35, 40, 44, 10)
        )
        conn.commit()
        conn.close()

        db.add_purchase(
            draw_no=1126, amount=1000, sets=1, numbers=[[5, 15, 25, 10, 11, 12]],
            is_real=False, source_strategy="simulation",
        )

        from purchase_manager import check_purchases_for_draw
        check_purchases_for_draw(1126)

        perfs = db.get_strategy_performance("simulation")
        assert len(perfs) >= 1
        p = perfs[-1]
        assert p["draw_no"] == 1126
        assert p["sets_count"] == 1
  • Step 2: 테스트 실행 — 실패 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py::test_check_purchases_for_draw -v Expected: FAIL — purchase_manager 모듈 미존재

  • Step 3: purchase_manager.py 작성
# backend/app/purchase_manager.py
"""
구매 이력 관리 + 결과 체크 모듈.

- check_purchases_for_draw(): 특정 회차 구매 건들의 결과를 자동 체크
- 체커의 _calc_rank 재사용
- 결과 체크 후 strategy_performance 자동 갱신
"""
import json
import logging
from .db import (
    _conn, get_draw, get_purchases, update_purchase_results,
    upsert_strategy_performance,
)
from .checker import _calc_rank

logger = logging.getLogger("lotto-backend")

RANK_PRIZE = {1: 0, 2: 0, 3: 1_500_000, 4: 50_000, 5: 5_000}


def check_purchases_for_draw(drw_no: int) -> int:
    """
    특정 회차 결과로 해당 회차 구매 건들을 채점한다.

    Returns: 채점한 구매 건 수
    """
    win_row = get_draw(drw_no)
    if not win_row:
        return 0

    win_nums = [win_row["n1"], win_row["n2"], win_row["n3"],
                win_row["n4"], win_row["n5"], win_row["n6"]]
    bonus = win_row["bonus"]

    # draw_no가 해당 회차이고 아직 체크 안 된 구매 건
    unchecked = get_purchases(draw_no=drw_no, checked=False)

    # 전략별 집계
    strategy_agg = {}  # strategy -> {sets_count, total_correct, max_correct, prize_total, scores[]}

    count = 0
    for purchase in unchecked:
        numbers_list = purchase["numbers"]
        if not numbers_list:
            continue

        results = []
        for nums in numbers_list:
            rank, correct, has_bonus = _calc_rank(nums, win_nums, bonus)
            prize = RANK_PRIZE.get(rank, 0)
            results.append({
                "numbers": nums,
                "rank": rank,
                "correct": correct,
                "has_bonus": has_bonus,
                "prize": prize,
            })

        total_prize = sum(r["prize"] for r in results)
        update_purchase_results(purchase["id"], results, total_prize)

        # 전략별 집계
        strat = purchase["source_strategy"]
        if strat not in strategy_agg:
            strategy_agg[strat] = {"sets_count": 0, "total_correct": 0, "max_correct": 0, "prize_total": 0, "scores": []}
        agg = strategy_agg[strat]
        for r in results:
            agg["sets_count"] += 1
            agg["total_correct"] += r["correct"]
            agg["max_correct"] = max(agg["max_correct"], r["correct"])
            agg["prize_total"] += r["prize"]
            agg["scores"].append(r["correct"] / 6.0)

        count += 1

    # strategy_performance 갱신
    for strat, agg in strategy_agg.items():
        avg_score = sum(agg["scores"]) / len(agg["scores"]) if agg["scores"] else 0.0
        upsert_strategy_performance(
            strategy=strat,
            draw_no=drw_no,
            sets_count=agg["sets_count"],
            total_correct=agg["total_correct"],
            max_correct=agg["max_correct"],
            prize_total=agg["prize_total"],
            avg_score=round(avg_score, 4),
        )

    logger.info(f"[purchase_manager] {drw_no}회차 구매 {count}건 체크 완료")
    return count
  • Step 4: 테스트 실행 — 통과 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_purchase_manager.py -v Expected: ALL PASS

  • Step 5: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add backend/app/purchase_manager.py backend/tests/test_purchase_manager.py
git commit -m "lotto-lab: purchase_manager — 구매 결과 자동 체크 + 전략 성과 집계"

Task 4: strategy_evolver.py — EMA + Softmax 가중치 진화 + 스마트 추천

Files:

  • Create: backend/app/strategy_evolver.py

  • Test: backend/tests/test_strategy_evolver.py

  • Step 1: 테스트 파일 생성

# backend/tests/test_strategy_evolver.py
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))

import math
import pytest
from unittest.mock import patch

TEST_DB = ":memory:"


def test_calc_draw_score_basic():
    """세트별 결과 → draw_score 계산"""
    from strategy_evolver import calc_draw_score

    results = [
        {"correct": 3, "rank": 5},  # 3/6 + 0.1 = 0.6
        {"correct": 1, "rank": 0},  # 1/6 + 0 = 0.167
    ]
    score = calc_draw_score(results)
    expected = ((3/6 + 0.1) + (1/6)) / 2
    assert abs(score - expected) < 0.01


def test_calc_draw_score_empty():
    """빈 결과 → 0"""
    from strategy_evolver import calc_draw_score
    assert calc_draw_score([]) == 0.0


def test_recalculate_weights_softmax():
    """EMA → Softmax 가중치 변환"""
    from strategy_evolver import _softmax_weights

    ema_scores = {
        "combined": 0.30,
        "simulation": 0.25,
        "heatmap": 0.15,
        "manual": 0.10,
        "custom": 0.05,
    }
    weights = _softmax_weights(ema_scores)

    # 합이 1.0
    assert abs(sum(weights.values()) - 1.0) < 0.001
    # combined가 가장 높아야 함
    assert weights["combined"] > weights["simulation"]
    assert weights["simulation"] > weights["heatmap"]
    # 최소 가중치 5% 보장
    assert all(w >= 0.049 for w in weights.values())


def test_recalculate_weights_min_weight():
    """한 전략의 EMA가 매우 낮아도 최소 5% 보장"""
    from strategy_evolver import _softmax_weights

    ema_scores = {
        "combined": 0.50,
        "simulation": 0.01,
        "heatmap": 0.01,
        "manual": 0.01,
        "custom": 0.01,
    }
    weights = _softmax_weights(ema_scores)

    assert weights["simulation"] >= 0.049
    assert weights["custom"] >= 0.049
    assert abs(sum(weights.values()) - 1.0) < 0.001


def test_update_ema():
    """EMA 갱신 공식 검증"""
    from strategy_evolver import ALPHA

    old_ema = 0.15
    draw_score = 0.40
    new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
    expected = 0.3 * 0.40 + 0.7 * 0.15  # = 0.12 + 0.105 = 0.225
    assert abs(new_ema - expected) < 0.001
  • Step 2: 테스트 실행 — 실패 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_strategy_evolver.py -v Expected: FAIL — strategy_evolver 모듈 미존재

  • Step 3: strategy_evolver.py 작성
# backend/app/strategy_evolver.py
"""
전략 진화 엔진 — EMA + Softmax 기반 적응형 가중치 관리.

- calc_draw_score(): 구매 결과 → 성과 점수
- update_ema_for_strategy(): 특정 전략의 EMA 갱신
- recalculate_weights(): 전 전략 EMA → Softmax → 가중치 저장
- generate_smart_recommendation(): 가중치 기반 메타 전략 추천
"""
import math
import json
import logging
from typing import Dict, List, Any, Tuple

from .db import (
    get_strategy_weights, update_strategy_weight,
    get_strategy_performance, get_best_picks, get_all_draw_numbers, get_latest_draw,
)
from .recommender import recommend_numbers, recommend_with_heatmap
from .analyzer import generate_combined_recommendation, score_combination, build_analysis_cache
from .db import list_recommendations_ex

logger = logging.getLogger("lotto-backend")

ALPHA = 0.3           # EMA 감쇠율
TEMPERATURE = 2.0     # Softmax 온도
MIN_WEIGHT = 0.05     # 최소 가중치
INITIAL_EMA = 0.15    # 콜드스타트 초기값
MIN_DATA_DRAWS = 10   # 학습 최소 회차

STRATEGIES = ["combined", "simulation", "heatmap", "manual", "custom"]

RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}


def calc_draw_score(results: List[Dict]) -> float:
    """구매 결과 리스트 → 평균 성과 점수"""
    if not results:
        return 0.0
    scores = []
    for r in results:
        s = r.get("correct", 0) / 6.0
        s += RANK_BONUS.get(r.get("rank", 0), 0)
        scores.append(s)
    return sum(scores) / len(scores)


def _softmax_weights(ema_scores: Dict[str, float]) -> Dict[str, float]:
    """EMA 점수 → Softmax → 최소 가중치 보장 → 정규화"""
    raw = {s: math.exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
    total = sum(raw.values())
    weights = {s: v / total for s, v in raw.items()}

    # 최소 가중치 보장
    clamped = {}
    surplus = 0.0
    unclamped = []
    for s, w in weights.items():
        if w < MIN_WEIGHT:
            clamped[s] = MIN_WEIGHT
            surplus += MIN_WEIGHT - w
        else:
            unclamped.append(s)
            clamped[s] = w

    # surplus를 unclamped에서 비례 차감
    if surplus > 0 and unclamped:
        unclamped_total = sum(clamped[s] for s in unclamped)
        for s in unclamped:
            clamped[s] -= surplus * (clamped[s] / unclamped_total)

    # 최종 정규화
    final_total = sum(clamped.values())
    return {s: round(v / final_total, 4) for s, v in clamped.items()}


def update_ema_for_strategy(strategy: str, draw_score: float) -> float:
    """특정 전략의 EMA 갱신 + DB 저장. 새 EMA 반환."""
    weights = get_strategy_weights()
    current = next((w for w in weights if w["strategy"] == strategy), None)
    old_ema = current["ema_score"] if current else INITIAL_EMA
    new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
    return new_ema


def recalculate_weights() -> Dict[str, float]:
    """전 전략 EMA → Softmax → 가중치 재계산 + DB 저장"""
    weights_rows = get_strategy_weights()
    ema_scores = {w["strategy"]: w["ema_score"] for w in weights_rows}

    # 누락된 전략 보충
    for s in STRATEGIES:
        if s not in ema_scores:
            ema_scores[s] = INITIAL_EMA

    new_weights = _softmax_weights(ema_scores)

    for s, w in new_weights.items():
        row = next((r for r in weights_rows if r["strategy"] == s), None)
        update_strategy_weight(
            strategy=s,
            weight=w,
            ema_score=ema_scores[s],
            total_sets=row["total_sets"] if row else 0,
            total_hits_3plus=row["total_hits_3plus"] if row else 0,
        )

    logger.info(f"[strategy_evolver] 가중치 재계산: {new_weights}")
    return new_weights


def evolve_after_check(strategy: str, draw_no: int, results: List[Dict]) -> None:
    """결과 체크 후 EMA 갱신 + 가중치 재계산 (purchase_manager에서 호출)"""
    draw_score = calc_draw_score(results)
    new_ema = update_ema_for_strategy(strategy, draw_score)

    weights_rows = get_strategy_weights()
    current = next((w for w in weights_rows if w["strategy"] == strategy), None)
    hits_3plus = sum(1 for r in results if r.get("correct", 0) >= 3)

    update_strategy_weight(
        strategy=strategy,
        weight=current["weight"] if current else 0.2,
        ema_score=new_ema,
        total_sets=(current["total_sets"] if current else 0) + len(results),
        total_hits_3plus=(current["total_hits_3plus"] if current else 0) + hits_3plus,
    )

    recalculate_weights()


def get_weights_with_trend() -> Dict[str, Any]:
    """현재 가중치 + trend 정보 반환"""
    weights = get_strategy_weights()
    perfs = get_strategy_performance()

    # 전략별 최근 EMA 변화 추적 (최근 5회차)
    strat_perfs = {}
    for p in perfs:
        s = p["strategy"]
        if s not in strat_perfs:
            strat_perfs[s] = []
        strat_perfs[s].append(p)

    result = []
    for w in weights:
        # trend 계산: 최근 5회차 avg_score 변화
        sp = strat_perfs.get(w["strategy"], [])
        if len(sp) >= 5:
            recent_avg = sum(p["avg_score"] for p in sp[-3:]) / 3
            older_avg = sum(p["avg_score"] for p in sp[-5:-2]) / 3
            delta = recent_avg - older_avg
            trend = "up" if delta > 0.02 else ("down" if delta < -0.02 else "stable")
        else:
            trend = "stable"

        result.append({
            "strategy": w["strategy"],
            "weight": w["weight"],
            "ema_score": w["ema_score"],
            "total_sets": w["total_sets"],
            "hits_3plus": w["total_hits_3plus"],
            "trend": trend,
        })

    # 학습 상태
    all_draws = set()
    for p in perfs:
        all_draws.add(p["draw_no"])

    return {
        "weights": result,
        "last_evolved": weights[0]["updated_at"] if weights else None,
        "min_data_draws": MIN_DATA_DRAWS,
        "current_data_draws": len(all_draws),
        "status": "active" if len(all_draws) >= MIN_DATA_DRAWS else "learning",
    }


def generate_smart_recommendation(sets: int = 5) -> Dict[str, Any]:
    """
    전략 가중치 기반 메타 전략 추천.

    1. 가중치 로드
    2. 각 전략에서 후보 10세트 생성
    3. meta_score = original_score × strategy_weight
    4. 상위 N세트 선출 (중복 제거)
    """
    weights_data = get_strategy_weights()
    weight_map = {w["strategy"]: w["weight"] for w in weights_data}
    draws = get_all_draw_numbers()
    if not draws:
        return {"error": "No draw data"}

    latest = get_latest_draw()
    cache = build_analysis_cache(draws)
    past_recs = list_recommendations_ex(limit=100, sort="id_desc")

    candidates = []  # [{numbers, score, strategy, meta_score}]
    seen_keys = set()

    def _add_candidate(nums: list, strategy: str, raw_score: float = None):
        key = tuple(sorted(nums))
        if key in seen_keys:
            return
        seen_keys.add(key)
        if raw_score is None:
            sc = score_combination(nums, cache)
            raw_score = sc["score_total"]
        meta = raw_score * weight_map.get(strategy, 0.1)
        candidates.append({
            "numbers": sorted(nums),
            "raw_score": round(raw_score, 4),
            "strategy": strategy,
            "meta_score": round(meta, 4),
        })

    # combined: 10세트
    for _ in range(10):
        try:
            r = generate_combined_recommendation(draws)
            if "final_numbers" in r:
                _add_candidate(r["final_numbers"], "combined")
        except Exception:
            pass

    # simulation: best_picks 상위 10개
    best = get_best_picks(limit=10)
    for b in best:
        nums = json.loads(b["numbers"]) if isinstance(b["numbers"], str) else b["numbers"]
        _add_candidate(nums, "simulation", b.get("score_total"))

    # heatmap: 10세트
    for _ in range(10):
        try:
            r = recommend_with_heatmap(draws, past_recs)
            _add_candidate(r["numbers"], "heatmap")
        except Exception:
            pass

    # manual: 10세트
    for _ in range(10):
        try:
            r = recommend_numbers(draws)
            _add_candidate(r["numbers"], "manual")
        except Exception:
            pass

    # meta_score 기준 정렬, 상위 N개
    candidates.sort(key=lambda c: -c["meta_score"])
    top = candidates[:sets]

    # contribution 계산
    result_sets = []
    for c in top:
        # 이 번호에 기여한 전략들의 비율
        sc = score_combination(c["numbers"], cache)
        contributions = {}
        for strat in STRATEGIES:
            contributions[strat] = round(weight_map.get(strat, 0) * sc["score_total"], 4)
        contrib_total = sum(contributions.values()) or 1
        contributions = {s: round(v / contrib_total, 3) for s, v in contributions.items()}

        result_sets.append({
            "numbers": c["numbers"],
            "meta_score": c["meta_score"],
            "source_strategy": c["strategy"],
            "contribution": contributions,
            "individual_scores": {k: round(v, 4) for k, v in sc.items()},
        })

    # 학습 상태
    perfs = get_strategy_performance()
    data_draws = len(set(p["draw_no"] for p in perfs))
    status = "active" if data_draws >= MIN_DATA_DRAWS else "learning"

    return {
        "sets": result_sets,
        "strategy_weights_used": weight_map,
        "learning_status": {
            "draws_learned": data_draws,
            "status": status,
            "message": "" if status == "active" else f"{MIN_DATA_DRAWS}회차 이상 데이터 필요 (현재 {data_draws}회차)",
        },
        "based_on_latest_draw": latest["drw_no"] if latest else None,
    }
  • Step 4: 테스트 실행 — 통과 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_strategy_evolver.py -v Expected: ALL PASS

  • Step 5: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add backend/app/strategy_evolver.py backend/tests/test_strategy_evolver.py
git commit -m "lotto-lab: strategy_evolver — EMA/Softmax 가중치 진화 + 스마트 추천"

Task 5: checker.py 연동 — 자동 파이프라인

Files:

  • Modify: backend/app/checker.py:28-66

  • Test: backend/tests/test_integration.py

  • Step 1: 통합 테스트 작성

# backend/tests/test_integration.py
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))

import pytest
from unittest.mock import patch

TEST_DB = ":memory:"


def test_check_results_triggers_purchase_check():
    """check_results_for_draw가 purchase 체크도 트리거하는지 검증"""
    with patch("db.DB_PATH", TEST_DB):
        import db
        db.DB_PATH = TEST_DB
        db.init_db()

        # 당첨번호 삽입: 1124회차 (base), 1125회차 (결과)
        conn = db._conn()
        conn.execute(
            "INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (1124, "2026-03-28", 1, 2, 3, 4, 5, 6, 7)
        )
        conn.execute(
            "INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (1125, "2026-04-04", 10, 20, 30, 35, 40, 44, 15)
        )
        conn.commit()
        conn.close()

        # 1125회차 대상 구매 (draw_no=1125)
        db.add_purchase(
            draw_no=1125, amount=1000, sets=1,
            numbers=[[10, 20, 30, 1, 2, 3]],
            is_real=True, source_strategy="combined",
        )

        from checker import check_results_for_draw
        check_results_for_draw(1125)

        # purchase도 체크되었는지 확인
        purchases = db.get_purchases(draw_no=1125)
        assert purchases[0]["checked"] == 1
        assert purchases[0]["results"][0]["correct"] == 3  # 10, 20, 30 맞음
  • Step 2: 테스트 실행 — 실패 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_integration.py -v Expected: FAIL — checker.py에 purchase 연동 없음

  • Step 3: checker.py 수정 — purchase 체크 연동

backend/app/checker.py 파일 끝(66행 이후)의 check_results_for_draw 함수에 추가:

기존 함수의 마지막 return count 바로 위에 추가:

    # ── 구매 이력 체크 연동 ──────────────────────────────────────
    try:
        from .purchase_manager import check_purchases_for_draw
        purchase_count = check_purchases_for_draw(drw_no)
        if purchase_count > 0:
            # 전략 가중치 재계산
            from .strategy_evolver import recalculate_weights
            recalculate_weights()
    except ImportError:
        pass  # purchase_manager 미설치 시 무시 (하위호환)
  • Step 4: 테스트 실행 — 통과 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/test_integration.py -v Expected: PASS

  • Step 5: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add backend/app/checker.py backend/tests/test_integration.py
git commit -m "lotto-lab: checker 연동 — 추첨 결과 시 purchase 자동 체크 + 가중치 재계산"

Task 6: main.py — 신규 API 엔드포인트

Files:

  • Modify: backend/app/main.py:12-36 (import 확장)

  • Modify: backend/app/main.py:258-308 (purchase API 확장)

  • Modify: backend/app/main.py (신규 전략/스마트 추천 API 추가)

  • Step 1: main.py import 확장

backend/app/main.py:12-36의 import 블록에 추가:

from .db import (
    # ... 기존 import 유지 ...
    # 신규: 전략 관련
    get_strategy_weights as db_get_strategy_weights,
    get_strategy_performance as db_get_strategy_performance,
    upsert_strategy_performance,
    update_strategy_weight,
    update_purchase_results,
)
from .purchase_manager import check_purchases_for_draw
from .strategy_evolver import (
    get_weights_with_trend, recalculate_weights,
    generate_smart_recommendation,
)
  • Step 2: PurchaseCreate Pydantic 모델 확장

기존 PurchaseCreate (main.py:260-265) 교체:

class PurchaseCreate(BaseModel):
    draw_no: int
    amount: int
    sets: int = 1
    prize: int = 0
    note: str = ""
    # 신규 필드
    numbers: List[List[int]] = []
    is_real: bool = True
    source_strategy: str = "manual"
    source_detail: dict = {}
  • Step 3: PurchaseUpdate 확장

기존 PurchaseUpdate (main.py:268-273) 확장:

class PurchaseUpdate(BaseModel):
    draw_no: Optional[int] = None
    amount: Optional[int] = None
    sets: Optional[int] = None
    prize: Optional[int] = None
    note: Optional[str] = None
    # 신규 필드
    numbers: Optional[List[List[int]]] = None
    is_real: Optional[bool] = None
    source_strategy: Optional[str] = None
  • Step 4: 기존 purchase API 수정

api_purchase_create (main.py:288-291) 수정:

@app.post("/api/lotto/purchase", status_code=201)
def api_purchase_create(body: PurchaseCreate):
    """구매 이력 추가 (실제/가상)"""
    sets = body.sets if body.sets > 0 else max(len(body.numbers), 1)
    amount = body.amount if body.amount > 0 else sets * 1000
    return add_purchase(
        draw_no=body.draw_no,
        amount=amount,
        sets=sets,
        prize=body.prize,
        note=body.note,
        numbers=body.numbers,
        is_real=body.is_real,
        source_strategy=body.source_strategy,
        source_detail=body.source_detail,
    )

api_purchase_list (main.py:282-285) 수정:

@app.get("/api/lotto/purchase")
def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None,
                      is_real: Optional[bool] = None, strategy: Optional[str] = None):
    """구매 이력 조회 (필터: draw_no, days, is_real, strategy)"""
    return {"records": get_purchases(draw_no=draw_no, days=days, is_real=is_real, strategy=strategy)}
  • Step 5: 전략 API 추가

main.py의 purchase API 블록 이후에 추가:

# ── 전략 진화 API ───────────────────────────────────────<E29480><E29480>────────────────────

@app.get("/api/lotto/strategy/weights")
def api_strategy_weights():
    """현재 전략별 가중치 + 성과 요약 + trend"""
    return get_weights_with_trend()


@app.get("/api/lotto/strategy/performance")
def api_strategy_performance(strategy: Optional[str] = None, days: Optional[int] = None):
    """전략별 회차 성과 이력 (차트용)"""
    rows = db_get_strategy_performance(strategy=strategy, days=days)
    return {"records": rows}


@app.post("/api/lotto/strategy/evolve")
def api_strategy_evolve():
    """수동 가중치 재계산 트리거"""
    new_weights = recalculate_weights()
    return {"ok": True, "weights": new_weights}


# ── 스마트 추천 API ──────────────────────────────────────────────────────────

@app.get("/api/lotto/recommend/smart")
def api_recommend_smart(sets: int = 5):
    """전략 가중치 기반 메타 전략 추천"""
    sets = max(1, min(sets, 10))
    result = generate_smart_recommendation(sets=sets)
    if "error" in result:
        raise HTTPException(status_code=500, detail=result["error"])
    return result
  • Step 6: 테스트 — API 스모크 테스트

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -c "from backend.app.main import app; print('import OK')" 또는 docker compose up 후 curl 테스트.

Expected: import 성공, 문법 오류 없음

  • Step 7: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add backend/app/main.py
git commit -m "lotto-lab: 구매/전략/스마트추천 API 엔드포인트 추가"

Task 7: 통합 검증 — Docker 빌드 + 전체 테스트

Files: (수정 없음, 검증만)

  • Step 1: 전체 테스트 실행

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -m pytest backend/tests/ -v Expected: ALL PASS

  • Step 2: Docker 빌드 테스트

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && docker compose build lotto-backend Expected: 빌드 성공

  • Step 3: 로컬 실행 테스트

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && docker compose up -d lotto-backend

API 엔드포인트 확인:

# 전략 가중치 조회
curl http://localhost:18000/api/lotto/strategy/weights

# 스마트 추천
curl "http://localhost:18000/api/lotto/recommend/smart?sets=3"

# 가상 구매 등록
curl -X POST http://localhost:18000/api/lotto/purchase \
  -H "Content-Type: application/json" \
  -d '{"draw_no":1125,"numbers":[[3,12,23,34,38,45]],"is_real":false,"amount":1000,"sets":1,"source_strategy":"smart"}'

# 구매 이력 조회 (가상만)
curl "http://localhost:18000/api/lotto/purchase?is_real=false"

# 구매 통계
curl http://localhost:18000/api/lotto/purchase/stats

Expected: 모든 API가 200 응답

  • Step 4: CLAUDE.md 업데이트

backend/ CLAUDE.md의 lotto-lab API 목록에 신규 API 추가:

| GET | `/api/lotto/purchase` | 구매 이력 조회 (is_real, strategy, draw_no, days 필터) |
| POST | `/api/lotto/purchase` | 구매 등록 (실제/가상, 번호, 전략 출처 포함) |
| GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) |
| GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend |
| GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) |
| POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 |
| GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 |

lotto.db 테이블에 추가:

| `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) |
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
  • Step 5: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add CLAUDE.md
git commit -m "lotto-lab: CLAUDE.md 신규 API + 테이블 문서 업데이트"

요약

Task 내용 파일
1 DB 스키마 확장 db.py (ALTER + 테이블 생성)
2 DB CRUD 함수 db.py (purchase 확장 + strategy CRUD)
3 purchase_manager.py 구매 결과 체크 + 전략 성과 집계
4 strategy_evolver.py EMA + Softmax + 스마트 추천
5 checker.py 연동 자동 파이프라인 완성
6 main.py API 9개 엔드포인트 추가
7 통합 검증 Docker 빌드 + API 테스트 + 문서

프론트엔드 작업은 별도 플랜으로 분리합니다 (web-ui 레포가 별도 Git 저장소이므로).