- backend/main.py: logging 모듈 도입, print() 제거 - stock-lab/main.py: print() → logger 전환, _calc_portfolio_totals 공용 함수 추출 - stock-lab/scraper.py: logging 모듈 도입, print() 제거 - docker-compose.yml: 전 서비스 healthcheck 블록 추가 (30s 간격, 3회 재시도) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1065 lines
36 KiB
Python
1065 lines
36 KiB
Python
import os
|
|
import time
|
|
import logging
|
|
from typing import Optional, List, Dict, Any, Tuple
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
|
logger = logging.getLogger("lotto-backend")
|
|
|
|
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,
|
|
# todos
|
|
get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos,
|
|
# blog
|
|
get_all_posts, create_post, update_post, delete_post,
|
|
# realestate
|
|
get_all_complexes, get_complex, create_complex, update_complex, delete_complex,
|
|
# subscription
|
|
get_all_subscription_items, create_subscription_item,
|
|
update_subscription_item, delete_subscription_item,
|
|
get_subscription_profile, upsert_subscription_profile,
|
|
# 성과 통계
|
|
get_recommendation_performance,
|
|
# Phase 2: 구매 이력
|
|
add_purchase, get_purchases, update_purchase, delete_purchase, get_purchase_stats,
|
|
# Phase 2: 주간 리포트 캐시
|
|
save_weekly_report, get_weekly_report_list, get_weekly_report,
|
|
# Phase 2: 개인 패턴 분석
|
|
get_all_recommendation_numbers,
|
|
)
|
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
|
from .collector import sync_latest, sync_ensure_all
|
|
from .generator import run_simulation, generate_smart_recommendations
|
|
from .checker import check_results_for_draw
|
|
from .utils import calc_metrics, calc_recent_overlap
|
|
from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns, generate_combined_recommendation
|
|
|
|
app = FastAPI()
|
|
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")
|
|
|
|
# ── 성과 통계 인메모리 캐시 ───────────────────────────────────────────────────
|
|
# 채점 데이터는 하루 2번 스케줄러 실행 시에만 갱신되므로 인메모리 캐시로 충분
|
|
_PERF_CACHE: Dict[str, Any] = {"data": None, "at": 0.0}
|
|
_PERF_CACHE_TTL = 3600 # 1시간 (스케줄러 미실행 상황 대비 폴백)
|
|
|
|
|
|
def _refresh_perf_cache() -> None:
|
|
_PERF_CACHE["data"] = get_recommendation_performance()
|
|
_PERF_CACHE["at"] = time.time()
|
|
logger.info("성과 통계 캐시 갱신")
|
|
|
|
|
|
@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"]:
|
|
check_results_for_draw(res["drawNo"])
|
|
_refresh_perf_cache() # 새 채점 결과 반영 → 즉시 갱신
|
|
|
|
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
|
|
|
# 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)
|
|
|
|
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
|
|
def _save_weekly_report_job():
|
|
import json as _json
|
|
draws = get_all_draw_numbers()
|
|
latest = get_latest_draw()
|
|
if not draws or not latest:
|
|
return
|
|
target = latest["drw_no"] + 1
|
|
report = generate_weekly_report(draws, target)
|
|
save_weekly_report(target, _json.dumps(report, ensure_ascii=False))
|
|
logger.info(f"{target}회차 리포트 저장 완료")
|
|
|
|
scheduler.add_job(_save_weekly_report_job, "cron", day_of_week="sat", hour=9, minute=0)
|
|
|
|
scheduler.start()
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/lotto/latest")
|
|
def api_latest():
|
|
row = get_latest_draw()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="No data yet")
|
|
return {
|
|
"drawNo": row["drw_no"],
|
|
"date": row["drw_date"],
|
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
|
"bonus": row["bonus"],
|
|
"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)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
return {
|
|
"drwNo": row["drw_no"],
|
|
"date": row["drw_date"],
|
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
|
"bonus": row["bonus"],
|
|
"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():
|
|
sync_ensure_all(LATEST_URL, ALL_URL)
|
|
|
|
draws = get_all_draw_numbers()
|
|
if not draws:
|
|
raise HTTPException(status_code=404, detail="No data yet")
|
|
|
|
frequency = {n: 0 for n in range(1, 46)}
|
|
total_draws = len(draws)
|
|
|
|
for _, nums in draws:
|
|
for n in nums:
|
|
frequency[n] += 1
|
|
|
|
stats = [
|
|
{"number": n, "count": frequency[n]}
|
|
for n in range(1, 46)
|
|
]
|
|
|
|
return {
|
|
"total_draws": total_draws,
|
|
"frequency": stats,
|
|
}
|
|
|
|
|
|
# ── 추천 성과 통계 (Phase 1, 인메모리 캐시) ──────────────────────────────────
|
|
@app.get("/api/lotto/stats/performance")
|
|
def api_performance_stats():
|
|
"""
|
|
채점된 추천 이력 기반 성과 통계 (캐시 반환).
|
|
캐시 갱신 시점: 새 회차 채점 직후 | TTL 1시간 만료 시
|
|
"""
|
|
if _PERF_CACHE["data"] is None or time.time() - _PERF_CACHE["at"] > _PERF_CACHE_TTL:
|
|
_refresh_perf_cache()
|
|
return _PERF_CACHE["data"]
|
|
|
|
|
|
# ── 회차 공략 리포트 (Phase 1) ────────────────────────────────────────────────
|
|
@app.get("/api/lotto/report/latest")
|
|
def api_report_latest():
|
|
"""
|
|
다음 회차 공략 리포트 (최신 회차 기준으로 자동 계산).
|
|
- 과출현/냉각/오버듀 번호 분석
|
|
- 최근 3회 패턴
|
|
- 3가지 전략별 추천 번호
|
|
- AI 신뢰도 점수
|
|
"""
|
|
draws = get_all_draw_numbers()
|
|
if not draws:
|
|
raise HTTPException(status_code=404, detail="No data yet")
|
|
latest = get_latest_draw()
|
|
target = latest["drw_no"] + 1
|
|
return generate_weekly_report(draws, target)
|
|
|
|
|
|
@app.get("/api/lotto/report/history")
|
|
def api_report_history(limit: int = 10):
|
|
"""저장된 주간 리포트 목록 (자동 저장된 것만)"""
|
|
return {"reports": get_weekly_report_list(limit=min(limit, 52))}
|
|
|
|
|
|
@app.get("/api/lotto/report/{drw_no}")
|
|
def api_report_by_draw(drw_no: int):
|
|
"""
|
|
특정 회차 공략 리포트 (캐시 우선, 없으면 실시간 생성).
|
|
"""
|
|
cached = get_weekly_report(drw_no)
|
|
if cached:
|
|
return {**cached, "cached": True}
|
|
|
|
draws = get_all_draw_numbers()
|
|
if not draws:
|
|
raise HTTPException(status_code=404, detail="No data yet")
|
|
base_draws = [(no, nums) for no, nums in draws if no < drw_no]
|
|
if not base_draws:
|
|
raise HTTPException(status_code=400, detail=f"{drw_no}회차 이전 데이터가 없습니다")
|
|
return {**generate_weekly_report(base_draws, drw_no), "cached": False}
|
|
|
|
|
|
# ── 개인 패턴 분석 (Phase 2) ─────────────────────────────────────────────────
|
|
@app.get("/api/lotto/analysis/personal")
|
|
def api_personal_analysis():
|
|
"""
|
|
저장된 추천 이력 기반 개인 패턴 분석.
|
|
- 자주 선택한 번호 TOP 10 / 한 번도 선택 안 한 번호
|
|
- 홀짝 비율, 합계, 범위, 연속번호 포함률
|
|
- 구간별 분포, 역대 당첨번호 평균과 비교
|
|
"""
|
|
all_numbers = get_all_recommendation_numbers()
|
|
draws = get_all_draw_numbers()
|
|
return analyze_personal_patterns(all_numbers, draws)
|
|
|
|
|
|
# ── 구매 이력 API (Phase 2) ───────────────────────────────────────────────────
|
|
|
|
class PurchaseCreate(BaseModel):
|
|
draw_no: int
|
|
amount: int
|
|
sets: int = 1
|
|
prize: int = 0
|
|
note: str = ""
|
|
|
|
|
|
class PurchaseUpdate(BaseModel):
|
|
draw_no: Optional[int] = None
|
|
amount: Optional[int] = None
|
|
sets: Optional[int] = None
|
|
prize: Optional[int] = None
|
|
note: Optional[str] = None
|
|
|
|
|
|
@app.get("/api/lotto/purchase/stats")
|
|
def api_purchase_stats():
|
|
"""투자 수익률 통계 (총 투자금, 총 당첨금, 수익률 등)"""
|
|
return get_purchase_stats()
|
|
|
|
|
|
@app.get("/api/lotto/purchase")
|
|
def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None):
|
|
"""구매 이력 조회 (draw_no, days 필터 선택)"""
|
|
return {"records": get_purchases(draw_no=draw_no, days=days)}
|
|
|
|
|
|
@app.post("/api/lotto/purchase", status_code=201)
|
|
def api_purchase_create(body: PurchaseCreate):
|
|
"""구매 이력 추가"""
|
|
return add_purchase(body.draw_no, body.amount, body.sets, body.prize, body.note)
|
|
|
|
|
|
@app.put("/api/lotto/purchase/{purchase_id}")
|
|
def api_purchase_update(purchase_id: int, body: PurchaseUpdate):
|
|
"""구매 이력 수정 (당첨금 업데이트 등)"""
|
|
updated = update_purchase(purchase_id, body.model_dump(exclude_none=True))
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail="Purchase not found")
|
|
return updated
|
|
|
|
|
|
@app.delete("/api/lotto/purchase/{purchase_id}")
|
|
def api_purchase_delete(purchase_id: int):
|
|
"""구매 이력 삭제"""
|
|
if not delete_purchase(purchase_id):
|
|
raise HTTPException(status_code=404, detail="Purchase not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
|
|
@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,
|
|
}
|
|
|
|
|
|
# ── 종합 추론 추천 ───────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/lotto/recommend/combined")
|
|
def api_recommend_combined():
|
|
"""5가지 통계 기법 종합 추론 추천 — 결과를 이력에 저장한다."""
|
|
draws = get_all_draw_numbers()
|
|
if not draws:
|
|
raise HTTPException(status_code=404, detail="No data")
|
|
|
|
latest = get_latest_draw()
|
|
result = generate_combined_recommendation(draws)
|
|
if "error" in result:
|
|
raise HTTPException(status_code=500, detail=result["error"])
|
|
|
|
# 추천 이력 저장 (태그: 종합추론)
|
|
params = {"method": "combined"}
|
|
saved = save_recommendation_dedup(
|
|
latest["drw_no"] if latest else None,
|
|
result["final_numbers"],
|
|
params,
|
|
)
|
|
if saved["saved"]:
|
|
update_recommendation(saved["id"], tags=["종합추론"])
|
|
|
|
return {
|
|
**result,
|
|
"id": saved["id"],
|
|
"saved": saved["saved"],
|
|
"deduped": saved["deduped"],
|
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
|
}
|
|
|
|
|
|
@app.get("/api/lotto/recommend/combined/history")
|
|
def api_combined_history(limit: int = 30):
|
|
"""종합추론 추천 이력 조회."""
|
|
items = list_recommendations_ex(limit=limit, tag="종합추론", sort="id_desc")
|
|
return {"items": items, "total": len(items)}
|
|
|
|
|
|
# ── 기존 수동 추천 API (하위 호환 유지) ─────────────────────────────────────
|
|
@app.get("/api/lotto/recommend")
|
|
def api_recommend(
|
|
recent_window: int = 200,
|
|
recent_weight: float = 2.0,
|
|
avoid_recent_k: int = 5,
|
|
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,
|
|
max_try: int = 200,
|
|
):
|
|
draws = get_all_draw_numbers()
|
|
if not draws:
|
|
raise HTTPException(status_code=404, detail="No data yet")
|
|
|
|
latest = get_latest_draw()
|
|
|
|
params = {
|
|
"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,
|
|
"odd_max": odd_max,
|
|
"range_min": range_min,
|
|
"range_max": range_max,
|
|
"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:
|
|
return False
|
|
if sum_max is not None and m["sum"] > sum_max:
|
|
return False
|
|
if odd_min is not None and m["odd"] < odd_min:
|
|
return False
|
|
if odd_max is not None and m["odd"] > odd_max:
|
|
return False
|
|
if range_min is not None and m["range"] < range_min:
|
|
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
|
|
result = recommend_numbers(
|
|
draws,
|
|
recent_window=recent_window,
|
|
recent_weight=recent_weight,
|
|
avoid_recent_k=avoid_recent_k,
|
|
)
|
|
nums = result["numbers"]
|
|
if _accept(nums):
|
|
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}.",
|
|
)
|
|
|
|
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"],
|
|
"deduped": saved["deduped"],
|
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
|
"numbers": chosen,
|
|
"explain": explain,
|
|
"params": params,
|
|
"metrics": metrics,
|
|
"recent_overlap": overlap,
|
|
"tries": tries,
|
|
}
|
|
|
|
|
|
# ── 히트맵 기반 추천 (하위 호환 유지) ────────────────────────────────────────
|
|
@app.get("/api/lotto/recommend/heatmap")
|
|
def api_recommend_heatmap(
|
|
heatmap_window: int = 20,
|
|
heatmap_weight: float = 1.5,
|
|
recent_window: int = 200,
|
|
recent_weight: float = 2.0,
|
|
avoid_recent_k: int = 5,
|
|
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,
|
|
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),
|
|
"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,
|
|
"odd_max": odd_max,
|
|
"range_min": range_min,
|
|
"range_max": range_max,
|
|
"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:
|
|
return False
|
|
if sum_max is not None and m["sum"] > sum_max:
|
|
return False
|
|
if odd_min is not None and m["odd"] < odd_min:
|
|
return False
|
|
if odd_max is not None and m["odd"] > odd_max:
|
|
return False
|
|
if range_min is not None and m["range"] < range_min:
|
|
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
|
|
result = recommend_with_heatmap(
|
|
draws,
|
|
past_recs,
|
|
heatmap_window=heatmap_window,
|
|
heatmap_weight=heatmap_weight,
|
|
recent_window=recent_window,
|
|
recent_weight=recent_weight,
|
|
avoid_recent_k=avoid_recent_k,
|
|
)
|
|
nums = result["numbers"]
|
|
if _accept(nums):
|
|
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}.",
|
|
)
|
|
|
|
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"],
|
|
"deduped": saved["deduped"],
|
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
|
"numbers": chosen,
|
|
"explain": explain,
|
|
"params": params,
|
|
"metrics": metrics,
|
|
"recent_overlap": overlap,
|
|
"tries": tries,
|
|
}
|
|
|
|
|
|
# ── 추천 이력 ────────────────────────────────────────────────────────────────
|
|
@app.get("/api/history")
|
|
def api_history(
|
|
limit: int = 30,
|
|
offset: int = 0,
|
|
favorite: Optional[bool] = None,
|
|
tag: Optional[str] = None,
|
|
q: Optional[str] = None,
|
|
sort: str = "id_desc",
|
|
):
|
|
items = list_recommendations_ex(
|
|
limit=limit,
|
|
offset=offset,
|
|
favorite=favorite,
|
|
tag=tag,
|
|
q=q,
|
|
sort=sort,
|
|
)
|
|
|
|
draws = get_all_draw_numbers()
|
|
|
|
out = []
|
|
for it in items:
|
|
nums = it["numbers"]
|
|
out.append({
|
|
**it,
|
|
"metrics": calc_metrics(nums),
|
|
"recent_overlap": calc_recent_overlap(
|
|
nums, draws, last_k=int(it["params"].get("avoid_recent_k", 0) or 0)
|
|
),
|
|
})
|
|
|
|
return {
|
|
"items": out,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"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)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
return {"deleted": True, "id": rec_id}
|
|
|
|
|
|
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)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Not found or no changes")
|
|
return {"updated": True, "id": rec_id}
|
|
|
|
|
|
# ── 배치 추천 (하위 호환 유지) ───────────────────────────────────────────────
|
|
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
|
|
r = recommend_numbers(draws, recent_window=recent_window, recent_weight=recent_weight, avoid_recent_k=avoid_recent_k)
|
|
key = tuple(sorted(r["numbers"]))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
items.append(r)
|
|
return items
|
|
|
|
|
|
@app.get("/api/lotto/recommend/batch")
|
|
def api_recommend_batch(
|
|
count: int = 5,
|
|
recent_window: int = 200,
|
|
recent_weight: float = 2.0,
|
|
avoid_recent_k: int = 5,
|
|
):
|
|
count = max(1, min(count, 20))
|
|
draws = get_all_draw_numbers()
|
|
if not draws:
|
|
raise HTTPException(status_code=404, detail="No data yet")
|
|
|
|
latest = get_latest_draw()
|
|
params = {
|
|
"recent_window": recent_window,
|
|
"recent_weight": float(recent_weight),
|
|
"avoid_recent_k": avoid_recent_k,
|
|
"count": count,
|
|
}
|
|
|
|
items = _batch_unique(draws, count, recent_window, float(recent_weight), avoid_recent_k)
|
|
|
|
return {
|
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
|
"count": count,
|
|
"items": [{
|
|
"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()
|
|
based = latest["drw_no"] if latest else None
|
|
|
|
created, deduped = [], []
|
|
for nums in body.items:
|
|
saved = save_recommendation_dedup(based, nums, body.params)
|
|
(created if saved["saved"] else deduped).append(saved["id"])
|
|
|
|
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
|
|
|
|
|
@app.get("/api/version")
|
|
def version():
|
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
|
|
|
|
|
# ── Todos API ─────────────────────────────────────────────────────────────────
|
|
|
|
class TodoCreate(BaseModel):
|
|
title: str
|
|
description: Optional[str] = None
|
|
status: str = "todo"
|
|
|
|
|
|
class TodoUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
status: Optional[str] = None
|
|
|
|
|
|
@app.get("/api/todos")
|
|
def api_todos_list():
|
|
return get_all_todos()
|
|
|
|
|
|
@app.post("/api/todos", status_code=201)
|
|
def api_todos_create(body: TodoCreate):
|
|
if body.status not in ("todo", "in_progress", "done"):
|
|
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
|
|
return create_todo(body.title, body.description, body.status)
|
|
|
|
|
|
# ⚠️ /done 라우트를 /{todo_id} 보다 먼저 등록해야 done이 id로 매칭되지 않음
|
|
@app.delete("/api/todos/done")
|
|
def api_todos_delete_done():
|
|
deleted = delete_done_todos()
|
|
return {"deleted": deleted}
|
|
|
|
|
|
@app.put("/api/todos/{todo_id}")
|
|
def api_todos_update(todo_id: str, body: TodoUpdate):
|
|
if body.status is not None and body.status not in ("todo", "in_progress", "done"):
|
|
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
|
|
updated = update_todo(todo_id, body.model_dump(exclude_none=True))
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail="Todo not found")
|
|
return updated
|
|
|
|
|
|
@app.delete("/api/todos/{todo_id}")
|
|
def api_todos_delete(todo_id: str):
|
|
ok = delete_todo(todo_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Todo not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Blog API ──────────────────────────────────────────────────────────────────
|
|
|
|
class BlogPostCreate(BaseModel):
|
|
title: str
|
|
body: str = ""
|
|
excerpt: str = ""
|
|
tags: List[str] = []
|
|
date: str = "" # 빈 문자열이면 오늘 날짜 사용
|
|
|
|
|
|
class BlogPostUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
body: Optional[str] = None
|
|
excerpt: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
date: Optional[str] = None
|
|
|
|
|
|
@app.get("/api/blog/posts")
|
|
def api_blog_list():
|
|
return {"posts": get_all_posts()}
|
|
|
|
|
|
@app.post("/api/blog/posts", status_code=201)
|
|
def api_blog_create(body: BlogPostCreate):
|
|
from datetime import date as _date
|
|
post_date = body.date if body.date else _date.today().isoformat()
|
|
post = create_post(body.title, body.body, body.excerpt, body.tags, post_date)
|
|
return post
|
|
|
|
|
|
@app.put("/api/blog/posts/{post_id}")
|
|
def api_blog_update(post_id: int, body: BlogPostUpdate):
|
|
updated = update_post(post_id, body.model_dump(exclude_none=True))
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
return updated
|
|
|
|
|
|
@app.delete("/api/blog/posts/{post_id}")
|
|
def api_blog_delete(post_id: int):
|
|
ok = delete_post(post_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── RealEstate API ─────────────────────────────────────────────────────────────
|
|
|
|
VALID_STATUSES = {"청약예정", "청약중", "결과발표", "완료"}
|
|
VALID_PRIORITIES = {"high", "normal", "low"}
|
|
|
|
|
|
class ComplexCreate(BaseModel):
|
|
name: str
|
|
address: str = ""
|
|
lat: Optional[float] = None
|
|
lng: Optional[float] = None
|
|
units: Optional[int] = None
|
|
types: List[str] = []
|
|
avgPricePerPyeong: Optional[int] = None
|
|
subscriptionStart: Optional[str] = None
|
|
subscriptionEnd: Optional[str] = None
|
|
resultDate: Optional[str] = None
|
|
status: str = "청약예정"
|
|
priority: str = "normal"
|
|
tags: List[str] = []
|
|
naverUrl: str = ""
|
|
floorPlanUrl: str = ""
|
|
memo: str = ""
|
|
|
|
|
|
class ComplexUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
address: Optional[str] = None
|
|
lat: Optional[float] = None
|
|
lng: Optional[float] = None
|
|
units: Optional[int] = None
|
|
types: Optional[List[str]] = None
|
|
avgPricePerPyeong: Optional[int] = None
|
|
subscriptionStart: Optional[str] = None
|
|
subscriptionEnd: Optional[str] = None
|
|
resultDate: Optional[str] = None
|
|
status: Optional[str] = None
|
|
priority: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
naverUrl: Optional[str] = None
|
|
floorPlanUrl: Optional[str] = None
|
|
memo: Optional[str] = None
|
|
|
|
|
|
@app.get("/api/realestate/complexes")
|
|
def api_realestate_list():
|
|
return get_all_complexes()
|
|
|
|
|
|
@app.post("/api/realestate/complexes", status_code=201)
|
|
def api_realestate_create(body: ComplexCreate):
|
|
if body.status not in VALID_STATUSES:
|
|
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}")
|
|
if body.priority not in VALID_PRIORITIES:
|
|
raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}")
|
|
return create_complex(body.model_dump())
|
|
|
|
|
|
@app.put("/api/realestate/complexes/{complex_id}")
|
|
def api_realestate_update(complex_id: int, body: ComplexUpdate):
|
|
data = body.model_dump(exclude_none=True)
|
|
if "status" in data and data["status"] not in VALID_STATUSES:
|
|
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}")
|
|
if "priority" in data and data["priority"] not in VALID_PRIORITIES:
|
|
raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}")
|
|
updated = update_complex(complex_id, data)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail="Complex not found")
|
|
return updated
|
|
|
|
|
|
@app.delete("/api/realestate/complexes/{complex_id}")
|
|
def api_realestate_delete(complex_id: int):
|
|
ok = delete_complex(complex_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Complex not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── Subscription API ───────────────────────────────────────────────────────────
|
|
|
|
class SubscriptionItemCreate(BaseModel):
|
|
complexName: str
|
|
address: str = ""
|
|
pyeong: Optional[str] = None
|
|
totalPrice: Optional[int] = None
|
|
type: Optional[str] = None
|
|
specialType: Optional[str] = None
|
|
supplyType: Optional[str] = None
|
|
status: str = "검토중"
|
|
minScore: Optional[int] = None
|
|
maxIncome: Optional[int] = None
|
|
homelessRequired: Optional[int] = None
|
|
subscriptionStart: Optional[str] = None
|
|
subscriptionEnd: Optional[str] = None
|
|
contractDate: Optional[str] = None
|
|
interimDate: Optional[str] = None
|
|
balanceDate: Optional[str] = None
|
|
resultDate: Optional[str] = None
|
|
depositRate: int = 10
|
|
interimRate: int = 60
|
|
balanceRate: int = 30
|
|
loanType: Optional[str] = None
|
|
loanRate: Optional[float] = None
|
|
memo: str = ""
|
|
naverUrl: str = ""
|
|
|
|
|
|
class SubscriptionItemUpdate(BaseModel):
|
|
complexName: Optional[str] = None
|
|
address: Optional[str] = None
|
|
pyeong: Optional[str] = None
|
|
totalPrice: Optional[int] = None
|
|
type: Optional[str] = None
|
|
specialType: Optional[str] = None
|
|
supplyType: Optional[str] = None
|
|
status: Optional[str] = None
|
|
minScore: Optional[int] = None
|
|
maxIncome: Optional[int] = None
|
|
homelessRequired: Optional[int] = None
|
|
subscriptionStart: Optional[str] = None
|
|
subscriptionEnd: Optional[str] = None
|
|
contractDate: Optional[str] = None
|
|
interimDate: Optional[str] = None
|
|
balanceDate: Optional[str] = None
|
|
resultDate: Optional[str] = None
|
|
depositRate: Optional[int] = None
|
|
interimRate: Optional[int] = None
|
|
balanceRate: Optional[int] = None
|
|
loanType: Optional[str] = None
|
|
loanRate: Optional[float] = None
|
|
memo: Optional[str] = None
|
|
naverUrl: Optional[str] = None
|
|
|
|
|
|
class SubscriptionProfile(BaseModel):
|
|
isHouseholdHead: Optional[bool] = None
|
|
isHomeless: Optional[bool] = None
|
|
homelessPeriod: Optional[int] = None
|
|
savingsMonths: Optional[int] = None
|
|
savingsCount: Optional[int] = None
|
|
dependents: Optional[int] = None
|
|
residencyArea: Optional[str] = None
|
|
isMarried: Optional[bool] = None
|
|
marriageMonths: Optional[int] = None
|
|
monthlyIncome: Optional[int] = None
|
|
specialQuals: Optional[List[str]] = None
|
|
|
|
|
|
@app.get("/api/subscription/items")
|
|
def api_subscription_list():
|
|
return get_all_subscription_items()
|
|
|
|
|
|
@app.post("/api/subscription/items", status_code=201)
|
|
def api_subscription_create(body: SubscriptionItemCreate):
|
|
return create_subscription_item(body.model_dump())
|
|
|
|
|
|
@app.put("/api/subscription/items/{item_id}")
|
|
def api_subscription_update(item_id: int, body: SubscriptionItemUpdate):
|
|
updated = update_subscription_item(item_id, body.model_dump(exclude_none=True))
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
return updated
|
|
|
|
|
|
@app.delete("/api/subscription/items/{item_id}")
|
|
def api_subscription_delete(item_id: int):
|
|
ok = delete_subscription_item(item_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/subscription/profile")
|
|
def api_subscription_profile_get():
|
|
profile = get_subscription_profile()
|
|
return profile if profile is not None else {}
|
|
|
|
|
|
@app.put("/api/subscription/profile")
|
|
def api_subscription_profile_put(body: SubscriptionProfile):
|
|
return upsert_subscription_profile(body.model_dump(exclude_none=True))
|