457 lines
19 KiB
JavaScript
457 lines
19 KiB
JavaScript
/**
|
|
* 체결강도 상승 감지 실시간 표시
|
|
* - 10초마다 /api/signal 폴링
|
|
* - 감지된 종목 WS 구독으로 실시간 가격/체결강도 갱신
|
|
* - 각 카드에 체결강도 히스토리 미니 라인차트 표시
|
|
*/
|
|
(function () {
|
|
const gridEl = document.getElementById('signalGrid');
|
|
const emptyEl = document.getElementById('signalEmpty');
|
|
const updatedEl = document.getElementById('signalUpdatedAt');
|
|
const INTERVAL = 10 * 1000;
|
|
const MAX_HISTORY = 60; // 최대 60개 포인트 유지
|
|
|
|
let currentCodes = [];
|
|
|
|
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
|
|
const cntrHistory = new Map(); // code → number[]
|
|
|
|
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';
|
|
}
|
|
|
|
// 체결강도 히스토리에 값 추가
|
|
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();
|
|
}
|
|
|
|
// canvas에 체결강도 라인차트 그리기
|
|
function drawChart(code) {
|
|
const canvas = document.getElementById(`sg-chart-${code}`);
|
|
if (!canvas) return;
|
|
const data = cntrHistory.get(code) || [];
|
|
if (data.length < 2) return;
|
|
|
|
// canvas 픽셀 크기를 실제 표시 크기에 맞춤 (선명도 유지)
|
|
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();
|
|
}
|
|
|
|
// 신호 유형 뱃지 HTML 생성
|
|
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>`;
|
|
}
|
|
|
|
// 1시간 이내 상승 확률 뱃지 HTML 생성
|
|
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>`;
|
|
}
|
|
|
|
// 호재/악재/중립 뱃지 HTML 생성 ("정보없음"은 빈 문자열 반환)
|
|
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 risingBadge(n) {
|
|
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>`;
|
|
}
|
|
|
|
// 복합 지표 섹션 HTML 생성 (거래량 배수 · 매도잔량비 · 가격 위치)
|
|
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>`;
|
|
}
|
|
|
|
// 익일 추세 예상 리포팅 HTML 생성
|
|
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' },
|
|
};
|
|
|
|
// LLM 결과가 없으면 "분석 중..." 표시
|
|
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>`;
|
|
}
|
|
|
|
// AI 목표가 뱃지 HTML 생성
|
|
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>`;
|
|
}
|
|
|
|
// 시그널 종목 카드 HTML 생성
|
|
function makeCard(s) {
|
|
const diff = s.cntrStr - s.prevCntrStr;
|
|
const rising = s.risingCount || 1;
|
|
return `
|
|
<a href="/stock/${s.code}" id="sg-${s.code}"
|
|
class="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-5 border border-orange-100">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<span class="text-sm text-gray-400 font-mono">${s.code}</span>
|
|
<div class="flex items-center gap-1.5 flex-wrap justify-end">
|
|
${signalTypeBadge(s)}
|
|
${riseProbBadge(s)}
|
|
${risingBadge(rising)}
|
|
${sentimentBadge(s)}
|
|
<span id="sg-rate-${s.code}" class="text-sm px-2.5 py-0.5 rounded-full font-semibold ${rateBadgeClass(s.changeRate)}">
|
|
${fmtRate(s.changeRate)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p class="font-bold text-gray-800 text-base truncate mb-1">${s.name}</p>
|
|
<p id="sg-price-${s.code}" class="text-2xl font-bold ${rateClass(s.changeRate)} mb-3">${fmtNum(s.currentPrice)}원</p>
|
|
<div class="pt-3 border-t border-gray-50 text-sm space-y-1.5">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-400">체결강도</span>
|
|
<span id="sg-cntr-${s.code}" class="font-bold text-orange-500">${fmtCntr(s.cntrStr)}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-400">직전 대비</span>
|
|
<span class="text-gray-500">${fmtCntr(s.prevCntrStr)} → <span class="text-green-500 font-semibold">+${diff.toFixed(2)}</span></span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-400">거래량</span>
|
|
<span id="sg-vol-${s.code}" class="text-gray-600">${fmtNum(s.volume)}</span>
|
|
</div>
|
|
${complexIndicators(s)}
|
|
${targetPriceBadge(s)}
|
|
${nextDayBadge(s)}
|
|
</div>
|
|
<!-- 체결강도 미니 라인차트 -->
|
|
<canvas id="sg-chart-${s.code}"
|
|
style="width:100%;height:48px;display:block;margin-top:12px;"
|
|
class="rounded-sm"></canvas>
|
|
</a>`;
|
|
}
|
|
|
|
// 카드 삽입 후 canvas 초기화 및 초기값 기록
|
|
function initChart(code, cntrStr) {
|
|
recordCntr(code, cntrStr);
|
|
drawChart(code);
|
|
}
|
|
|
|
// WebSocket 실시간 가격 수신 시 카드 업데이트
|
|
function updateCard(code, data) {
|
|
const priceEl = document.getElementById(`sg-price-${code}`);
|
|
const rateEl = document.getElementById(`sg-rate-${code}`);
|
|
const cntrEl = document.getElementById(`sg-cntr-${code}`);
|
|
const volEl = document.getElementById(`sg-vol-${code}`);
|
|
if (!priceEl) return;
|
|
|
|
const rate = data.changeRate ?? 0;
|
|
priceEl.textContent = fmtNum(data.currentPrice) + '원';
|
|
priceEl.className = `text-lg font-bold ${rateClass(rate)}`;
|
|
rateEl.textContent = fmtRate(rate);
|
|
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(rate)}`;
|
|
if (cntrEl && data.cntrStr !== undefined) {
|
|
cntrEl.textContent = fmtCntr(data.cntrStr);
|
|
}
|
|
if (volEl) volEl.textContent = fmtNum(data.volume);
|
|
|
|
// 체결강도 히스토리 기록 후 차트 갱신
|
|
if (data.cntrStr != null && data.cntrStr !== 0) {
|
|
recordCntr(code, data.cntrStr);
|
|
drawChart(code);
|
|
}
|
|
}
|
|
|
|
// 이전 구독 종목 해제 후 신규 종목 구독
|
|
function resubscribe(newCodes) {
|
|
currentCodes.forEach(code => {
|
|
if (!newCodes.includes(code)) stockWS.unsubscribe(code);
|
|
});
|
|
newCodes.forEach(code => {
|
|
if (!currentCodes.includes(code)) {
|
|
stockWS.subscribe(code);
|
|
stockWS.onPrice(code, data => updateCard(code, data));
|
|
}
|
|
});
|
|
currentCodes = newCodes;
|
|
}
|
|
|
|
// /api/signal 조회 후 그리드 렌더링
|
|
async function fetchAndRender() {
|
|
try {
|
|
const resp = await fetch('/api/signal');
|
|
if (!resp.ok) throw new Error('조회 실패');
|
|
const signals = await resp.json();
|
|
|
|
const now = new Date();
|
|
updatedEl.textContent = now.toTimeString().slice(0, 8) + ' 기준';
|
|
|
|
if (!Array.isArray(signals) || signals.length === 0) {
|
|
gridEl.innerHTML = '';
|
|
if (emptyEl) emptyEl.classList.remove('hidden');
|
|
resubscribe([]);
|
|
return;
|
|
}
|
|
|
|
if (emptyEl) emptyEl.classList.add('hidden');
|
|
gridEl.innerHTML = signals.map(makeCard).join('');
|
|
|
|
// 카드 삽입 후 각 종목 초기 체결강도 기록 및 차트 초기화
|
|
signals.forEach(s => initChart(s.code, s.cntrStr));
|
|
|
|
resubscribe(signals.map(s => s.code));
|
|
} catch (e) {
|
|
console.error('시그널 조회 실패:', e);
|
|
}
|
|
}
|
|
|
|
let pollTimer = null;
|
|
|
|
function startPolling() {
|
|
if (pollTimer) return;
|
|
fetchAndRender();
|
|
pollTimer = setInterval(fetchAndRender, INTERVAL);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
// 그리드 비우기
|
|
gridEl.innerHTML = '';
|
|
if (emptyEl) {
|
|
emptyEl.textContent = '스캐너가 꺼져 있습니다. 버튼을 눌러 켜주세요.';
|
|
emptyEl.classList.remove('hidden');
|
|
}
|
|
resubscribe([]);
|
|
}
|
|
|
|
// 토글 버튼 상태 적용
|
|
const toggleBtn = document.getElementById('scannerToggleBtn');
|
|
function applyState(enabled) {
|
|
if (!toggleBtn) return;
|
|
if (enabled) {
|
|
toggleBtn.textContent = '● ON';
|
|
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-green-100 text-green-700 border-green-300';
|
|
startPolling();
|
|
} else {
|
|
toggleBtn.textContent = '○ OFF';
|
|
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-gray-100 text-gray-400 border-gray-300';
|
|
stopPolling();
|
|
}
|
|
}
|
|
|
|
// 페이지 로드 시 백엔드 상태 조회
|
|
fetch('/api/scanner/status')
|
|
.then(r => r.json())
|
|
.then(d => applyState(d.enabled))
|
|
.catch(() => applyState(true)); // 실패 시 켜짐으로 fallback
|
|
|
|
// 토글 버튼 클릭
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener('click', async () => {
|
|
try {
|
|
const resp = await fetch('/api/scanner/toggle', { method: 'POST' });
|
|
const data = await resp.json();
|
|
applyState(data.enabled);
|
|
} catch (e) {
|
|
console.error('스캐너 토글 실패:', e);
|
|
}
|
|
});
|
|
}
|
|
})();
|
|
|
|
// 시그널 판단 기준 모달 열기/닫기
|
|
(function () {
|
|
const btn = document.getElementById('signalGuideBtn');
|
|
const modal = document.getElementById('signalGuideModal');
|
|
const close = document.getElementById('signalGuideClose');
|
|
if (!btn || !modal) return;
|
|
|
|
btn.addEventListener('click', () => modal.classList.remove('hidden'));
|
|
close.addEventListener('click', () => modal.classList.add('hidden'));
|
|
// 모달 바깥 클릭 시 닫기
|
|
modal.addEventListener('click', e => {
|
|
if (e.target === modal) modal.classList.add('hidden');
|
|
});
|
|
// ESC 키로 닫기
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') modal.classList.add('hidden');
|
|
});
|
|
})(); |