diff --git a/src/pages/lotto/Evolver.css b/src/pages/lotto/Evolver.css new file mode 100644 index 0000000..f370eee --- /dev/null +++ b/src/pages/lotto/Evolver.css @@ -0,0 +1,51 @@ +.evolver { max-width: 1100px; margin: 0 auto; padding: 24px 16px; } +.evolver-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 24px; gap: 16px; } +.evolver-kicker { letter-spacing: 0.12em; color: #6b7280; font-size: 0.75rem; margin: 0 0 4px; } +.evolver-header h1 { margin: 0 0 8px; font-size: 2rem; } +.evolver-sub { color: #6b7280; margin: 0; } +.refresh-btn { padding: 8px 14px; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; cursor: pointer; } + +.evolver-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; margin-bottom: 16px; } +.evolver-card.empty .muted { color: #9ca3af; } +.evolver-card h2 { margin: 0 0 12px; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.evolver-card .badge { background: #ecfdf5; color: #065f46; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: normal; } + +.winner-card .winner-meta { display: flex; gap: 16px; flex-wrap: wrap; color: #6b7280; font-size: 0.9rem; margin-bottom: 12px; } +.winner-card .winner-meta strong { color: #111827; } + +.trials-grid .grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; height: 140px; align-items: end; } +.trial-cell { border: none; background: #f9fafb; border-radius: 6px; padding: 8px 4px; display: flex; flex-direction: column; align-items: center; justify-content: end; cursor: pointer; height: 100%; } +.trial-cell.winner { background: #ecfdf5; } +.trial-cell .bar { width: 80%; background: #34d399; border-radius: 3px 3px 0 0; min-height: 4px; } +.trial-cell.winner .bar { background: #059669; } +.trial-cell .label { font-size: 0.85rem; margin-top: 6px; } +.trial-cell .max-correct { font-size: 0.7rem; color: #6b7280; } +.trial-detail { margin-top: 16px; padding: 12px; background: #f9fafb; border-radius: 6px; } +.trial-detail ul { margin: 8px 0 0; padding-left: 18px; } + +.base-diff .diff-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; } +.metric-card { padding: 12px; background: #f9fafb; border-radius: 8px; text-align: center; } +.metric-card .metric-name { color: #6b7280; font-size: 0.75rem; text-transform: uppercase; } +.metric-card .metric-values { margin: 6px 0; font-size: 0.85rem; } +.metric-card .metric-diff { font-weight: bold; } +.metric-card.up .metric-diff, .metric-card.up-big .metric-diff { color: #059669; } +.metric-card.down .metric-diff, .metric-card.down-big .metric-diff { color: #dc2626; } +.metric-card.eq .metric-diff { color: #9ca3af; } + +.activity-card .activity-list { list-style: none; padding: 0; margin: 0; } +.activity-item { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; padding: 8px 0; border-bottom: 1px solid #f3f4f6; } +.activity-item .ts { color: #9ca3af; font-size: 0.75rem; white-space: nowrap; } +.activity-item .status.ok { color: #059669; } +.activity-item .status.err { color: #dc2626; } +.activity-item .detail { color: #6b7280; font-size: 0.85rem; } + +.actions-card .action-buttons { display: flex; gap: 8px; flex-wrap: wrap; } +.actions-card button { padding: 8px 14px; background: #1f2937; color: #fff; border: none; border-radius: 6px; cursor: pointer; } +.actions-card button:disabled { opacity: 0.5; cursor: wait; } +.action-output { background: #1f2937; color: #d1d5db; padding: 12px; border-radius: 6px; margin-top: 12px; max-height: 200px; overflow: auto; font-size: 0.8rem; } + +@media (max-width: 640px) { + .trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; } + .base-diff .diff-grid { grid-template-columns: repeat(2, 1fr); } + .evolver-header { flex-direction: column; } +} diff --git a/src/pages/lotto/Evolver.jsx b/src/pages/lotto/Evolver.jsx new file mode 100644 index 0000000..47ca8ba --- /dev/null +++ b/src/pages/lotto/Evolver.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import './Evolver.css'; +import { useEvolverApi } from './evolver/useEvolverApi'; +import WinnerCard from './evolver/WinnerCard'; +import TrialsGrid from './evolver/TrialsGrid'; +import BaseDiff from './evolver/BaseDiff'; +import BaseHistory from './evolver/BaseHistory'; +import LottoActivityTimeline from './evolver/LottoActivityTimeline'; +import EvolverActions from './evolver/EvolverActions'; + +export default function Evolver() { + const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 }); + + if (loading) return

로딩 중...

; + if (error) return

에러: {String(error)}

; + + const latestBase = (history.items || [])[0]; + const previousBase = (history.items || [])[1]?.weight || status?.current_base || [0.2, 0.2, 0.2, 0.2, 0.2]; + const newBase = latestBase?.weight || status?.current_base; + + const trials = status?.trials || []; + const winnerTrialId = latestBase?.source_trial_id; + const winnerTrial = trials.find(t => t.id === winnerTrialId); + const winnerInfo = winnerTrial ? { + day_of_week: winnerTrial.day_of_week, + weight: winnerTrial.weight, + avg_score: latestBase?.winner_score, + max_correct: latestBase?.winner_max_correct, + n_picks: (winnerTrial.picks || []).length, + } : null; + + const perDay = trials.map(t => ({ + day_of_week: t.day_of_week, + trial_id: t.id, + avg_score: (t.picks || []).reduce((s, p) => s + (p.meta_score || 0), 0) / Math.max(1, (t.picks || []).length), + max_correct: Math.max(0, ...(t.picks || []).map(p => p.correct || 0)), + })); + + const hasBase = (history.items || []).length > 0; + + return ( +
+
+
+

Lotto · Weight Evolver

+

자율 학습 루프

+

+ 매주 6가지 가중치를 시도해서 best 조합을 다음주 base로 학습합니다. + {status?.latest_draw && ` 마지막 회차: ${status.latest_draw}회.`} +

+
+ +
+ + {!hasBase ? ( +
+

아직 학습 시작 전

+

다음 월요일 09:00에 자동 시작 또는 수동 트리거 사용.

+ +
+ ) : ( + <> + + + + + + + + )} +
+ ); +} diff --git a/src/pages/lotto/evolver/EvolverActions.jsx b/src/pages/lotto/evolver/EvolverActions.jsx new file mode 100644 index 0000000..b169c27 --- /dev/null +++ b/src/pages/lotto/evolver/EvolverActions.jsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import { triggerEvolverGenerate, triggerEvolverEvaluate } from '../../../api'; + +export default function EvolverActions({ onChange }) { + const [busy, setBusy] = useState(null); + const [out, setOut] = useState(null); + + async function run(kind) { + setBusy(kind); + setOut(null); + try { + const fn = kind === 'generate' ? triggerEvolverGenerate : triggerEvolverEvaluate; + const res = await fn(); + setOut(res); + onChange && onChange(); + } catch (e) { + setOut({ error: String(e) }); + } finally { + setBusy(null); + } + } + + return ( +
+

수동 트리거 (dev)

+
+ + +
+ {out &&
{JSON.stringify(out, null, 2)}
} +
+ ); +} diff --git a/src/pages/lotto/evolver/LottoActivityTimeline.jsx b/src/pages/lotto/evolver/LottoActivityTimeline.jsx new file mode 100644 index 0000000..5750a9b --- /dev/null +++ b/src/pages/lotto/evolver/LottoActivityTimeline.jsx @@ -0,0 +1,91 @@ +import React from 'react'; + +const ICONS = { + curate_weekly: '📋', + signal_check: '🔍', + daily_digest: '📊', + weekly_evolution_report: '🧬', + evolver_generate: '🌱', + evolver_apply: '🎲', +}; + +const STATUS_CLS = { + succeeded: 'ok', + failed: 'err', + working: 'pending', + pending: 'pending', +}; + +function formatTaskDetail(t) { + const r = t.result_data || {}; + switch (t.task_type) { + case 'signal_check': return `${r.source} → ${r.overall_fire} (${r.n_results} results)`; + case 'daily_digest': return `평가 ${r.evaluated} / 발화 ${r.fired}`; + case 'weekly_evolution_report': return `draw=${r.draw_no} reason=${r.update_reason}`; + case 'evolver_apply': return `${r.n_picks}세트 추출`; + case 'evolver_generate': return `${r.trials_count} trials 생성`; + case 'curate_weekly': return `draw=${r.draw_no || '?'} conf=${r.confidence || '?'}`; + default: return ''; + } +} + +function renderItem(item) { + const ts = (item.ts || '').replace('T', ' ').slice(0, 19); + if (item.kind === 'task') { + const t = item.payload; + const icon = ICONS[t.task_type] || '⚙️'; + const cls = STATUS_CLS[t.status] || ''; + const detail = formatTaskDetail(t); + return ( +
  • + {icon} +
    +
    {t.task_type} · {t.status}
    + {detail &&
    {detail}
    } +
    + {ts} +
  • + ); + } + if (item.kind === 'log') { + const l = item.payload; + return ( +
  • + {l.level === 'error' ? '❌' : l.level === 'warning' ? '⚠️' : '·'} +
    {l.message}
    + {ts} +
  • + ); + } + if (item.kind === 'evolver') { + const e = item.payload; + return ( +
  • + ⚖️ +
    +
    weight_evolver_eval (lotto-lab)
    +
    reason={e.update_reason} winner_max={e.winner_max_correct}
    +
    + {ts} +
  • + ); + } + return null; +} + +export default function LottoActivityTimeline({ activity = [], days = 7 }) { + if (!activity || activity.length === 0) { + return ( +
    +

    최근 활동

    +

    지난 {days}일 활동 없음.

    +
    + ); + } + return ( +
    +

    최근 {days}일 에이전트 활동 ({activity.length})

    + +
    + ); +} diff --git a/src/routes.jsx b/src/routes.jsx index ff029d1..74d1cab 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -16,6 +16,7 @@ import { const Home = lazy(() => import('./pages/home/Home')); const Blog = lazy(() => import('./pages/blog/Blog')); const Lotto = lazy(() => import('./pages/lotto/Lotto')); +const Evolver = lazy(() => import('./pages/lotto/Evolver')); const Travel = lazy(() => import('./pages/travel/Travel')); const Stock = lazy(() => import('./pages/stock/Stock')); const StockTrade = lazy(() => import('./pages/stock/StockTrade')); @@ -153,6 +154,10 @@ export const appRoutes = [ path: 'lotto', element: , }, + { + path: 'lotto/evolver', + element: , + }, { path: 'stock', element: ,