feat(stock): 미리보기 결과 세션 히스토리 + 결과 비교 컬럼

- useScreenerRun: 실행 시마다 previewHistory에 누적 (최대 10, 메모리만 —
  새로고침 시 사라짐, DB 부하 없음). top_ticker/score 요약 포함.
- RunHistoryList: '이번 세션 미리보기'와 '저장된 실행' 두 섹션으로 분리.
  미리보기 항목은 클릭으로 결과 표 로드 + '비교' 버튼으로 비교 대상 지정.
- ResultTable: compareWith prop으로 비교 모드. 순위Δ(▲▼NEW)·점수Δ
  컬럼 추가, 이번엔 빠진 종목은 'OUT'으로 별도 섹션에 회색 표시.
- 헤더에 'vs HH:MM:SS (통과 X)' 라벨로 비교 대상 명시.
This commit is contained in:
2026-05-13 08:16:21 +09:00
parent 6fd70dd802
commit 55d2adeaf5
4 changed files with 218 additions and 30 deletions

View File

@@ -9,6 +9,8 @@ const COL_TIPS = {
stop: '손절가 (원) — 현재가 - 2 × ATR(14, Wilder smoothing). 변동성 기반 손절',
target: '익절가 (원) — 진입가 + (진입가 - 손절가) × R:R 비율 (기본 2.0). 위험 1 대비 보상 2',
r_pct: '손실 위험 % — (진입가 - 손절가) / 진입가 × 100. 클수록 변동성 큰 종목',
delta_rank: '비교 대상 대비 순위 변화 — ▲(상승)·▼(하락)·NEW(이번에 새로 진입)·OUT(비교 대상에만 있음)',
delta_score: '비교 대상 대비 점수 변화 — 양수면 상승',
};
function Th({ k, children }) {
@@ -20,7 +22,42 @@ function Th({ k, children }) {
);
}
export default function ResultTable({ result }) {
function buildCompareIndex(compareWith) {
if (!compareWith?.results) return null;
const idx = new Map();
for (const r of compareWith.results) {
idx.set(r.ticker, r);
}
return idx;
}
function DeltaRank({ current, prev }) {
if (!prev) {
return <span style={{ color: '#22c55e', fontSize: 11, fontWeight: 600 }}>NEW</span>;
}
const diff = prev.rank - current.rank; // 양수: 순위 상승
if (diff === 0) return <span style={{ color: '#9ca3af', fontSize: 11 }}></span>;
const up = diff > 0;
return (
<span style={{ color: up ? '#22c55e' : '#ef4444', fontSize: 11 }}>
{up ? '▲' : '▼'} {Math.abs(diff)}
</span>
);
}
function DeltaScore({ current, prev }) {
if (!prev) return <span style={{ color: '#9ca3af', fontSize: 11 }}>-</span>;
const d = (current.total_score ?? 0) - (prev.total_score ?? 0);
if (Math.abs(d) < 0.1) return <span style={{ color: '#9ca3af', fontSize: 11 }}></span>;
const up = d > 0;
return (
<span style={{ color: up ? '#22c55e' : '#ef4444', fontSize: 11 }}>
{up ? '+' : ''}{d.toFixed(1)}
</span>
);
}
export default function ResultTable({ result, compareWith, compareLabel }) {
if (!result) {
return (
<section className="screener-card">
@@ -32,11 +69,25 @@ export default function ResultTable({ result }) {
);
}
const cmpIdx = buildCompareIndex(compareWith);
const hasCompare = !!cmpIdx;
// 비교 모드에서, 비교 대상에만 있는 ticker도 추가 (OUT 표시)
const currentTickers = new Set((result.results || []).map((r) => r.ticker));
const onlyInCompare = hasCompare
? (compareWith.results || []).filter((r) => !currentTickers.has(r.ticker))
: [];
return (
<section className="screener-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
{hasCompare && (
<span style={{ marginLeft: 8, fontSize: 12, color: '#fbbf24' }}>
vs {compareLabel ?? '비교 대상'} (통과 {compareWith.survivors_count})
</span>
)}
</h3>
{result.warnings?.length > 0 && (
<div style={{
@@ -50,6 +101,7 @@ export default function ResultTable({ result }) {
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
💡 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다.
{hasCompare && ' · 비교 모드 ON — ▲▼NEW/OUT으로 변화 표시'}
</p>
<div style={{ overflowX: 'auto', marginTop: 12 }}>
@@ -59,6 +111,8 @@ export default function ResultTable({ result }) {
<Th k="rank">#</Th>
<Th k="name">종목</Th>
<Th k="total">총점</Th>
{hasCompare && <Th k="delta_rank">순위Δ</Th>}
{hasCompare && <Th k="delta_score">점수Δ</Th>}
<Th k="nodes">노드</Th>
<Th k="entry">진입()</Th>
<Th k="stop">손절()</Th>
@@ -67,18 +121,41 @@ export default function ResultTable({ result }) {
</tr>
</thead>
<tbody>
{(result.results || []).map((r) => (
<tr key={r.ticker}>
<td>{r.rank}</td>
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
<td><ScoreChips scores={r.scores} /></td>
<td>{r.entry_price?.toLocaleString?.()}</td>
<td>{r.stop_price?.toLocaleString?.()}</td>
<td>{r.target_price?.toLocaleString?.()}</td>
<td>{r.r_pct?.toFixed?.(1)}</td>
</tr>
))}
{(result.results || []).map((r) => {
const prev = cmpIdx?.get(r.ticker);
return (
<tr key={r.ticker}>
<td>{r.rank}</td>
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
{hasCompare && <td><DeltaRank current={r} prev={prev} /></td>}
{hasCompare && <td><DeltaScore current={r} prev={prev} /></td>}
<td><ScoreChips scores={r.scores} /></td>
<td>{r.entry_price?.toLocaleString?.()}</td>
<td>{r.stop_price?.toLocaleString?.()}</td>
<td>{r.target_price?.toLocaleString?.()}</td>
<td>{r.r_pct?.toFixed?.(1)}</td>
</tr>
);
})}
{hasCompare && onlyInCompare.length > 0 && (
<>
<tr><td colSpan={10} style={{ fontSize: 11, color: '#6b7280', padding: '12px 8px 4px' }}>
이번엔 빠진 종목 (비교 대상에만 존재)
</td></tr>
{onlyInCompare.map((r) => (
<tr key={`out-${r.ticker}`} style={{ opacity: 0.55 }}>
<td></td>
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
<td style={{ fontWeight: 500 }}>{r.total_score?.toFixed(1)}</td>
<td><span style={{ color: '#ef4444', fontSize: 11, fontWeight: 600 }}>OUT</span></td>
<td></td>
<td><ScoreChips scores={r.scores} /></td>
<td colSpan={4}></td>
</tr>
))}
</>
)}
</tbody>
</table>
</div>