lotto lab 추천 알고리즘 및 시뮬레이션 강화
This commit is contained in:
@@ -8,13 +8,15 @@ from .db import (
|
||||
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||
update_recommendation,
|
||||
# 시뮬레이션 관련
|
||||
get_best_picks, get_simulation_runs, get_simulation_candidates,
|
||||
)
|
||||
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||
from .collector import sync_latest, sync_ensure_all
|
||||
from .generator import generate_smart_recommendations
|
||||
from .generator import generate_smart_recommendations
|
||||
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
|
||||
|
||||
app = FastAPI()
|
||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
@@ -22,29 +24,35 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
||||
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
|
||||
|
||||
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
|
||||
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
|
||||
def _sync_and_check():
|
||||
res = sync_latest(LATEST_URL)
|
||||
if res["was_new"]:
|
||||
# 새로운 회차(예: 1000회)가 나오면, 999회차 기반 추천들을 채점
|
||||
check_results_for_draw(res["drawNo"])
|
||||
|
||||
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
||||
|
||||
# 2. 매일 아침 8시: 지능형 자동 추천 (10개씩)
|
||||
scheduler.add_job(lambda: generate_smart_recommendations(10), "cron", hour="8", minute=0)
|
||||
|
||||
|
||||
# 2. 몬테카를로 시뮬레이션 (하루 6회: 0, 4, 8, 12, 16, 20시)
|
||||
# 20,000개 후보 생성 → 스코어링 → 상위 100개 저장 → best_picks 교체
|
||||
def _run_simulation_job():
|
||||
run_simulation(n_candidates=20000, top_k=100, best_n=20)
|
||||
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/lotto/latest")
|
||||
def api_latest():
|
||||
row = get_latest_draw()
|
||||
@@ -58,6 +66,7 @@ def api_latest():
|
||||
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/lotto/{drw_no:int}")
|
||||
def api_draw(drw_no: int):
|
||||
row = get_draw(drw_no)
|
||||
@@ -71,67 +80,163 @@ def api_draw(drw_no: int):
|
||||
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/sync_latest")
|
||||
def admin_sync_latest():
|
||||
res = sync_latest(LATEST_URL)
|
||||
# 수동 동기화 시에도 신규 회차면 채점
|
||||
if res["was_new"]:
|
||||
check_results_for_draw(res["drawNo"])
|
||||
return res
|
||||
|
||||
|
||||
@app.post("/api/admin/auto_gen")
|
||||
def admin_auto_gen(count: int = 10):
|
||||
"""지능형 자동 생성 수동 트리거"""
|
||||
"""기존 호환 유지: 소규모 시뮬레이션 수동 트리거"""
|
||||
n = generate_smart_recommendations(count)
|
||||
return {"generated": n}
|
||||
|
||||
|
||||
@app.post("/api/admin/simulate")
|
||||
def admin_simulate(n_candidates: int = 20000, top_k: int = 100, best_n: int = 20):
|
||||
"""
|
||||
몬테카를로 시뮬레이션 수동 트리거.
|
||||
백그라운드 스케줄과 동일한 동작을 즉시 실행.
|
||||
"""
|
||||
result = run_simulation(
|
||||
n_candidates=max(1000, min(n_candidates, 50000)),
|
||||
top_k=max(10, min(top_k, 500)),
|
||||
best_n=max(10, min(best_n, 50)),
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/lotto/stats")
|
||||
def api_stats():
|
||||
# 1. 데이터 완전성 보장 (없으면 가져옴)
|
||||
sync_ensure_all(LATEST_URL, ALL_URL)
|
||||
|
||||
# 2. 전체 데이터 조회
|
||||
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
|
||||
# 1~45번 빈도 초기화
|
||||
|
||||
frequency = {n: 0 for n in range(1, 46)}
|
||||
|
||||
total_draws = len(draws)
|
||||
|
||||
|
||||
for _, nums in draws:
|
||||
for n in nums:
|
||||
frequency[n] += 1
|
||||
|
||||
# 리스트 형태로 변환 (프론트엔드 차트용)
|
||||
# x: 번호, y: 횟수
|
||||
|
||||
stats = [
|
||||
{"number": n, "count": frequency[n]}
|
||||
{"number": n, "count": frequency[n]}
|
||||
for n in range(1, 46)
|
||||
]
|
||||
|
||||
|
||||
return {
|
||||
"total_draws": total_draws,
|
||||
"frequency": stats
|
||||
"frequency": stats,
|
||||
}
|
||||
|
||||
# ---------- ✅ recommend (dedup save) ----------
|
||||
|
||||
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
|
||||
@app.get("/api/lotto/analysis")
|
||||
def api_analysis():
|
||||
"""
|
||||
5가지 통계 기법 기반 분석 리포트.
|
||||
- 번호별 빈도, Z-score, 갭
|
||||
- 핫/콜드/오버듀 번호
|
||||
- 역대 합계 분포, 홀짝 분포
|
||||
"""
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
return get_statistical_report(draws)
|
||||
|
||||
|
||||
# ── 시뮬레이션 best_picks (메인 추천 엔드포인트) ────────────────────────────
|
||||
@app.get("/api/lotto/best")
|
||||
def api_best_picks(limit: int = 20):
|
||||
"""
|
||||
시뮬레이션을 통해 선별된 최적 번호 조합 반환 (기본 20쌍).
|
||||
하루 6회 시뮬레이션 후 자동 갱신됨.
|
||||
각 조합에 점수 및 메트릭 포함.
|
||||
"""
|
||||
limit = max(1, min(limit, 50))
|
||||
picks = get_best_picks(limit=limit)
|
||||
if not picks:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="시뮬레이션 결과가 없습니다. /api/admin/simulate로 먼저 실행하세요.",
|
||||
)
|
||||
|
||||
draws = get_all_draw_numbers()
|
||||
|
||||
result = []
|
||||
for p in picks:
|
||||
nums = p["numbers"]
|
||||
result.append({
|
||||
"rank": p["rank_in_run"],
|
||||
"numbers": nums,
|
||||
"score_total": p["score_total"],
|
||||
"based_on_draw": p["based_on_draw"],
|
||||
"simulation_run_id": p["source_run_id"],
|
||||
"created_at": p["created_at"],
|
||||
"metrics": calc_metrics(nums),
|
||||
})
|
||||
|
||||
latest = get_latest_draw()
|
||||
return {
|
||||
"based_on_draw": latest["drw_no"] if latest else None,
|
||||
"count": len(result),
|
||||
"items": result,
|
||||
}
|
||||
|
||||
|
||||
# ── 시뮬레이션 전체 결과 조회 (상세 API) ────────────────────────────────────
|
||||
@app.get("/api/lotto/simulation")
|
||||
def api_simulation(run_id: Optional[int] = None, runs_limit: int = 5):
|
||||
"""
|
||||
시뮬레이션 실행 기록 및 상위 후보 상세 조회.
|
||||
run_id 미지정 시: 최근 runs_limit개 실행 기록 + 가장 최근 run의 후보 반환.
|
||||
run_id 지정 시: 해당 run의 후보만 반환.
|
||||
"""
|
||||
runs = get_simulation_runs(limit=runs_limit)
|
||||
if not runs:
|
||||
raise HTTPException(status_code=404, detail="시뮬레이션 기록이 없습니다.")
|
||||
|
||||
target_run_id = run_id if run_id is not None else runs[0]["id"]
|
||||
candidates = get_simulation_candidates(target_run_id, limit=100)
|
||||
|
||||
# 후보에 메트릭 추가
|
||||
enriched = []
|
||||
for c in candidates:
|
||||
enriched.append({
|
||||
**c,
|
||||
"metrics": calc_metrics(c["numbers"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"runs": runs,
|
||||
"selected_run_id": target_run_id,
|
||||
"candidates_count": len(enriched),
|
||||
"candidates": enriched,
|
||||
}
|
||||
|
||||
|
||||
# ── 기존 수동 추천 API (하위 호환 유지) ─────────────────────────────────────
|
||||
@app.get("/api/lotto/recommend")
|
||||
def api_recommend(
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
|
||||
# ---- optional constraints (Lotto Lab) ----
|
||||
sum_min: Optional[int] = None,
|
||||
sum_max: Optional[int] = None,
|
||||
odd_min: Optional[int] = None,
|
||||
odd_max: Optional[int] = None,
|
||||
range_min: Optional[int] = None,
|
||||
range_max: Optional[int] = None,
|
||||
max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수
|
||||
max_try: int = 200, # 조건 맞는 조합 찾기 재시도
|
||||
max_overlap_latest: Optional[int] = None,
|
||||
max_try: int = 200,
|
||||
):
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
@@ -143,7 +248,6 @@ def api_recommend(
|
||||
"recent_window": recent_window,
|
||||
"recent_weight": float(recent_weight),
|
||||
"avoid_recent_k": avoid_recent_k,
|
||||
|
||||
"sum_min": sum_min,
|
||||
"sum_max": sum_max,
|
||||
"odd_min": odd_min,
|
||||
@@ -168,7 +272,6 @@ def api_recommend(
|
||||
return False
|
||||
if range_max is not None and m["range"] > range_max:
|
||||
return False
|
||||
|
||||
if max_overlap_latest is not None:
|
||||
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||
if ov["repeats"] > max_overlap_latest:
|
||||
@@ -196,11 +299,9 @@ def api_recommend(
|
||||
if chosen is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}. "
|
||||
f"Try relaxing sum/odd/range/overlap constraints.",
|
||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||
)
|
||||
|
||||
# ✅ dedup save
|
||||
saved = save_recommendation_dedup(
|
||||
latest["drw_no"] if latest else None,
|
||||
chosen,
|
||||
@@ -220,10 +321,11 @@ def api_recommend(
|
||||
"params": params,
|
||||
"metrics": metrics,
|
||||
"recent_overlap": overlap,
|
||||
"tries": tries,
|
||||
"tries": tries,
|
||||
}
|
||||
|
||||
# ---------- ✅ heatmap-based recommend ----------
|
||||
|
||||
# ── 히트맵 기반 추천 (하위 호환 유지) ────────────────────────────────────────
|
||||
@app.get("/api/lotto/recommend/heatmap")
|
||||
def api_recommend_heatmap(
|
||||
heatmap_window: int = 20,
|
||||
@@ -231,8 +333,6 @@ def api_recommend_heatmap(
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
|
||||
# ---- optional constraints ----
|
||||
sum_min: Optional[int] = None,
|
||||
sum_max: Optional[int] = None,
|
||||
odd_min: Optional[int] = None,
|
||||
@@ -242,18 +342,13 @@ def api_recommend_heatmap(
|
||||
max_overlap_latest: Optional[int] = None,
|
||||
max_try: int = 200,
|
||||
):
|
||||
"""
|
||||
히트맵 기반 추천: 과거 추천 번호들의 적중률을 분석하여 가중치 부여
|
||||
"""
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
|
||||
# 과거 추천 데이터 가져오기 (적중 결과가 있는 것만)
|
||||
|
||||
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
|
||||
|
||||
latest = get_latest_draw()
|
||||
|
||||
|
||||
params = {
|
||||
"heatmap_window": heatmap_window,
|
||||
"heatmap_weight": float(heatmap_weight),
|
||||
@@ -269,7 +364,7 @@ def api_recommend_heatmap(
|
||||
"max_overlap_latest": max_overlap_latest,
|
||||
"max_try": int(max_try),
|
||||
}
|
||||
|
||||
|
||||
def _accept(nums: List[int]) -> bool:
|
||||
m = calc_metrics(nums)
|
||||
if sum_min is not None and m["sum"] < sum_min:
|
||||
@@ -284,16 +379,15 @@ def api_recommend_heatmap(
|
||||
return False
|
||||
if range_max is not None and m["range"] > range_max:
|
||||
return False
|
||||
|
||||
if max_overlap_latest is not None:
|
||||
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||
if ov["repeats"] > max_overlap_latest:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
chosen = None
|
||||
explain = None
|
||||
|
||||
|
||||
tries = 0
|
||||
while tries < max_try:
|
||||
tries += 1
|
||||
@@ -311,23 +405,22 @@ def api_recommend_heatmap(
|
||||
chosen = nums
|
||||
explain = result["explain"]
|
||||
break
|
||||
|
||||
|
||||
if chosen is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||
)
|
||||
|
||||
# ✅ dedup save
|
||||
|
||||
saved = save_recommendation_dedup(
|
||||
latest["drw_no"] if latest else None,
|
||||
chosen,
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
metrics = calc_metrics(chosen)
|
||||
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||
|
||||
|
||||
return {
|
||||
"id": saved["id"],
|
||||
"saved": saved["saved"],
|
||||
@@ -341,7 +434,8 @@ def api_recommend_heatmap(
|
||||
"tries": tries,
|
||||
}
|
||||
|
||||
# ---------- ✅ history list (filter/paging) ----------
|
||||
|
||||
# ── 추천 이력 ────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/history")
|
||||
def api_history(
|
||||
limit: int = 30,
|
||||
@@ -380,6 +474,7 @@ def api_history(
|
||||
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/history/{rec_id:int}")
|
||||
def api_history_delete(rec_id: int):
|
||||
ok = delete_recommendation(rec_id)
|
||||
@@ -387,12 +482,13 @@ def api_history_delete(rec_id: int):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {"deleted": True, "id": rec_id}
|
||||
|
||||
# ---------- ✅ history update (favorite/note/tags) ----------
|
||||
|
||||
class HistoryUpdate(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
note: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@app.patch("/api/history/{rec_id:int}")
|
||||
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
||||
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
||||
@@ -400,11 +496,11 @@ def api_history_patch(rec_id: int, body: HistoryUpdate):
|
||||
raise HTTPException(status_code=404, detail="Not found or no changes")
|
||||
return {"updated": True, "id": rec_id}
|
||||
|
||||
# ---------- ✅ batch recommend ----------
|
||||
|
||||
# ── 배치 추천 (하위 호환 유지) ───────────────────────────────────────────────
|
||||
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
|
||||
items = []
|
||||
seen = set()
|
||||
|
||||
tries = 0
|
||||
while len(items) < count and tries < max_try:
|
||||
tries += 1
|
||||
@@ -414,9 +510,9 @@ def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, a
|
||||
continue
|
||||
seen.add(key)
|
||||
items.append(r)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/api/lotto/recommend/batch")
|
||||
def api_recommend_batch(
|
||||
count: int = 5,
|
||||
@@ -443,17 +539,19 @@ def api_recommend_batch(
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
"count": count,
|
||||
"items": [{
|
||||
"numbers": it["numbers"],
|
||||
"numbers": it["numbers"],
|
||||
"explain": it["explain"],
|
||||
"metrics": calc_metrics(it["numbers"]),
|
||||
} for it in items],
|
||||
"params": params,
|
||||
}
|
||||
|
||||
|
||||
class BatchSave(BaseModel):
|
||||
items: List[List[int]]
|
||||
params: dict
|
||||
|
||||
|
||||
@app.post("/api/lotto/recommend/batch")
|
||||
def api_recommend_batch_save(body: BatchSave):
|
||||
latest = get_latest_draw()
|
||||
@@ -466,7 +564,7 @@ def api_recommend_batch_save(body: BatchSave):
|
||||
|
||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
import os
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
|
||||
Reference in New Issue
Block a user