From 55d2adeaf5cc6844eeb5217a160a015447b145a9 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 13 May 2026 08:16:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20=EC=84=B8=EC=85=98=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20+=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20=EC=BB=AC=EB=9F=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useScreenerRun: 실행 시마다 previewHistory에 누적 (최대 10, 메모리만 — 새로고침 시 사라짐, DB 부하 없음). top_ticker/score 요약 포함. - RunHistoryList: '이번 세션 미리보기'와 '저장된 실행' 두 섹션으로 분리. 미리보기 항목은 클릭으로 결과 표 로드 + '비교' 버튼으로 비교 대상 지정. - ResultTable: compareWith prop으로 비교 모드. 순위Δ(▲▼NEW)·점수Δ 컬럼 추가, 이번엔 빠진 종목은 'OUT'으로 별도 섹션에 회색 표시. - 헤더에 'vs HH:MM:SS (통과 X)' 라벨로 비교 대상 명시. --- src/pages/stock/screener/Screener.jsx | 23 +++- .../stock/screener/components/ResultTable.jsx | 103 +++++++++++++++--- .../screener/components/RunHistoryList.jsx | 97 +++++++++++++++-- .../stock/screener/hooks/useScreenerRun.js | 25 ++++- 4 files changed, 218 insertions(+), 30 deletions(-) diff --git a/src/pages/stock/screener/Screener.jsx b/src/pages/stock/screener/Screener.jsx index c06b4ca..41995ec 100644 --- a/src/pages/stock/screener/Screener.jsx +++ b/src/pages/stock/screener/Screener.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import './Screener.css'; @@ -17,9 +17,14 @@ import RunHistoryList from './components/RunHistoryList'; export default function Screener() { const { meta, loading: metaLoading } = useScreenerMeta(); 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(); + // 비교 모드 — 미리보기 히스토리에서 선택된 항목 ID + const [compareId, setCompareId] = useState(null); + const compareItem = previewHistory.find((p) => p.id === compareId); + const compareResult = compareItem?.result ?? null; + const activeResult = selectedRun || result; if (metaLoading || !meta || !settings) { @@ -57,13 +62,21 @@ export default function Screener() {
- +
diff --git a/src/pages/stock/screener/components/ResultTable.jsx b/src/pages/stock/screener/components/ResultTable.jsx index cf2514c..94d0162 100644 --- a/src/pages/stock/screener/components/ResultTable.jsx +++ b/src/pages/stock/screener/components/ResultTable.jsx @@ -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 NEW; + } + const diff = prev.rank - current.rank; // 양수: 순위 상승 + if (diff === 0) return ; + const up = diff > 0; + return ( + + {up ? '▲' : '▼'} {Math.abs(diff)} + + ); +} + +function DeltaScore({ current, prev }) { + if (!prev) return -; + const d = (current.total_score ?? 0) - (prev.total_score ?? 0); + if (Math.abs(d) < 0.1) return ; + const up = d > 0; + return ( + + {up ? '+' : ''}{d.toFixed(1)} + + ); +} + +export default function ResultTable({ result, compareWith, compareLabel }) { if (!result) { return (
@@ -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 (

Top {result.top_n} · 통과 {result.survivors_count} · {result.asof} + {hasCompare && ( + + vs {compareLabel ?? '비교 대상'} (통과 {compareWith.survivors_count}) + + )}

{result.warnings?.length > 0 && (
💡 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다. + {hasCompare && ' · 비교 모드 ON — ▲▼NEW/OUT으로 변화 표시'}

@@ -59,6 +111,8 @@ export default function ResultTable({ result }) { # 종목 총점 + {hasCompare && 순위Δ} + {hasCompare && 점수Δ} 노드 진입(원) 손절(원) @@ -67,18 +121,41 @@ export default function ResultTable({ result }) { - {(result.results || []).map((r) => ( - - {r.rank} - {r.name}
{r.ticker} - {r.total_score?.toFixed(1)} - - {r.entry_price?.toLocaleString?.()} - {r.stop_price?.toLocaleString?.()} - {r.target_price?.toLocaleString?.()} - {r.r_pct?.toFixed?.(1)} - - ))} + {(result.results || []).map((r) => { + const prev = cmpIdx?.get(r.ticker); + return ( + + {r.rank} + {r.name}
{r.ticker} + {r.total_score?.toFixed(1)} + {hasCompare && } + {hasCompare && } + + {r.entry_price?.toLocaleString?.()} + {r.stop_price?.toLocaleString?.()} + {r.target_price?.toLocaleString?.()} + {r.r_pct?.toFixed?.(1)} + + ); + })} + {hasCompare && onlyInCompare.length > 0 && ( + <> + + ── 이번엔 빠진 종목 (비교 대상에만 존재) ── + + {onlyInCompare.map((r) => ( + + — + {r.name}
{r.ticker} + {r.total_score?.toFixed(1)} + OUT + — + + — + + ))} + + )}
diff --git a/src/pages/stock/screener/components/RunHistoryList.jsx b/src/pages/stock/screener/components/RunHistoryList.jsx index d1b2ee9..e058371 100644 --- a/src/pages/stock/screener/components/RunHistoryList.jsx +++ b/src/pages/stock/screener/components/RunHistoryList.jsx @@ -1,17 +1,92 @@ -export default function RunHistoryList({ runs, loading, onSelect, selectedId }) { - if (loading) return

로딩…

; +function formatTime(iso) { + 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 (

최근 실행

-
    - {(runs || []).map((r) => ( -
  • onSelect(r.id)}> - {r.asof} · {r.mode} -
  • - ))} -
+

+ 💡 클릭하면 결과 표에 로드. 우측 "비교"를 누르면 다른 실행과 함께 표시 +

+ + {hasPreview && ( +
+
+ 이번 세션 미리보기 (새로고침 시 사라짐) +
+
    + {previewHistory.map((p) => { + const isSelected = selectedPreviewId === p.id; + const isCompare = compareId === p.id; + return ( +
  • + onSelectPreview?.(p.id)} + style={{ cursor: 'pointer', flex: 1, color: isSelected ? '#fbbf24' : '#e5e7eb' }} + > + {formatTime(p.timestamp)} · {p.mode} +
    + + 통과 {p.survivors_count ?? '-'} · Top1 {p.top_name ?? '-'} + +
    + +
  • + ); + })} +
+
+ )} + +
+
+ 저장된 실행 (자동 잡 + 스냅샷 저장) +
+ {loading ?

로딩…

: ( +
    + {(runs || []).length === 0 && ( +
  • 저장된 실행 없음
  • + )} + {(runs || []).map((r) => ( +
  • onSelect?.(r.id)}> + {r.asof} · {r.mode} +
  • + ))} +
+ )} +
); } diff --git a/src/pages/stock/screener/hooks/useScreenerRun.js b/src/pages/stock/screener/hooks/useScreenerRun.js index ac4e4ab..c44ad1f 100644 --- a/src/pages/stock/screener/hooks/useScreenerRun.js +++ b/src/pages/stock/screener/hooks/useScreenerRun.js @@ -1,9 +1,13 @@ import { useState } from 'react'; import { runScreener } from '../../../../api'; +const MAX_PREVIEW_HISTORY = 10; + export function useScreenerRun() { const [result, setResult] = useState(null); const [running, setRunning] = useState(false); + // 미리보기 결과를 세션 메모리에 누적 (새로고침 시 사라짐 — DB 부하 없음) + const [previewHistory, setPreviewHistory] = useState([]); async function call(mode, settings) { setRunning(true); @@ -17,15 +21,34 @@ export function useScreenerRun() { }; const r = await runScreener(body); 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; } finally { setRunning(false); } } + function selectPreview(id) { + const item = previewHistory.find((p) => p.id === id); + if (item) setResult(item.result); + } + return { - result, running, + result, running, previewHistory, runPreview: (s) => call('preview', s), runSave: (s) => call('manual_save', s), + selectPreview, }; }