first commit
This commit is contained in:
598
static/js/watchlist.js
Normal file
598
static/js/watchlist.js
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* 관심종목 관리 + 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);
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user