lotto 추천 및 시뮬레이션 시스템 고도화

This commit is contained in:
2026-02-23 22:27:21 +09:00
parent 1d78b2c430
commit 628a47b2ec
4 changed files with 617 additions and 177 deletions

View File

@@ -82,6 +82,25 @@ export function deleteHistory(id) {
return apiDelete(`/api/history/${id}`);
}
// ── 시뮬레이션 관련 API ──────────────────────────────────────────────────────
export function getBestPicks(limit = 20) {
return apiGet(`/api/lotto/best?limit=${limit}`);
}
export function getAnalysis() {
return apiGet('/api/lotto/analysis');
}
export function triggerSimulate(nCandidates = 20000, topK = 100, bestN = 20) {
const qs = new URLSearchParams({
n_candidates: String(nCandidates),
top_k: String(topK),
best_n: String(bestN),
});
return apiPost(`/api/admin/simulate?${qs.toString()}`);
}
export function getStockNews(limit = 20, category) {
const qs = new URLSearchParams({ limit: String(limit) });
if (category) {

View File

@@ -1,5 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { deleteHistory, getHistory, getLatest, getStats, recommend } from '../../api';
import {
deleteHistory, getHistory, getLatest, getStats, recommend,
getBestPicks, getAnalysis, triggerSimulate,
} from '../../api';
const fmtKST = (value) => value?.replace('T', ' ') ?? '';
@@ -23,6 +26,7 @@ const NumberRow = ({ nums }) => (
const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
const STATS_CACHE_KEY = 'lotto_stats_v1';
const BEST_PICKS_DEFAULT_SHOW = 5;
const readStatsCache = () => {
if (typeof window === 'undefined') return null;
@@ -243,6 +247,7 @@ const FrequencyChart = ({ stats }) => {
};
export default function Functions() {
// ── 기존 상태 ──────────────────────────────────────────────────────────────
const [latest, setLatest] = useState(null);
const [params, setParams] = useState({
recent_window: 200,
@@ -258,7 +263,6 @@ export default function Functions() {
],
[]
);
const [result, setResult] = useState(null);
const [history, setHistory] = useState([]);
const [historyExpanded, setHistoryExpanded] = useState(false);
@@ -271,8 +275,19 @@ export default function Functions() {
latest: false,
recommend: false,
history: false,
bestPicks: false,
analysis: false,
});
const [error, setError] = useState('');
// ── 시뮬레이션 관련 상태 ───────────────────────────────────────────────────
const [bestPicks, setBestPicks] = useState([]);
const [bestPicksExpanded, setBestPicksExpanded] = useState(false);
const [analysis, setAnalysis] = useState(null);
const [simulating, setSimulating] = useState(false);
const [simResult, setSimResult] = useState(null);
// ── 파생 값 ────────────────────────────────────────────────────────────────
const overallMetrics = useMemo(
() => buildMetricsFromFrequency(stats?.frequency),
[stats]
@@ -281,7 +296,12 @@ export default function Functions() {
() => buildMetricsFromHistory(history),
[history]
);
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
const visibleBestPicks = bestPicksExpanded
? bestPicks
: bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
// ── 기존 데이터 로드 함수 ──────────────────────────────────────────────────
const refreshLatest = async () => {
setLoading((s) => ({ ...s, latest: true }));
setError('');
@@ -339,6 +359,49 @@ export default function Functions() {
}
};
// ── 시뮬레이션 관련 함수 ───────────────────────────────────────────────────
const refreshBestPicks = async () => {
setLoading((s) => ({ ...s, bestPicks: true }));
try {
const data = await getBestPicks(20);
setBestPicks(data.items ?? []);
} catch (e) {
// best picks 없을 수 있음 (첫 실행 전) - 전역 에러 표시 안 함
} finally {
setLoading((s) => ({ ...s, bestPicks: false }));
}
};
const refreshAnalysis = async () => {
setLoading((s) => ({ ...s, analysis: true }));
try {
const data = await getAnalysis();
setAnalysis(data);
} catch {
// 분석 데이터는 보조 정보이므로 에러 무시
} finally {
setLoading((s) => ({ ...s, analysis: false }));
}
};
const onSimulate = async () => {
const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)');
if (!ok) return;
setSimulating(true);
setSimResult(null);
setError('');
try {
const data = await triggerSimulate();
setSimResult(data);
await refreshBestPicks();
} catch (e) {
setError(e?.message ?? String(e));
} finally {
setSimulating(false);
}
};
// ── 수동 추천 ──────────────────────────────────────────────────────────────
const onRecommend = async () => {
setLoading((s) => ({ ...s, recommend: true }));
setError('');
@@ -356,7 +419,6 @@ export default function Functions() {
const onDelete = async (id) => {
const ok = confirm(`히스토리 #${id}를 삭제할까요?`);
if (!ok) return;
setError('');
try {
await deleteHistory(id);
@@ -376,14 +438,15 @@ export default function Functions() {
}
};
// ── 초기 로드 ──────────────────────────────────────────────────────────────
useEffect(() => {
refreshLatest();
refreshHistory();
refreshStats();
refreshBestPicks();
refreshAnalysis();
}, []);
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
useEffect(() => {
if (historyExpanded && !prevHistoryExpandedRef.current) {
requestAnimationFrame(() => {
@@ -396,6 +459,7 @@ export default function Functions() {
prevHistoryExpandedRef.current = historyExpanded;
}, [historyExpanded, visibleHistory.length]);
// ── 렌더 ───────────────────────────────────────────────────────────────────
return (
<div className="lotto-functions">
{error ? (
@@ -410,7 +474,9 @@ export default function Functions() {
</div>
) : null}
{/* ── 상단 2열 그리드: 최신 회차 + 시뮬레이션 추천 ── */}
<div className="lotto-grid">
{/* Latest Draw */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
@@ -462,17 +528,262 @@ export default function Functions() {
)}
</section>
{/* Simulation Picks */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Recommendation</p>
<h3>추천 생성</h3>
<p className="lotto-panel__eyebrow">Simulation Picks</p>
<h3>시뮬레이션 추천</h3>
<p className="lotto-panel__sub">
파라미터를 조정해 다른 추천 전략을 만들 있습니다.
하루 6 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
</p>
</div>
<div className="lotto-panel__actions">
{loading.recommend ? <span className="lotto-chip">계산 </span> : null}
{loading.bestPicks ? <span className="lotto-chip">로딩 </span> : null}
{simulating ? (
<span className="lotto-chip lotto-chip--active">분석 </span>
) : null}
<button
className="button ghost small"
onClick={refreshBestPicks}
disabled={loading.bestPicks || simulating}
>
새로고침
</button>
<button
className="button small"
onClick={onSimulate}
disabled={simulating || loading.bestPicks}
>
{simulating ? '실행 중...' : '지금 실행'}
</button>
</div>
</div>
{simResult ? (
<div className="lotto-sim-result">
<p>
완료: {simResult.total_generated?.toLocaleString()} 후보 {' '}
상위 {simResult.best_n_saved} 저장
</p>
<p>
최고 점수 {((simResult.best_score ?? 0) * 100).toFixed(1)}% /{' '}
평균 {((simResult.avg_score ?? 0) * 100).toFixed(1)}%
</p>
</div>
) : null}
{bestPicks.length === 0 ? (
<p className="lotto-empty">
{loading.bestPicks
? '불러오는 중...'
: "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
</p>
) : (
<>
<div className="lotto-picks">
{visibleBestPicks.map((pick) => (
<div key={pick.id} className="lotto-pick">
<span className="lotto-pick__rank">#{pick.rank}</span>
<div className="lotto-pick__content">
<NumberRow nums={pick.numbers} />
<div className="lotto-pick__score">
<span className="lotto-pick__score-label">
{((pick.score_total ?? 0) * 100).toFixed(1)}%
</span>
<div className="lotto-pick__bar">
<span
style={{
width: `${(pick.score_total ?? 0) * 100}%`,
}}
/>
</div>
</div>
</div>
<button
className="button ghost small"
onClick={() => copyNumbers(pick.numbers)}
>
복사
</button>
</div>
))}
</div>
{bestPicks.length > BEST_PICKS_DEFAULT_SHOW ? (
<button
className="button ghost small lotto-history-toggle"
onClick={() => setBestPicksExpanded((prev) => !prev)}
aria-expanded={bestPicksExpanded}
>
{bestPicksExpanded
? '접기'
: `모두 보기 (${bestPicks.length}개)`}
<span
className={`lotto-history-toggle__icon ${bestPicksExpanded ? 'is-open' : ''}`}
aria-hidden
>
</span>
</button>
) : null}
<p className="lotto-panel__sub">
갱신: {fmtKST(bestPicks[0]?.created_at) || '-'}
{bestPicks[0]?.based_on_draw
? ` · ${bestPicks[0].based_on_draw}회 기준`
: ''}
</p>
</>
)}
</section>
</div>
{/* ── 통계 분석 ── */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Analysis</p>
<h3>통계 분석</h3>
<p className="lotto-panel__sub">
빈도, Z-score, 분석으로 번호를 분류합니다.
</p>
</div>
<div className="lotto-panel__actions">
{loading.analysis ? <span className="lotto-chip">로딩 </span> : null}
<button
className="button ghost small"
onClick={refreshAnalysis}
disabled={loading.analysis}
>
새로고침
</button>
</div>
</div>
{analysis ? (
<div className="lotto-analysis">
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">
🔥 번호
<span>출현 빈도 상위 10</span>
</p>
<div className="lotto-row">
{(analysis.hot_numbers ?? []).map((n) => (
<Ball key={n} n={n} />
))}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">
🧊 콜드 번호
<span>출현 빈도 하위 10</span>
</p>
<div className="lotto-row">
{(analysis.cold_numbers ?? []).map((n) => (
<Ball key={n} n={n} />
))}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">
오버듀 번호
<span>오래 나온 번호 (회차 )</span>
</p>
<div className="lotto-row">
{(analysis.overdue_numbers ?? []).map((n) => {
const stat = (analysis.number_stats ?? []).find(
(s) => s.number === n
);
return (
<div key={n} className="lotto-overdue">
<Ball n={n} />
<span className="lotto-overdue__gap">
{stat?.gap ?? '-'}
</span>
</div>
);
})}
</div>
</div>
</div>
<div className="lotto-analysis__stats">
<span>
역대 합계 평균 <strong>{analysis.mean_sum}</strong>
</span>
<span>
표준편차 <strong>±{analysis.std_sum}</strong>
</span>
<span>
분석 회차 <strong>{analysis.total_draws?.toLocaleString()}</strong>
</span>
<span>
홀수 3:짝수 3 확률{' '}
<strong>
{analysis.odd_distribution?.['3']
? `${analysis.odd_distribution['3']}%`
: '-'}
</strong>
</span>
</div>
</div>
) : (
<p className="lotto-empty">
{loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
</p>
)}
</section>
{/* ── 전체 번호 분포 ── */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
<h3>전체 회차 번호 분포</h3>
<p className="lotto-panel__sub">
1~45 번호가 등장한 횟수를 기준으로 분포를 표시합니다.
</p>
</div>
<div className="lotto-panel__actions">
{statsLoading ? (
<span className="lotto-chip">로딩 </span>
) : null}
{stats?.total_draws ? (
<span className="lotto-chip">{stats.total_draws}회차</span>
) : null}
<button
className="button ghost small"
onClick={refreshStats}
disabled={statsLoading}
>
새로고침
</button>
</div>
</div>
{statsError ? <p className="lotto-empty">{statsError}</p> : null}
{stats ? (
<FrequencyChart stats={stats} />
) : (
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)}
</section>
{/* ── 수동 추천 ── */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
<h3>수동 추천</h3>
<p className="lotto-panel__sub">
파라미터를 직접 조정해 번호를 추천받을 있습니다.
</p>
</div>
<div className="lotto-panel__actions">
{loading.recommend ? (
<span className="lotto-chip">계산 </span>
) : null}
</div>
</div>
@@ -630,53 +941,14 @@ export default function Functions() {
)}
</section>
</div>
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
<h3>전체 회차 번호 분포</h3>
<p className="lotto-panel__sub">
1~45 번호가 등장한 횟수를 기준으로 분포를 표시합니다.
</p>
</div>
<div className="lotto-panel__actions">
{statsLoading ? (
<span className="lotto-chip">로딩 </span>
) : null}
{stats?.total_draws ? (
<span className="lotto-chip">
{stats.total_draws}회차
</span>
) : null}
<button
className="button ghost small"
onClick={refreshStats}
disabled={statsLoading}
>
새로고침
</button>
</div>
</div>
{statsError ? <p className="lotto-empty">{statsError}</p> : null}
{stats ? (
<FrequencyChart stats={stats} />
) : (
<p className="lotto-empty">
통계 데이터를 불러오지 못했습니다.
</p>
)}
</section>
{/* ── 추천 히스토리 ── */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
<h3>추천 히스토리</h3>
<p className="lotto-panel__sub">
최근 추천 결과를 모아서 확인할 있습니다.
수동 추천 결과를 모아서 확인할 있습니다.
</p>
</div>
<div className="lotto-panel__actions">
@@ -684,15 +956,9 @@ export default function Functions() {
{history.length > 5 ? (
<button
className="button ghost small lotto-history-toggle"
onClick={() =>
setHistoryExpanded((prev) => !prev)
}
onClick={() => setHistoryExpanded((prev) => !prev)}
aria-expanded={historyExpanded}
aria-label={
historyExpanded
? '히스토리 접기'
: '히스토리 더보기'
}
aria-label={historyExpanded ? '히스토리 접기' : '히스토리 더보기'}
>
{historyExpanded ? '접기' : '더보기'}
<span

View File

@@ -589,6 +589,151 @@
background: rgba(247, 116, 125, 0.15);
}
/* ── 시뮬레이션 추천 (Best Picks) ────────────────────────────────────────── */
.lotto-chip--active {
background: rgba(151, 201, 170, 0.2);
border-color: rgba(151, 201, 170, 0.5);
color: #97c9aa;
}
.lotto-sim-result {
padding: 12px 14px;
background: rgba(151, 201, 170, 0.08);
border: 1px solid rgba(151, 201, 170, 0.3);
border-radius: 14px;
font-size: 13px;
color: var(--muted);
display: grid;
gap: 4px;
}
.lotto-picks {
display: grid;
gap: 8px;
}
.lotto-pick {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
}
.lotto-pick__rank {
font-size: 11px;
color: var(--muted);
font-weight: 600;
text-align: center;
}
.lotto-pick__content {
display: grid;
gap: 6px;
}
.lotto-pick__score {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.lotto-pick__score-label {
font-size: 11px;
color: var(--muted);
font-weight: 600;
}
.lotto-pick__bar {
height: 5px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.25);
overflow: hidden;
border: 1px solid var(--line);
}
.lotto-pick__bar span {
display: block;
height: 100%;
background: linear-gradient(
90deg,
rgba(151, 201, 170, 0.85),
rgba(133, 165, 216, 0.85)
);
border-radius: 999px;
}
/* ── 통계 분석 (Analysis) ─────────────────────────────────────────────────── */
.lotto-analysis {
display: grid;
gap: 20px;
}
.lotto-analysis__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.lotto-analysis__group {
display: grid;
gap: 10px;
}
.lotto-analysis__label {
margin: 0;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.lotto-analysis__label span {
font-weight: 400;
color: var(--muted);
font-size: 11px;
}
.lotto-analysis__stats {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding-top: 14px;
border-top: 1px solid var(--line);
font-size: 13px;
color: var(--muted);
}
.lotto-analysis__stats strong {
color: var(--text);
margin-left: 4px;
}
/* ── 오버듀 번호 ─────────────────────────────────────────────────────────── */
.lotto-overdue {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.lotto-overdue__gap {
font-size: 10px;
color: var(--muted);
font-weight: 600;
}
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.lotto-header {
grid-template-columns: 1fr;
@@ -597,6 +742,16 @@
.lotto-history__item {
grid-template-columns: 1fr;
}
.lotto-analysis__row {
grid-template-columns: 1fr;
gap: 16px;
}
.lotto-pick {
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 8px;
}
}
@media (max-width: 768px) {

View File

@@ -15,11 +15,11 @@ const Lotto = () => {
</p>
</div>
<div className="lotto-card">
<p className="lotto-card__title">다음 업데이트 아이디어</p>
<p className="lotto-card__title">시뮬레이션 추천 시스템</p>
<ul>
<li>로또 기록을 캘린더 형태로 정리</li>
<li>자주 등장하는 번호 조합 분석</li>
<li>그래프로 추첨 추세 확인</li>
<li>하루 6 몬테카를로 시뮬레이션 자동 실행</li>
<li>20,000 후보를 5가지 통계 기법으로 스코어링</li>
<li>·콜드·오버듀 번호 통계 분석 제공</li>
</ul>
</div>
</header>