/** * 체결강도 상승 감지 실시간 표시 * - 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 `${s.signalType}`; } // 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 `${icon} ${s.riseLabel}`; } // 호재/악재/중립 뱃지 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 `${s.sentiment}`; } // 연속 상승 횟수에 따른 뱃지 텍스트 function risingBadge(n) { if (n >= 4) return `🔥${n}연속`; if (n >= 2) return `▲${n}연속`; return `↑상승`; } // 복합 지표 섹션 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(`
거래량 증가 ${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('')}
`; } // 익일 추세 예상 리포팅 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 `
익일 추세 분석 중...
`; } 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}

` : ''}
`; } // 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 `
AI 목표가 ${fmtNum(s.targetPrice)}원 (${sign}${pct}%)
`; } // 시그널 종목 카드 HTML 생성 function makeCard(s) { const diff = s.cntrStr - s.prevCntrStr; const rising = s.risingCount || 1; return `
${s.code}
${signalTypeBadge(s)} ${riseProbBadge(s)} ${risingBadge(rising)} ${sentimentBadge(s)} ${fmtRate(s.changeRate)}

${s.name}

${fmtNum(s.currentPrice)}원

체결강도 ${fmtCntr(s.cntrStr)}
직전 대비 ${fmtCntr(s.prevCntrStr)} → +${diff.toFixed(2)}
거래량 ${fmtNum(s.volume)}
${complexIndicators(s)} ${targetPriceBadge(s)} ${nextDayBadge(s)}
`; } // 카드 삽입 후 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'); }); })();