/** * 관심종목 관리 + 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 `${s.signalType}`; } 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 `${icon} ${s.riseLabel}`; } function risingBadge(n) { if (!n) return ''; if (n >= 4) return `🔥${n}연속`; if (n >= 2) return `▲${n}연속`; return `↑상승`; } 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 `${s.sentiment}`; } 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(`
거래량 증가 ${volLabel}
`); } 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(`
매도/매수 잔량 ${ratioLabel}
`); } 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(`
가격 위치 ${pos}%
`); } if (rows.length === 0) return ''; return `
${rows.join('')}
`; } 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 `
AI 목표가 ${fmtNum(s.targetPrice)}원 (${sign}${pct}%)
`; } 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 `
익일 추세 분석 중...
`; } const style = trendMap[s.nextDayTrend] || trendMap['횡보']; const confBadge = s.nextDayConf ? `(${s.nextDayConf})` : ''; const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : ''; return `
익일 추세 ${style.icon} ${s.nextDayTrend}${confBadge}
${s.nextDayReason ? `

${s.nextDayReason}

` : ''}
`; } // ───────────────────────────────────────────── // 체결강도 히스토리 + 미니 차트 // ───────────────────────────────────────────── 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 = `

${name}

${code}

`; 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 = `
${code}
-

${name}

-

체결강도 -
직전 대비 -
거래량 -
`; 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)} → ${diff >= 0 ? '+' : ''}${diff.toFixed(2)}`; } // 복합 지표 갱신 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); })(); })();