first commit

This commit is contained in:
hayato5246
2026-03-31 19:32:59 +09:00
commit d10b794c9f
78 changed files with 1671595 additions and 0 deletions

598
static/js/watchlist.js Normal file
View 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);
})();
})();