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:
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import './Screener.css';
|
import './Screener.css';
|
||||||
|
|
||||||
@@ -17,9 +17,14 @@ import RunHistoryList from './components/RunHistoryList';
|
|||||||
export default function Screener() {
|
export default function Screener() {
|
||||||
const { meta, loading: metaLoading } = useScreenerMeta();
|
const { meta, loading: metaLoading } = useScreenerMeta();
|
||||||
const { settings, dirty, setLocal, save } = useScreenerSettings();
|
const { settings, dirty, setLocal, save } = useScreenerSettings();
|
||||||
const { result, running, runPreview, runSave } = useScreenerRun();
|
const { result, running, previewHistory, runPreview, runSave, selectPreview } = useScreenerRun();
|
||||||
const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
|
const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
|
||||||
|
|
||||||
|
// 비교 모드 — 미리보기 히스토리에서 선택된 항목 ID
|
||||||
|
const [compareId, setCompareId] = useState(null);
|
||||||
|
const compareItem = previewHistory.find((p) => p.id === compareId);
|
||||||
|
const compareResult = compareItem?.result ?? null;
|
||||||
|
|
||||||
const activeResult = selectedRun || result;
|
const activeResult = selectedRun || result;
|
||||||
|
|
||||||
if (metaLoading || !meta || !settings) {
|
if (metaLoading || !meta || !settings) {
|
||||||
@@ -57,13 +62,21 @@ export default function Screener() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="screener-center">
|
<main className="screener-center">
|
||||||
<ResultTable result={activeResult} />
|
<ResultTable result={activeResult} compareWith={compareResult} compareLabel={compareItem ? new Date(compareItem.timestamp).toLocaleTimeString() : null} />
|
||||||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="screener-right">
|
<aside className="screener-right">
|
||||||
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
|
<RunHistoryList
|
||||||
selectedId={selectedRun?.meta?.id} />
|
runs={runs}
|
||||||
|
loading={runs_loading}
|
||||||
|
onSelect={selectRun}
|
||||||
|
selectedId={selectedRun?.meta?.id}
|
||||||
|
previewHistory={previewHistory}
|
||||||
|
onSelectPreview={selectPreview}
|
||||||
|
onSetCompare={setCompareId}
|
||||||
|
compareId={compareId}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const COL_TIPS = {
|
|||||||
stop: '손절가 (원) — 현재가 - 2 × ATR(14, Wilder smoothing). 변동성 기반 손절',
|
stop: '손절가 (원) — 현재가 - 2 × ATR(14, Wilder smoothing). 변동성 기반 손절',
|
||||||
target: '익절가 (원) — 진입가 + (진입가 - 손절가) × R:R 비율 (기본 2.0). 위험 1 대비 보상 2',
|
target: '익절가 (원) — 진입가 + (진입가 - 손절가) × R:R 비율 (기본 2.0). 위험 1 대비 보상 2',
|
||||||
r_pct: '손실 위험 % — (진입가 - 손절가) / 진입가 × 100. 클수록 변동성 큰 종목',
|
r_pct: '손실 위험 % — (진입가 - 손절가) / 진입가 × 100. 클수록 변동성 큰 종목',
|
||||||
|
delta_rank: '비교 대상 대비 순위 변화 — ▲(상승)·▼(하락)·NEW(이번에 새로 진입)·OUT(비교 대상에만 있음)',
|
||||||
|
delta_score: '비교 대상 대비 점수 변화 — 양수면 상승',
|
||||||
};
|
};
|
||||||
|
|
||||||
function Th({ k, children }) {
|
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) {
|
if (!result) {
|
||||||
return (
|
return (
|
||||||
<section className="screener-card">
|
<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 (
|
return (
|
||||||
<section className="screener-card">
|
<section className="screener-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h3 style={{ margin: 0 }}>
|
<h3 style={{ margin: 0 }}>
|
||||||
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
|
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>
|
</h3>
|
||||||
{result.warnings?.length > 0 && (
|
{result.warnings?.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -50,6 +101,7 @@ export default function ResultTable({ result }) {
|
|||||||
|
|
||||||
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
|
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
|
||||||
💡 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다.
|
💡 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다.
|
||||||
|
{hasCompare && ' · 비교 모드 ON — ▲▼NEW/OUT으로 변화 표시'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||||
@@ -59,6 +111,8 @@ export default function ResultTable({ result }) {
|
|||||||
<Th k="rank">#</Th>
|
<Th k="rank">#</Th>
|
||||||
<Th k="name">종목</Th>
|
<Th k="name">종목</Th>
|
||||||
<Th k="total">총점</Th>
|
<Th k="total">총점</Th>
|
||||||
|
{hasCompare && <Th k="delta_rank">순위Δ</Th>}
|
||||||
|
{hasCompare && <Th k="delta_score">점수Δ</Th>}
|
||||||
<Th k="nodes">노드</Th>
|
<Th k="nodes">노드</Th>
|
||||||
<Th k="entry">진입(원)</Th>
|
<Th k="entry">진입(원)</Th>
|
||||||
<Th k="stop">손절(원)</Th>
|
<Th k="stop">손절(원)</Th>
|
||||||
@@ -67,18 +121,41 @@ export default function ResultTable({ result }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(result.results || []).map((r) => (
|
{(result.results || []).map((r) => {
|
||||||
|
const prev = cmpIdx?.get(r.ticker);
|
||||||
|
return (
|
||||||
<tr key={r.ticker}>
|
<tr key={r.ticker}>
|
||||||
<td>{r.rank}</td>
|
<td>{r.rank}</td>
|
||||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></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 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><ScoreChips scores={r.scores} /></td>
|
||||||
<td>{r.entry_price?.toLocaleString?.()}</td>
|
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||||
<td>{r.stop_price?.toLocaleString?.()}</td>
|
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||||
<td>{r.target_price?.toLocaleString?.()}</td>
|
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||||
<td>{r.r_pct?.toFixed?.(1)}</td>
|
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,92 @@
|
|||||||
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
|
function formatTime(iso) {
|
||||||
if (loading) return <section className="screener-card"><p>로딩…</p></section>;
|
if (!iso) return '-';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RunHistoryList({
|
||||||
|
runs, loading, onSelect, selectedId,
|
||||||
|
previewHistory = [], onSelectPreview, selectedPreviewId,
|
||||||
|
onSetCompare, compareId,
|
||||||
|
}) {
|
||||||
|
const hasPreview = previewHistory.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="screener-card">
|
<section className="screener-card">
|
||||||
<h3>최근 실행</h3>
|
<h3>최근 실행</h3>
|
||||||
|
<p style={{ fontSize: 11, color: '#6b7280', marginTop: 0 }}>
|
||||||
|
💡 클릭하면 결과 표에 로드. 우측 "비교"를 누르면 다른 실행과 함께 표시
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{hasPreview && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 4 }}>
|
||||||
|
이번 세션 미리보기 (새로고침 시 사라짐)
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 12 }}>
|
||||||
|
{previewHistory.map((p) => {
|
||||||
|
const isSelected = selectedPreviewId === p.id;
|
||||||
|
const isCompare = compareId === p.id;
|
||||||
|
return (
|
||||||
|
<li key={p.id} style={{
|
||||||
|
padding: '6px 4px',
|
||||||
|
borderBottom: '1px solid #1f2937',
|
||||||
|
background: isSelected ? '#1f2937' : 'transparent',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
<span
|
||||||
|
onClick={() => onSelectPreview?.(p.id)}
|
||||||
|
style={{ cursor: 'pointer', flex: 1, color: isSelected ? '#fbbf24' : '#e5e7eb' }}
|
||||||
|
>
|
||||||
|
{formatTime(p.timestamp)} · {p.mode}
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: 10, color: '#9ca3af' }}>
|
||||||
|
통과 {p.survivors_count ?? '-'} · Top1 {p.top_name ?? '-'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onSetCompare?.(isCompare ? null : p.id)}
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px', fontSize: 10,
|
||||||
|
background: isCompare ? '#fbbf24' : '#374151',
|
||||||
|
color: isCompare ? '#0b0f17' : '#e5e7eb',
|
||||||
|
border: 'none', borderRadius: 4, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
title="이 결과를 비교 대상으로 설정"
|
||||||
|
>
|
||||||
|
{isCompare ? '✓ 비교중' : '비교'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 4 }}>
|
||||||
|
저장된 실행 (자동 잡 + 스냅샷 저장)
|
||||||
|
</div>
|
||||||
|
{loading ? <p style={{ fontSize: 12 }}>로딩…</p> : (
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 13 }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 13 }}>
|
||||||
|
{(runs || []).length === 0 && (
|
||||||
|
<li style={{ fontSize: 11, color: '#6b7280' }}>저장된 실행 없음</li>
|
||||||
|
)}
|
||||||
{(runs || []).map((r) => (
|
{(runs || []).map((r) => (
|
||||||
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
|
<li key={r.id} style={{
|
||||||
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
|
padding: '6px 0', borderBottom: '1px solid #1f2937', cursor: 'pointer',
|
||||||
onClick={() => onSelect(r.id)}>
|
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb',
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect?.(r.id)}>
|
||||||
{r.asof} · {r.mode}
|
{r.asof} · {r.mode}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { runScreener } from '../../../../api';
|
import { runScreener } from '../../../../api';
|
||||||
|
|
||||||
|
const MAX_PREVIEW_HISTORY = 10;
|
||||||
|
|
||||||
export function useScreenerRun() {
|
export function useScreenerRun() {
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
// 미리보기 결과를 세션 메모리에 누적 (새로고침 시 사라짐 — DB 부하 없음)
|
||||||
|
const [previewHistory, setPreviewHistory] = useState([]);
|
||||||
|
|
||||||
async function call(mode, settings) {
|
async function call(mode, settings) {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
@@ -17,15 +21,34 @@ export function useScreenerRun() {
|
|||||||
};
|
};
|
||||||
const r = await runScreener(body);
|
const r = await runScreener(body);
|
||||||
setResult(r);
|
setResult(r);
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
const item = {
|
||||||
|
id: `${mode}-${stamp}`,
|
||||||
|
mode,
|
||||||
|
timestamp: stamp,
|
||||||
|
asof: r?.asof,
|
||||||
|
survivors_count: r?.survivors_count,
|
||||||
|
top_ticker: r?.results?.[0]?.ticker,
|
||||||
|
top_name: r?.results?.[0]?.name,
|
||||||
|
top_score: r?.results?.[0]?.total_score,
|
||||||
|
result: r,
|
||||||
|
};
|
||||||
|
setPreviewHistory((prev) => [item, ...prev].slice(0, MAX_PREVIEW_HISTORY));
|
||||||
return r;
|
return r;
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPreview(id) {
|
||||||
|
const item = previewHistory.find((p) => p.id === id);
|
||||||
|
if (item) setResult(item.result);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result, running,
|
result, running, previewHistory,
|
||||||
runPreview: (s) => call('preview', s),
|
runPreview: (s) => call('preview', s),
|
||||||
runSave: (s) => call('manual_save', s),
|
runSave: (s) => call('manual_save', s),
|
||||||
|
selectPreview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user