Compare commits

4 Commits

Author SHA1 Message Date
55d2adeaf5 feat(stock): 미리보기 결과 세션 히스토리 + 결과 비교 컬럼
- useScreenerRun: 실행 시마다 previewHistory에 누적 (최대 10, 메모리만 —
  새로고침 시 사라짐, DB 부하 없음). top_ticker/score 요약 포함.
- RunHistoryList: '이번 세션 미리보기'와 '저장된 실행' 두 섹션으로 분리.
  미리보기 항목은 클릭으로 결과 표 로드 + '비교' 버튼으로 비교 대상 지정.
- ResultTable: compareWith prop으로 비교 모드. 순위Δ(▲▼NEW)·점수Δ
  컬럼 추가, 이번엔 빠진 종목은 'OUT'으로 별도 섹션에 회색 표시.
- 헤더에 'vs HH:MM:SS (통과 X)' 라벨로 비교 대상 명시.
2026-05-13 08:16:21 +09:00
6fd70dd802 feat(stock): 스크리너 노드/컬럼 hover 설명 추가
- ScoreChips: 아이콘 제거, 풀 라벨 표시 (외국인/거래량급증/20일모멘텀/
  52주신고가/RS레이팅/이평선정배열/VCP수축). title에 노드 의미 + 70점
  강조 안내.
- ResultTable: 각 컬럼 헤더에 ⓘ 마커 + 의미 hover 설명. 진입/손절/익절
  컬럼명에 '(원)' 명시. 상단에 hover 가이드 한 줄 추가.
2026-05-13 07:52:14 +09:00
9f4363cdbb fix(deploy): PowerShell single-quote literal로 path 전달
기존 replace(/\/g, "\\\\") + double-quote escape 패턴이 PowerShell
-Command 컨텍스트에서 한 번 더 escape돼 백슬래시가 두 개씩으로
부풀려져 Test-Path가 실패하던 케이스 fix. single-quote로 raw literal
전달 — env override(NAS_FRONTEND_DEST_WIN)가 의도대로 그대로 적용됨.
2026-05-13 07:31:48 +09:00
295972e0cb Merge feature/stock-screener-board: Stock Screener Board MVP (frontend)
- /stock/screener 라우트 + 페이지 골격 + hooks 4 + 컴포넌트 7
- NodeCard 자동 폼 (param_schema), ScoreChips (노드 70점 강조)
- 모바일 적층 + 표 가로 스크롤
- API 헬퍼 7개 + CLAUDE.md 갱신
- Stock 페이지 nav 링크
2026-05-13 07:23:17 +09:00
6 changed files with 298 additions and 52 deletions

View File

@@ -33,10 +33,9 @@ if (!fs.existsSync(src)) {
}
if (isWin) {
// dstWin을 PowerShell 문자열로 안전하게 escape
const dstPs = dstWin.replace(/\\/g, "\\\\");
// PowerShell single-quote literal로 path 전달 — backslash over-escape 회피
const cmd =
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"${dstPs}\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS 경로를 찾을 수 없음: $dst — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $src='dist'; $dst='${dstWin}'; if(!(Test-Path $src)){ throw 'dist not found. Run build first.' }; if(!(Test-Path $dst)){ throw ('NAS 경로를 찾을 수 없음: ' + $dst + ' — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인') }; $log = Join-Path (Get-Location) 'robocopy.log'; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host ('robocopy failed with code ' + $rc + '. See ' + $log); exit $rc } else { exit 0 }"`;
execSync(cmd, { stdio: "inherit" });
} else if (isMac) {
const sshTarget = process.env.NAS_SSH_TARGET;

View File

@@ -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() {
</aside>
<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} />
</main>
<aside className="screener-right">
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
selectedId={selectedRun?.meta?.id} />
<RunHistoryList
runs={runs}
loading={runs_loading}
onSelect={selectRun}
selectedId={selectedRun?.meta?.id}
previewHistory={previewHistory}
onSelectPreview={selectPreview}
onSetCompare={setCompareId}
compareId={compareId}
/>
</aside>
</div>
</div>

View File

@@ -1,19 +1,93 @@
import ScoreChips from './ScoreChips';
export default function ResultTable({ result }) {
const COL_TIPS = {
rank: '순위 — 종합 점수가 높은 순서',
name: '종목명과 종목 코드',
total: '종합 점수 (0~100) — 활성 점수 노드들의 가중평균. 가중치는 좌측 패널에서 조정',
nodes: '노드별 점수 칩 — 70점 이상이면 노란색 강조. 각 칩에 마우스 올리면 해당 노드 설명이 나옵니다',
entry: '예상 진입가 (원) — 현재 종가의 +0.5%, 다음날 시초가 슬리피지 가정',
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 }) {
return (
<th title={COL_TIPS[k]} style={{ cursor: 'help' }}>
{children}
<span style={{ marginLeft: 4, fontSize: 10, color: '#6b7280' }}></span>
</th>
);
}
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">
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행" 눌러보세요.</p>
<p style={{ color: '#6b7280', fontSize: 12, marginTop: 8 }}>
💡 점수·가격 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다.
</p>
</section>
);
}
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={{
@@ -25,27 +99,63 @@ export default function ResultTable({ result }) {
)}
</div>
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
💡 컬럼 헤더와 노드 칩에 마우스를 올리면 의미가 표시됩니다.
{hasCompare && ' · 비교 모드 ON — ▲▼NEW/OUT으로 변화 표시'}
</p>
<div style={{ overflowX: 'auto', marginTop: 12 }}>
<table className="screener-table">
<thead>
<tr>
<th>#</th><th>종목</th><th>총점</th><th>노드</th>
<th>진입</th><th>손절</th><th>익절</th><th>R%</th>
<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>
<Th k="target">익절()</Th>
<Th k="r_pct">R%</Th>
</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>

View File

@@ -1,17 +1,92 @@
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
if (loading) return <section className="screener-card"><p>로딩</p></section>;
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 (
<section className="screener-card">
<h3>최근 실행</h3>
<ul style={{listStyle:'none', padding:0, margin:0, fontSize:13}}>
{(runs || []).map((r) => (
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
onClick={() => onSelect(r.id)}>
{r.asof} · {r.mode}
</li>
))}
</ul>
<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 }}>
{(runs || []).length === 0 && (
<li style={{ fontSize: 11, color: '#6b7280' }}>저장된 실행 없음</li>
)}
{(runs || []).map((r) => (
<li key={r.id} style={{
padding: '6px 0', borderBottom: '1px solid #1f2937', cursor: 'pointer',
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb',
}}
onClick={() => onSelect?.(r.id)}>
{r.asof} · {r.mode}
</li>
))}
</ul>
)}
</div>
</section>
);
}

View File

@@ -1,29 +1,55 @@
const NODE_ICONS = {
foreign_buy: { icon: '👤', label: '외국인' },
volume_surge: { icon: '⚡', label: '거래량' },
momentum: { icon: '🚀', label: '모멘텀' },
high52w: { icon: '🆙', label: '52w고' },
rs_rating: { icon: '💪', label: 'RS' },
ma_alignment: { icon: '📈', label: '정배열' },
vcp_lite: { icon: '🌀', label: 'VCP' },
const NODE_META = {
foreign_buy: {
label: '외국인',
description: '외국인 누적 순매수 강도 — 최근 N일(기본 5일) 외국인 순매수 합계를 시가총액으로 나눈 비율의 백분위',
},
volume_surge: {
label: '거래량 급증',
description: '최근 3일 평균 거래량 vs 직전 20일 평균의 log(비율) 백분위 — 매집/관심 급증 신호',
},
momentum: {
label: '20일 모멘텀',
description: '20일 누적 수익률 백분위 — 단기 상승 추세 강도',
},
high52w: {
label: '52주 신고가 근접도',
description: '현재가 / 52주 최고가 (룰 기반: 70% 미만 0점, 100% 도달 100점, 선형) — 미너비니 SEPA 핵심',
},
rs_rating: {
label: 'RS Rating',
description: '시장(KOSPI) 대비 3·6·9·12개월 초과수익 가중합 (IBD 표준 2:1:1:1) 백분위 — 상대강도',
},
ma_alignment: {
label: '이평선 정배열',
description: '현재가>MA50, MA50>MA150, MA150>MA200, 현재가>MA200, 52주 저점+25% 이상 — 5조건 만족도 × 20점',
},
vcp_lite: {
label: 'VCP-lite (변동성 수축)',
description: '단기(40일) vs 장기(252일) 일중 변동성 비율 백분위 — 변동성 수축 = 돌파 직전 패턴',
},
};
export default function ScoreChips({ scores }) {
return (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{Object.entries(scores || {}).map(([name, s]) => {
const meta = NODE_ICONS[name];
const meta = NODE_META[name];
if (!meta) return null;
const active = s >= 70;
const score = Math.round(s);
return (
<span key={name}
title={`${meta.label}: ${s.toFixed?.(0) ?? s}`}
style={{
padding: '2px 6px', borderRadius: 4, fontSize: 11,
background: active ? '#fbbf24' : '#1f2937',
color: active ? '#0b0f17' : '#9ca3af',
}}>
{meta.icon}{Math.round(s)}
<span
key={name}
title={`${meta.label} ${score}\n\n${meta.description}\n\n(70점 이상이면 강조 표시)`}
style={{
padding: '3px 8px', borderRadius: 4, fontSize: 11,
background: active ? '#fbbf24' : '#1f2937',
color: active ? '#0b0f17' : '#9ca3af',
cursor: 'help',
fontWeight: active ? 600 : 400,
}}
>
{meta.label} {score}
</span>
);
})}

View File

@@ -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,
};
}