Files
stocksearch/static/js/watchlist.js
2026-03-31 19:32:59 +09:00

599 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 관심종목 관리 + WebSocket 실시간 시세 + 10초 폴링 분석
* - 서버 API (/api/watchlist)에 종목 저장
* - WS 구독으로 1초마다 현재가/등락률/체결강도 + 미니 차트 갱신
* - 10초마다 /api/watchlist-signal 폴링으로 복합 분석 뱃지 갱신
*/
(function () {
const MAX_HISTORY = 60; // 체결강도 히스토리 최대 포인트
const SIGNAL_INTERVAL = 10 * 1000; // 10초 폴링
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
const cntrHistory = new Map(); // code → number[]
// --- 서버 API 헬퍼 ---
// 메모리 캐시 (서버 동기화용)
let cachedList = [];
async function loadFromServer() {
try {
const resp = await fetch('/api/watchlist');
if (!resp.ok) return [];
const list = await resp.json();
cachedList = Array.isArray(list) ? list : [];
return cachedList;
} catch {
return cachedList;
}
}
function loadList() {
return cachedList;
}
async function addToServer(code, name) {
const resp = await fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, name }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || '추가 실패');
}
cachedList.push({ code, name });
}
async function removeFromServer(code) {
await fetch(`/api/watchlist/${code}`, { method: 'DELETE' });
cachedList = cachedList.filter(s => s.code !== code);
}
// --- DOM 요소 ---
const input = document.getElementById('watchlistInput');
const addBtn = document.getElementById('watchlistAddBtn');
const msgEl = document.getElementById('watchlistMsg');
const sidebarEl = document.getElementById('watchlistSidebar');
const emptyEl = document.getElementById('watchlistEmpty');
const panelEl = document.getElementById('watchlistPanel');
const panelEmpty = document.getElementById('watchlistPanelEmpty');
const wsStatusEl = document.getElementById('wsStatus');
// --- WS 상태 표시 ---
function setWsStatus(text, color) {
if (!wsStatusEl) return;
wsStatusEl.textContent = text;
wsStatusEl.className = `text-xs font-normal ml-1 ${color}`;
}
// ─────────────────────────────────────────────
// 포맷 유틸
// ─────────────────────────────────────────────
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
return (f >= 0 ? '+' : '') + f.toFixed(2) + '%';
}
function fmtCntr(f) {
return f ? f.toFixed(2) : '-';
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBadgeClass(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// ─────────────────────────────────────────────
// 뱃지 HTML 생성 함수 (signal.js와 동일)
// ─────────────────────────────────────────────
function signalTypeBadge(s) {
if (!s.signalType) return '';
const map = {
'강한매수': 'bg-red-600 text-white border-red-700',
'매수우세': 'bg-orange-400 text-white border-orange-500',
'물량소화': 'bg-yellow-100 text-yellow-700 border-yellow-300',
'추격위험': 'bg-gray-800 text-white border-gray-900',
'약한상승': 'bg-gray-100 text-gray-500 border-gray-300',
};
const cls = map[s.signalType] || 'bg-gray-100 text-gray-500';
return `<span class="border ${cls} text-xs px-1.5 py-0.5 rounded font-bold">${s.signalType}</span>`;
}
function riseProbBadge(s) {
if (!s.riseLabel) return '';
const isVeryHigh = s.riseLabel === '매우 높음';
const cls = isVeryHigh
? 'bg-emerald-500 text-white border border-emerald-600'
: 'bg-teal-100 text-teal-700 border border-teal-200';
const icon = isVeryHigh ? '🚀' : '📈';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: ${s.riseScore}점">${icon} ${s.riseLabel}</span>`;
}
function risingBadge(n) {
if (!n) return '';
if (n >= 4) return `<span class="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">🔥${n}연속</span>`;
if (n >= 2) return `<span class="bg-orange-400 text-white text-xs px-1.5 py-0.5 rounded font-bold">▲${n}연속</span>`;
return `<span class="bg-yellow-100 text-yellow-700 text-xs px-1.5 py-0.5 rounded font-semibold">↑상승</span>`;
}
function sentimentBadge(s) {
const map = {
'호재': 'bg-green-100 text-green-700 border border-green-200',
'악재': 'bg-red-100 text-red-600 border border-red-200',
'중립': 'bg-gray-100 text-gray-500',
};
const cls = map[s.sentiment];
if (!cls) return '';
const title = s.sentimentReason ? ` title="${s.sentimentReason}"` : '';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-semibold"${title}>${s.sentiment}</span>`;
}
function complexIndicators(s) {
const rows = [];
if (s.volRatio > 0) {
let volCls = 'text-gray-500';
let volLabel = `${s.volRatio.toFixed(1)}`;
if (s.volRatio >= 10) { volCls = 'text-gray-400 line-through'; volLabel += ' ⚠과열'; }
else if (s.volRatio >= 5) volCls = 'text-orange-400';
else if (s.volRatio >= 2) volCls = 'text-green-600 font-semibold';
else if (s.volRatio >= 1) volCls = 'text-green-500';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">거래량 증가</span>
<span class="${volCls}">${volLabel}</span>
</div>`);
}
if (s.totalAskVol > 0 && s.totalBidVol > 0) {
const ratio = s.askBidRatio.toFixed(2);
let ratioCls = 'text-gray-500';
let ratioLabel = `${ratio} (`;
if (s.askBidRatio <= 0.7) { ratioCls = 'text-green-600 font-semibold'; ratioLabel += '매수 강세)'; }
else if (s.askBidRatio <= 1.0) { ratioCls = 'text-green-500'; ratioLabel += '매수 우세)'; }
else if (s.askBidRatio <= 1.5) { ratioCls = 'text-gray-500'; ratioLabel += '균형)'; }
else { ratioCls = 'text-blue-500'; ratioLabel += '매도 우세)'; }
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">매도/매수 잔량</span>
<span class="${ratioCls} text-xs">${ratioLabel}</span>
</div>`);
}
if (s.pricePos !== undefined) {
const pos = s.pricePos.toFixed(0);
let posCls = 'text-gray-500';
if (s.pricePos >= 80) posCls = 'text-red-500 font-semibold';
else if (s.pricePos >= 60) posCls = 'text-orange-400';
else if (s.pricePos <= 30) posCls = 'text-blue-400';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">가격 위치</span>
<span class="${posCls}">${pos}%</span>
</div>`);
}
if (rows.length === 0) return '';
return `<div class="mt-2 pt-2 border-t border-gray-100 space-y-1 text-sm">${rows.join('')}</div>`;
}
function targetPriceBadge(s) {
if (!s.targetPrice || s.targetPrice === 0) return '';
const diff = s.targetPrice - s.currentPrice;
const pct = ((diff / s.currentPrice) * 100).toFixed(1);
const sign = diff >= 0 ? '+' : '';
const cls = diff >= 0 ? 'bg-purple-50 text-purple-600 border border-purple-200' : 'bg-gray-100 text-gray-500';
const title = s.targetReason ? ` title="${s.targetReason}"` : '';
return `<div class="flex justify-between items-center mt-2 pt-2 border-t border-purple-50">
<span class="text-xs text-gray-400">AI 목표가</span>
<span class="${cls} text-xs px-2 py-0.5 rounded font-semibold"${title}>
${fmtNum(s.targetPrice)}원 <span class="opacity-70">(${sign}${pct}%)</span>
</span>
</div>`;
}
function nextDayBadge(s) {
const trendMap = {
'상승': { bg: 'bg-red-50 border-red-200', icon: '▲', cls: 'text-red-500' },
'하락': { bg: 'bg-blue-50 border-blue-200', icon: '▼', cls: 'text-blue-500' },
'횡보': { bg: 'bg-gray-50 border-gray-200', icon: '─', cls: 'text-gray-500' },
};
if (!s.nextDayTrend) {
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="text-xs text-gray-400 animate-pulse">분석 중...</span>
</div>
</div>`;
}
const style = trendMap[s.nextDayTrend] || trendMap['횡보'];
const confBadge = s.nextDayConf ? `<span class="text-xs text-gray-400 ml-1">(${s.nextDayConf})</span>` : '';
const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : '';
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="${style.bg} ${style.cls} border text-xs px-2 py-0.5 rounded font-bold"${reasonTip}>
${style.icon} ${s.nextDayTrend}${confBadge}
</span>
</div>
${s.nextDayReason ? `<p class="text-xs text-gray-400 mt-1 truncate" title="${s.nextDayReason}">${s.nextDayReason}</p>` : ''}
</div>`;
}
// ─────────────────────────────────────────────
// 체결강도 히스토리 + 미니 차트
// ─────────────────────────────────────────────
function recordCntr(code, value) {
if (value == null || value === 0) return;
if (!cntrHistory.has(code)) cntrHistory.set(code, []);
const arr = cntrHistory.get(code);
arr.push(value);
if (arr.length > MAX_HISTORY) arr.shift();
}
function drawChart(code) {
const canvas = document.getElementById(`wc-chart-${code}`);
if (!canvas) return;
const data = cntrHistory.get(code) || [];
if (data.length < 2) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.floor(rect.width * dpr);
const h = Math.floor(rect.height * dpr);
if (w === 0 || h === 0) return;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const ctx = canvas.getContext('2d');
const pad = 3 * dpr;
const drawH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const step = w / (data.length - 1);
const getY = val => h - pad - ((val - min) / range) * drawH;
// 그라디언트 필
const lastX = (data.length - 1) * step;
const grad = ctx.createLinearGradient(0, pad, 0, h);
grad.addColorStop(0, 'rgba(249,115,22,0.35)');
grad.addColorStop(1, 'rgba(249,115,22,0)');
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(lastX, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// 라인
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 1.5 * dpr;
ctx.lineJoin = 'round';
ctx.stroke();
// 마지막 포인트 강조
const lastVal = data[data.length - 1];
ctx.beginPath();
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#f97316';
ctx.fill();
}
// ─────────────────────────────────────────────
// 관심종목 추가 / 삭제
// ─────────────────────────────────────────────
async function addStock(code) {
code = code.trim().toUpperCase();
if (!/^\d{6}$/.test(code)) {
showMsg('6자리 숫자 종목코드를 입력해주세요.');
return;
}
if (loadList().find(s => s.code === code)) {
showMsg('이미 추가된 종목입니다.');
return;
}
showMsg('조회 중...', false);
try {
const resp = await fetch(`/api/stock/${code}`);
if (!resp.ok) throw new Error('조회 실패');
const data = await resp.json();
if (!data.name) throw new Error('종목 없음');
await addToServer(code, data.name);
hideMsg();
input.value = '';
renderSidebar();
addPanelCard(code, data.name);
updateCard(code, data);
subscribeCode(code);
} catch (e) {
showMsg(e.message || '종목을 찾을 수 없습니다.');
}
}
async function removeStock(code) {
await removeFromServer(code);
stockWS.unsubscribe(code);
document.getElementById(`si-${code}`)?.remove();
document.getElementById(`wc-${code}`)?.remove();
cntrHistory.delete(code);
updateEmptyStates();
}
// ─────────────────────────────────────────────
// 사이드바 렌더링
// ─────────────────────────────────────────────
function renderSidebar() {
const list = loadList();
Array.from(sidebarEl.children).forEach(el => {
if (el.id !== 'watchlistEmpty') el.remove();
});
list.forEach(s => sidebarEl.appendChild(makeSidebarItem(s.code, s.name)));
updateEmptyStates();
}
function makeSidebarItem(code, name) {
const li = document.createElement('li');
li.id = `si-${code}`;
li.className = 'flex items-center justify-between px-3 py-2.5 hover:bg-gray-50 group';
li.innerHTML = `
<a href="/stock/${code}" class="flex-1 min-w-0">
<p class="text-xs font-medium text-gray-800 truncate">${name}</p>
<p class="text-xs text-gray-400 font-mono">${code}</p>
</a>
<button onclick="removeStock('${code}')" title="삭제"
class="ml-2 text-gray-300 hover:text-red-400 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none">
×
</button>`;
return li;
}
// ─────────────────────────────────────────────
// 패널 카드 추가 (signal.js makeCard 구조와 동일)
// ─────────────────────────────────────────────
function addPanelCard(code, name) {
if (document.getElementById(`wc-${code}`)) return;
const card = document.createElement('div');
card.id = `wc-${code}`;
card.className = 'bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-4 border border-gray-100 relative';
card.innerHTML = `
<!-- 헤더: 종목코드 + 분석 뱃지 + 등락률 -->
<div class="flex justify-between items-start mb-2">
<a href="/stock/${code}" class="text-xs text-gray-400 font-mono hover:text-blue-500">${code}</a>
<div id="wc-badges-${code}" class="flex items-center gap-1 flex-wrap justify-end">
<span id="wc-rate-${code}" class="text-xs px-2 py-0.5 rounded-full font-semibold bg-gray-100 text-gray-500">-</span>
</div>
</div>
<!-- 종목명 + 현재가 -->
<a href="/stock/${code}" class="block">
<p class="font-semibold text-gray-800 text-sm truncate mb-1">${name}</p>
<p id="wc-price-${code}" class="text-xl font-bold text-gray-900 mb-2">-</p>
</a>
<!-- info 섹션 -->
<div class="pt-2 border-t border-gray-50 text-sm space-y-1">
<div class="flex justify-between">
<span class="text-gray-400">체결강도</span>
<span id="wc-cntr-${code}" class="font-bold text-orange-500">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">직전 대비</span>
<span id="wc-prev-${code}" class="text-gray-500">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">거래량</span>
<span id="wc-vol-${code}" class="text-gray-600">-</span>
</div>
<!-- 복합 지표 (10초 폴링 갱신) -->
<div id="wc-complex-${code}"></div>
<!-- AI 목표가 (10초 폴링 갱신) -->
<div id="wc-target-${code}"></div>
<!-- 익일 추세 (10초 폴링 갱신) -->
<div id="wc-nextday-${code}"></div>
</div>
<!-- 체결강도 미니 라인차트 -->
<canvas id="wc-chart-${code}"
style="width:100%;height:48px;display:block;margin-top:10px;"
class="rounded-sm"></canvas>
<!-- 삭제 버튼 -->
<button onclick="removeStock('${code}')" title="삭제"
class="absolute top-3 right-3 text-gray-200 hover:text-red-400 text-lg leading-none opacity-0 hover:opacity-100 transition-opacity" style="font-size:18px;">×</button>`;
panelEl.appendChild(card);
updateEmptyStates();
}
// ─────────────────────────────────────────────
// WS 실시간 카드 업데이트 (1초)
// ─────────────────────────────────────────────
function updateCard(code, data) {
const priceEl = document.getElementById(`wc-price-${code}`);
const rateEl = document.getElementById(`wc-rate-${code}`);
const volEl = document.getElementById(`wc-vol-${code}`);
const cntrEl = document.getElementById(`wc-cntr-${code}`);
if (!priceEl) return;
const rate = data.changeRate ?? 0;
const colorCls = rateClass(rate);
const bgCls = rateBadgeClass(rate);
const sign = rate > 0 ? '+' : '';
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
rateEl.textContent = sign + rate.toFixed(2) + '%';
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
if (volEl) volEl.textContent = fmtNum(data.volume);
if (cntrEl && data.cntrStr !== undefined) {
const cs = data.cntrStr;
cntrEl.textContent = fmtCntr(cs);
cntrEl.className = `font-bold ${cs >= 100 ? 'text-orange-500' : 'text-blue-400'}`;
}
// 체결강도 히스토리 기록 + 미니 차트 갱신
if (data.cntrStr != null && data.cntrStr !== 0) {
recordCntr(code, data.cntrStr);
drawChart(code);
}
}
// ─────────────────────────────────────────────
// 10초 폴링 분석 결과 DOM 갱신
// ─────────────────────────────────────────────
function updateAnalysis(code, sig) {
// 뱃지 영역 갱신 (등락률 뱃지는 유지하면서 분석 뱃지 앞에 삽입)
const badgesEl = document.getElementById(`wc-badges-${code}`);
const rateEl = document.getElementById(`wc-rate-${code}`);
if (badgesEl && rateEl) {
// 기존 분석 뱃지 제거
badgesEl.querySelectorAll('.analysis-badge').forEach(el => el.remove());
// 분석 뱃지 생성 후 등락률 앞에 삽입
const frag = document.createRange().createContextualFragment(
[
signalTypeBadge(sig),
riseProbBadge(sig),
risingBadge(sig.risingCount),
sentimentBadge(sig),
].filter(Boolean).join('')
);
// analysis-badge 클래스 추가 (제거 시 사용)
frag.querySelectorAll('span').forEach(el => el.classList.add('analysis-badge'));
badgesEl.insertBefore(frag, rateEl);
}
// 직전 대비 갱신
const prevEl = document.getElementById(`wc-prev-${code}`);
if (prevEl && sig.prevCntrStr != null && sig.cntrStr != null) {
const diff = sig.cntrStr - sig.prevCntrStr;
prevEl.innerHTML = `${fmtCntr(sig.prevCntrStr)} → <span class="${diff >= 0 ? 'text-green-500' : 'text-blue-400'} font-semibold">${diff >= 0 ? '+' : ''}${diff.toFixed(2)}</span>`;
}
// 복합 지표 갱신
const complexEl = document.getElementById(`wc-complex-${code}`);
if (complexEl) complexEl.innerHTML = complexIndicators(sig);
// AI 목표가 갱신
const targetEl = document.getElementById(`wc-target-${code}`);
if (targetEl) targetEl.innerHTML = targetPriceBadge(sig);
// 익일 추세 갱신
const nextEl = document.getElementById(`wc-nextday-${code}`);
if (nextEl) nextEl.innerHTML = nextDayBadge(sig);
}
// ─────────────────────────────────────────────
// WS 구독
// ─────────────────────────────────────────────
function subscribeCode(code) {
stockWS.subscribe(code);
stockWS.onPrice(code, (data) => updateCard(code, data));
}
// ─────────────────────────────────────────────
// 10초 폴링: /api/watchlist-signal
// ─────────────────────────────────────────────
async function fetchWatchlistSignal() {
const codes = cachedList.map(s => s.code).join(',');
if (!codes) return;
try {
const resp = await fetch(`/api/watchlist-signal?codes=${codes}`);
if (!resp.ok) throw new Error('조회 실패');
const signals = await resp.json();
if (!Array.isArray(signals)) return;
signals.forEach(sig => updateAnalysis(sig.code, sig));
} catch (e) {
console.error('관심종목 분석 조회 실패:', e);
}
}
// ─────────────────────────────────────────────
// 빈 상태 처리
// ─────────────────────────────────────────────
function updateEmptyStates() {
const hasItems = cachedList.length > 0;
emptyEl.classList.toggle('hidden', hasItems);
panelEmpty?.classList.toggle('hidden', hasItems);
}
// ─────────────────────────────────────────────
// 메시지
// ─────────────────────────────────────────────
function showMsg(text, isError = true) {
msgEl.textContent = text;
msgEl.className = `text-xs mt-1 ${isError ? 'text-red-500' : 'text-gray-400'}`;
msgEl.classList.remove('hidden');
}
function hideMsg() { msgEl.classList.add('hidden'); }
// ─────────────────────────────────────────────
// WS 상태 모니터링
// ─────────────────────────────────────────────
function monitorWS() {
setInterval(() => {
if (stockWS.ws?.readyState === WebSocket.OPEN) {
setWsStatus('● 실시간', 'text-green-500 font-normal ml-1');
} else {
setWsStatus('○ 연결 중...', 'text-gray-400 font-normal ml-1');
}
}, 1000);
}
// ─────────────────────────────────────────────
// 이벤트 바인딩
// ─────────────────────────────────────────────
addBtn.addEventListener('click', () => addStock(input.value));
input.addEventListener('keydown', e => { if (e.key === 'Enter') addStock(input.value); });
// removeStock을 전역으로 노출 (onclick 속성에서 호출)
window.removeStock = removeStock;
// ─────────────────────────────────────────────
// 초기화
// ─────────────────────────────────────────────
monitorWS();
// 서버에서 관심종목 로드 후 카드 생성 + WS 구독
(async function init() {
await loadFromServer();
renderSidebar();
cachedList.forEach(s => {
addPanelCard(s.code, s.name);
subscribeCode(s.code);
fetch(`/api/stock/${s.code}`)
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) updateCard(s.code, data); })
.catch(() => {});
});
updateEmptyStates();
// 10초 폴링 시작 (즉시 1회 + 10초 주기)
fetchWatchlistSignal();
setInterval(fetchWatchlistSignal, SIGNAL_INTERVAL);
})();
})();