From 628a47b2ececec62a68eabd4b0b61600e837f0da Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 23 Feb 2026 22:27:21 +0900 Subject: [PATCH] =?UTF-8?q?lotto=20=EC=B6=94=EC=B2=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.js | 19 ++ src/pages/lotto/Functions.jsx | 612 ++++++++++++++++++++++++---------- src/pages/lotto/Lotto.css | 155 +++++++++ src/pages/lotto/Lotto.jsx | 8 +- 4 files changed, 617 insertions(+), 177 deletions(-) diff --git a/src/api.js b/src/api.js index 3c79907..ab25655 100644 --- a/src/api.js +++ b/src/api.js @@ -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) { diff --git a/src/pages/lotto/Functions.jsx b/src/pages/lotto/Functions.jsx index 84bbb86..7178d4f 100644 --- a/src/pages/lotto/Functions.jsx +++ b/src/pages/lotto/Functions.jsx @@ -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 (
{error ? ( @@ -410,7 +474,9 @@ export default function Functions() {
) : null} + {/* ── 상단 2열 그리드: 최신 회차 + 시뮬레이션 추천 ── */}
+ {/* Latest Draw */}
@@ -462,176 +528,214 @@ export default function Functions() { )}
+ {/* Simulation Picks */}
-

Recommendation

-

추천 생성

+

Simulation Picks

+

시뮬레이션 추천

- 파라미터를 조정해 다른 추천 전략을 만들 수 있습니다. + 하루 6회 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.

- {loading.recommend ? 계산 중 : null} -
-
- -
- {presets.map((preset) => ( + {loading.bestPicks ? 로딩 중 : null} + {simulating ? ( + 분석 중 + ) : null} + - ))} -
- -
- - - - - -
- - - - {result ? ( -
-
-
-

추천 ID #{result.id}

-

- 기준 회차 {result.based_on_latest_draw ?? '-'} -

-
- -
- {result.numbers ? : null} - {historyMetrics ? ( -
- -
- ) : null} - {Array.isArray(result.items) && result.items.length ? ( -
- 추천 후보 보기 -
- {result.items.map((item, idx) => ( -
-
-
-

- 후보 #{item.id ?? idx + 1} -

-

- 기준 회차 {item.based_on_draw ?? '-'} -

-
- -
- - {item.metrics ? ( - - ) : null} -
- ))} -
-
- ) : null} - {result.explain ? ( -
- 설명 보기 -
{JSON.stringify(result.explain, null, 2)}
-
- ) : null}
+
+ + {simResult ? ( +
+

+ 완료: {simResult.total_generated?.toLocaleString()}개 후보 →{' '} + 상위 {simResult.best_n_saved}개 저장 +

+

+ 최고 점수 {((simResult.best_score ?? 0) * 100).toFixed(1)}% /{' '} + 평균 {((simResult.avg_score ?? 0) * 100).toFixed(1)}% +

+
+ ) : null} + + {bestPicks.length === 0 ? ( +

+ {loading.bestPicks + ? '불러오는 중...' + : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."} +

) : ( -

아직 추천 결과가 없습니다.

+ <> +
+ {visibleBestPicks.map((pick) => ( +
+ #{pick.rank} +
+ +
+ + {((pick.score_total ?? 0) * 100).toFixed(1)}% + +
+ +
+
+
+ +
+ ))} +
+ + {bestPicks.length > BEST_PICKS_DEFAULT_SHOW ? ( + + ) : null} + +

+ 갱신: {fmtKST(bestPicks[0]?.created_at) || '-'} + {bestPicks[0]?.based_on_draw + ? ` · ${bestPicks[0].based_on_draw}회 기준` + : ''} +

+ )} - + {/* ── 통계 분석 ── */} +
+
+
+

Analysis

+

통계 분석

+

+ 빈도, Z-score, 갭 분석으로 번호를 분류합니다. +

+
+
+ {loading.analysis ? 로딩 중 : null} + +
+
+ + {analysis ? ( +
+
+
+

+ 🔥 핫 번호 + 출현 빈도 상위 10 +

+
+ {(analysis.hot_numbers ?? []).map((n) => ( + + ))} +
+
+
+

+ 🧊 콜드 번호 + 출현 빈도 하위 10 +

+
+ {(analysis.cold_numbers ?? []).map((n) => ( + + ))} +
+
+
+

+ ⏰ 오버듀 번호 + 오래 안 나온 번호 (회차 수) +

+
+ {(analysis.overdue_numbers ?? []).map((n) => { + const stat = (analysis.number_stats ?? []).find( + (s) => s.number === n + ); + return ( +
+ + + {stat?.gap ?? '-'}회 + +
+ ); + })} +
+
+
+
+ + 역대 합계 평균 {analysis.mean_sum} + + + 표준편차 ±{analysis.std_sum} + + + 분석 회차 {analysis.total_draws?.toLocaleString()} + + + 홀수 3:짝수 3 확률{' '} + + {analysis.odd_distribution?.['3'] + ? `${analysis.odd_distribution['3']}%` + : '-'} + + +
+
+ ) : ( +

+ {loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'} +

+ )} +
+ + {/* ── 전체 번호 분포 ── */}
@@ -646,9 +750,7 @@ export default function Functions() { 로딩 중 ) : null} {stats?.total_draws ? ( - - {stats.total_draws}회차 - + {stats.total_draws}회차 ) : null}
+ {/* ── 수동 추천 ── */} +
+
+
+

Manual Recommendation

+

수동 추천

+

+ 파라미터를 직접 조정해 번호를 추천받을 수 있습니다. +

+
+
+ {loading.recommend ? ( + 계산 중 + ) : null} +
+
+ +
+ {presets.map((preset) => ( + + ))} +
+ +
+ + + + + +
+ + + + {result ? ( +
+
+
+

추천 ID #{result.id}

+

+ 기준 회차 {result.based_on_latest_draw ?? '-'} +

+
+ +
+ {result.numbers ? : null} + {historyMetrics ? ( +
+ +
+ ) : null} + {Array.isArray(result.items) && result.items.length ? ( +
+ 추천 후보 보기 +
+ {result.items.map((item, idx) => ( +
+
+
+

+ 후보 #{item.id ?? idx + 1} +

+

+ 기준 회차 {item.based_on_draw ?? '-'} +

+
+ +
+ + {item.metrics ? ( + + ) : null} +
+ ))} +
+
+ ) : null} + {result.explain ? ( +
+ 설명 보기 +
{JSON.stringify(result.explain, null, 2)}
+
+ ) : null} +
+ ) : ( +

아직 추천 결과가 없습니다.

+ )} +
+ + {/* ── 추천 히스토리 ── */}

History

추천 히스토리

- 최근 추천 결과를 모아서 확인할 수 있습니다. + 수동 추천 결과를 모아서 확인할 수 있습니다.

@@ -684,15 +956,9 @@ export default function Functions() { {history.length > 5 ? (
-

다음 업데이트 아이디어

+

시뮬레이션 추천 시스템

    -
  • 로또 기록을 캘린더 형태로 정리
  • -
  • 자주 등장하는 번호 조합 분석
  • -
  • 그래프로 추첨 추세 확인
  • +
  • 하루 6회 몬테카를로 시뮬레이션 자동 실행
  • +
  • 20,000개 후보를 5가지 통계 기법으로 스코어링
  • +
  • 핫·콜드·오버듀 번호 통계 분석 제공