diff --git a/src/api.js b/src/api.js index a85affd..02ba9bf 100644 --- a/src/api.js +++ b/src/api.js @@ -740,6 +740,11 @@ export async function triggerEvolverEvaluate() { return r.json(); } +// --- Lotto Backtest --- +export const lottoBacktestTrackRecord = () => apiGet('/api/lotto/backtest/track-record'); +export const lottoBacktestCalibration = (weeks=52) => apiGet(`/api/lotto/backtest/calibration?weeks=${weeks}`); +export const lottoBacktestReview = (drawNo) => apiGet(`/api/lotto/backtest/review/${drawNo}`); + // --- Tarot Lab --- export function tarotInterpret(body) { diff --git a/src/pages/lotto/Evolver.css b/src/pages/lotto/Evolver.css index fed3069..2fec4ff 100644 --- a/src/pages/lotto/Evolver.css +++ b/src/pages/lotto/Evolver.css @@ -58,6 +58,9 @@ .winner-card .winner-meta strong { color: #f1f5f9; font-weight: 600; } .winner-card .winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; } +/* Backtest — WinnerAnalysisCard chart wrapper (standalone, not inside .winner-card) */ +.backtest-winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; } + /* TrialsGrid */ .trials-grid .grid { display: grid; grid-template-columns: repeat(6, 1fr); @@ -186,6 +189,47 @@ font-size: 0.75rem; } +/* Backtest — TrackRecordCard */ +.backtest-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-bottom: 10px; +} +.backtest-table th { + text-align: left; + color: #94a3b8; + font-weight: 500; + padding: 6px 8px; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +.backtest-table td { + padding: 6px 8px; + color: #cbd5e1; + border-bottom: 1px solid rgba(255,255,255,0.04); +} +.backtest-table tr:last-child td { border-bottom: none; } + +/* Backtest — shared note */ +.backtest-note { + margin: 8px 0 0; + color: #64748b; + font-size: 0.8rem; + line-height: 1.4; +} +.backtest-note strong { color: #cbd5e1; } + +/* Backtest — section divider */ +.backtest-section-header { + margin: 8px 0 4px; + color: #94a3b8; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.03em; + border-top: 1px solid rgba(255,255,255,0.06); + padding-top: 14px; +} + @media (max-width: 640px) { .trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; } .base-diff .diff-grid { grid-template-columns: repeat(3, 1fr); } diff --git a/src/pages/lotto/evolver/CalibrationChart.jsx b/src/pages/lotto/evolver/CalibrationChart.jsx new file mode 100644 index 0000000..52d79f2 --- /dev/null +++ b/src/pages/lotto/evolver/CalibrationChart.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, +} from 'recharts'; + +export default function CalibrationChart({ history }) { + if (!history || history.length === 0) { + return ( +
+

당첨조합 캘리브레이션 추세

+

캘리브레이션 데이터가 없습니다.

+
+ ); + } + + // history는 DESC 순서로 오므로 역순해서 오름차순 x축 + const data = [...history].reverse().map((h) => ({ + draw: h.draw_no, + score: h.score_total != null ? +h.score_total.toFixed(3) : null, + pct: h.percentile != null ? +h.percentile.toFixed(3) : null, + })); + + return ( +
+

당첨조합 캘리브레이션 추세 (최근 {history.length}회차)

+ + + + + + + + + + + +
+ ); +} diff --git a/src/pages/lotto/evolver/TrackRecordCard.jsx b/src/pages/lotto/evolver/TrackRecordCard.jsx new file mode 100644 index 0000000..72600c7 --- /dev/null +++ b/src/pages/lotto/evolver/TrackRecordCard.jsx @@ -0,0 +1,56 @@ +import React from 'react'; + +const STRATEGY_ORDER = ['engine_w', 'random_null', 'coverage']; +const STRATEGY_LABEL = { engine_w: '엔진', random_null: '무작위', coverage: '커버리지' }; + +export default function TrackRecordCard({ byStrategy }) { + if (!byStrategy) return null; + + const rows = STRATEGY_ORDER.filter((s) => byStrategy[s]); + + return ( +
+

누적 성적표

+ {rows.length === 0 ? ( +

아직 백테스트 데이터가 없습니다.

+ ) : ( + <> + + + + + + + + + + + + + + + {rows.map((s) => { + const a = byStrategy[s]; + return ( + + + + + + + + + + + ); + })} + +
전략누적 장수회차수1등2등3등4등5등
{STRATEGY_LABEL[s] || s}{(a.n_tickets || 0).toLocaleString()}{a.draws || 0}{a['1st'] || 0}{a['2nd'] || 0}{a['3rd'] || 0}{a['4th'] || 0}{a['5th'] || 0}
+

+ 엔진이 무작위를 넘지 못하면 분석에 통계적 우위가 없다는 정직한 증거입니다. +

+ + )} +
+ ); +} diff --git a/src/pages/lotto/evolver/WinnerAnalysisCard.jsx b/src/pages/lotto/evolver/WinnerAnalysisCard.jsx new file mode 100644 index 0000000..af860b3 --- /dev/null +++ b/src/pages/lotto/evolver/WinnerAnalysisCard.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { + RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, + Radar, ResponsiveContainer, +} from 'recharts'; + +export default function WinnerAnalysisCard({ analysis }) { + if (!analysis) return null; + + const data = [ + { k: '빈도', v: analysis.score_frequency ?? 0 }, + { k: '지문', v: analysis.score_fingerprint ?? 0 }, + { k: '갭', v: analysis.score_gap ?? 0 }, + { k: '공동출현', v: analysis.score_cooccur ?? 0 }, + { k: '다양성', v: analysis.score_diversity ?? 0 }, + ]; + + const pct = analysis.percentile != null + ? `${(analysis.percentile * 100).toFixed(0)}%` + : '—'; + + return ( +
+

+ 이번 당첨조합 분석치 + 무작위 상위 {pct} +

+
+ + + + + + + + +
+

+ 종합 점수: {(analysis.score_total ?? 0).toFixed(3)} +

+
+ ); +} diff --git a/src/pages/lotto/tabs/EvolverTab.jsx b/src/pages/lotto/tabs/EvolverTab.jsx index 4699268..6d54418 100644 --- a/src/pages/lotto/tabs/EvolverTab.jsx +++ b/src/pages/lotto/tabs/EvolverTab.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import '../Evolver.css'; import { useEvolverApi } from '../evolver/useEvolverApi'; import WinnerCard from '../evolver/WinnerCard'; @@ -7,10 +7,40 @@ import BaseDiff from '../evolver/BaseDiff'; import BaseHistory from '../evolver/BaseHistory'; import LottoActivityTimeline from '../evolver/LottoActivityTimeline'; import EvolverActions from '../evolver/EvolverActions'; +import TrackRecordCard from '../evolver/TrackRecordCard'; +import CalibrationChart from '../evolver/CalibrationChart'; +import WinnerAnalysisCard from '../evolver/WinnerAnalysisCard'; +import { getLatest, lottoBacktestTrackRecord, lottoBacktestCalibration, lottoBacktestReview } from '../../../api'; export default function EvolverTab() { const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 }); + const [trackRecord, setTrackRecord] = useState(null); + const [calibHistory, setCalibHistory] = useState([]); + const [winnerAnalysis, setWinnerAnalysis] = useState(null); + + useEffect(() => { + (async () => { + try { + const [tr, cal] = await Promise.all([ + lottoBacktestTrackRecord(), + lottoBacktestCalibration(52), + ]); + setTrackRecord(tr); + setCalibHistory(cal.history || []); + } catch (_) { /* 백엔드 미준비 시 graceful skip */ } + + try { + const latest = await getLatest(); + const drawNo = latest?.drawNo || latest?.drw_no || latest?.draw_no; + if (drawNo) { + const review = await lottoBacktestReview(drawNo); + setWinnerAnalysis(review.winner_analysis || null); + } + } catch (_) { /* 아직 데이터 없으면 null 유지 */ } + })(); + }, []); + if (loading) return

로딩 중...

; if (error) return

에러: {String(error)}

; @@ -73,6 +103,16 @@ export default function EvolverTab() { )} + + {/* 백테스트 성적표 · 캘리브레이션 · 당첨조합 분석 */} + {(winnerAnalysis || trackRecord || calibHistory.length > 0) && ( + <> +

백테스트 & 캘리브레이션

+ + + + + )} ); }