로또 프리미엄 Phase 2 — 구매 이력 + 개인 패턴 분석 + 주간 리포트 캐싱

- purchase_history 테이블 추가 (draw_no, amount, sets, prize, note)
- weekly_reports 캐시 테이블 추가 (drw_no UNIQUE, report JSON)
- GET  /api/lotto/purchase         구매 이력 조회 (draw_no, days 필터)
- POST /api/lotto/purchase         구매 이력 추가
- PUT  /api/lotto/purchase/:id     구매 이력 수정 (당첨금 업데이트)
- DELETE /api/lotto/purchase/:id   구매 이력 삭제
- GET  /api/lotto/purchase/stats   투자 수익률 통계
- GET  /api/lotto/analysis/personal 개인 패턴 분석 (top/least picks, 홀짝/구간/연속번호)
- GET  /api/lotto/report/history   저장된 주간 리포트 목록
- GET  /api/lotto/report/:drw_no   캐시 우선 조회 + cached 플래그
- 스케줄러: 토요일 09:00 주간 리포트 자동 생성 및 DB 캐싱

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:59:07 +09:00
parent 2ce118baba
commit 732d78becc
3 changed files with 336 additions and 5 deletions

View File

@@ -355,6 +355,76 @@ def get_statistical_report(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]
}
def analyze_personal_patterns(
all_numbers: List[List[int]],
draws: List[Tuple[int, List[int]]],
) -> Dict[str, Any]:
"""
사용자 추천 이력 기반 개인 패턴 분석.
all_numbers: 저장된 모든 추천 번호 리스트 (각 원소는 6개짜리 리스트)
draws: 역대 당첨번호 (홀짝/합계 평균 비교용)
"""
if not all_numbers:
return {"total_analyzed": 0, "message": "추천 이력이 없습니다"}
total = len(all_numbers)
flat = [n for nums in all_numbers for n in nums]
freq = Counter(flat)
# 번호별 선택 빈도
number_frequency = {n: freq.get(n, 0) for n in range(1, 46)}
top_picks = sorted(range(1, 46), key=lambda n: -freq.get(n, 0))[:10]
least_picks = [n for n in sorted(range(1, 46), key=lambda n: freq.get(n, 0)) if freq.get(n, 0) == 0][:10]
# 패턴 지표
odd_counts = [sum(1 for n in nums if n % 2 == 1) for nums in all_numbers]
sums = [sum(nums) for nums in all_numbers]
ranges = [max(nums) - min(nums) for nums in all_numbers]
consecutive_count = sum(
1 for nums in all_numbers
if any(sorted(nums)[i + 1] - sorted(nums)[i] == 1 for i in range(5))
)
zone_totals = {k: 0 for k in ["1-9", "10-19", "20-29", "30-39", "40-45"]}
zone_ranges = [("1-9", 1, 9), ("10-19", 10, 19), ("20-29", 20, 29), ("30-39", 30, 39), ("40-45", 40, 45)]
for nums in all_numbers:
for label, lo, hi in zone_ranges:
zone_totals[label] += sum(1 for n in nums if lo <= n <= hi)
zone_avg = {k: round(v / total, 2) for k, v in zone_totals.items()}
avg_odd = sum(odd_counts) / total
avg_sum = sum(sums) / total
avg_range = sum(ranges) / total
# 역대 당첨번호 평균과 비교
if draws:
draw_odd_avg = sum(sum(1 for n in nums if n % 2 == 1) for _, nums in draws) / len(draws)
draw_sum_avg = sum(sum(nums) for _, nums in draws) / len(draws)
else:
draw_odd_avg = 3.0
draw_sum_avg = 138.0
return {
"total_analyzed": total,
"number_frequency": number_frequency,
"top_picks": top_picks,
"least_picks": least_picks,
"pattern": {
"avg_odd_count": round(avg_odd, 2),
"avg_sum": round(avg_sum, 1),
"avg_range": round(avg_range, 1),
"consecutive_rate": round(consecutive_count / total, 3),
"zone_avg": zone_avg,
},
"vs_draw_avg": {
"odd_diff": round(avg_odd - draw_odd_avg, 2),
"sum_diff": round(avg_sum - draw_sum_avg, 1),
"odd_tendency": "홀수 선호" if avg_odd > draw_odd_avg + 0.2 else ("짝수 선호" if avg_odd < draw_odd_avg - 0.2 else "균형"),
"sum_tendency": "고합계 선호" if avg_sum > draw_sum_avg + 5 else ("저합계 선호" if avg_sum < draw_sum_avg - 5 else "균형"),
},
}
def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]:
"""
특정 회차 공략 리포트 생성.