/** * 한국 주식 스크리너 — 통합 필터 (v2) * * 각 조건별 체크박스로 활성화, 체크된 조건만 AND로 적용. */ let allRows = []; let allCompanies = []; // 전체 종목 (컨센서스 없는 것 포함) let dtTable = null; let bondRate = null; let watchlistTickers = new Set(); let currentUser = null; // {id, email, name, is_admin} 또는 null // ── 컬럼 정의 ──────────────────────────────────────────────────── const COLUMNS = [ { title: '', data: 'ticker', orderable: false, width: '32px', render: (d) => `` }, { title: '종목코드', data: 'ticker', render: (d) => `${d}` }, { title: '종목명', data: 'name', render: (d, t, row) => `${d}` }, { title: '현재가', data: 'current_price', render: (d, t, row) => `${formatPrice(d)}` }, { title: '시가총액(억)', data: 'market_cap', render: formatNum0, visible: false }, // 가치 { title: 'Forward PER', data: 'forward_per', render: formatNum1 }, { title: 'PBR', data: 'pbr', render: formatNum2 }, { title: 'ROE(%)', data: 'roe_fwd', render: d => d != null ? (d * 100).toFixed(1) : '-' }, // 성장 { title: '매출성장률(%)', data: 'rev_growth', render: d => d != null ? (d * 100).toFixed(1) : '-' }, { title: '영업이익성장률(%)', data: 'op_growth', render: d => d != null ? (d * 100).toFixed(1) : '-' }, { title: '영업이익률(%)', data: 'op_margin', render: d => d != null ? (d * 100).toFixed(1) : '-' }, // 배당 { title: '배당수익률(%)', data: 'est_dividend_yield', render: (d, t, row) => { if (t !== 'display') return d; if (d == null) return '-'; const base = d.toFixed(2); if (bondRate == null) return base; const spread = (d - bondRate).toFixed(1); const sign = d - bondRate >= 0 ? '+' : ''; return `${base}(${sign}${spread})`; } }, { title: '배당성향(%)', data: 'payout_ratio', render: d => d != null ? (d * 100).toFixed(1) : '-' }, { title: '연속배당(년)', data: 'dividend_years', render: d => d != null ? d : '-' }, ]; // ── 포맷 헬퍼 ──────────────────────────────────────────────────── function formatPrice(d) { return d != null ? d.toLocaleString('ko-KR') + '원' : '-'; } function formatNum0(d) { return d != null ? Math.round(d).toLocaleString('ko-KR') : '-'; } function formatNum1(d) { return d != null ? d.toFixed(1) : '-'; } function formatNum2(d) { return d != null ? d.toFixed(2) : '-'; } // ── 필터 로직 ──────────────────────────────────────────────────── function passesFilter(row) { // 관심종목 필터 if (getCheck('f-watchlist')) { if (!watchlistTickers.has(row.ticker)) return false; } // 배당 조건 if (getCheck('f-div-yield')) { const spread = getNum('p-spread', 1.5); const dy = row.est_dividend_yield; if (dy == null || dy <= (bondRate || 0) + spread) return false; } if (getCheck('f-div-years')) { const minYrs = getNum('p-div-years', 3); if ((row.dividend_years || 0) < minYrs) return false; } if (getCheck('f-div-payout')) { const maxPayout = getNum('p-max-payout', 60); const payout = row.payout_ratio; if (payout != null && payout * 100 > maxPayout) return false; } // 가치 조건 if (getCheck('f-val-per')) { const maxPer = getNum('p-max-per', 12); const per = row.forward_per; if (per == null || per <= 0 || per > maxPer) return false; } if (getCheck('f-val-pbr')) { const maxPbr = getNum('p-max-pbr', 1.0); const pbr = row.pbr; if (pbr == null || pbr > maxPbr) return false; } if (getCheck('f-val-roe')) { const minRoe = getNum('p-min-roe', 10); const roe = row.roe_fwd; if (roe == null || roe * 100 < minRoe) return false; } // 성장 조건 if (getCheck('f-grw-rev')) { const minRevG = getNum('p-rev-growth', 20); const revG = row.rev_growth; if (revG == null || revG * 100 < minRevG) return false; } if (getCheck('f-grw-op')) { const minOpG = getNum('p-op-growth', 20); const opG = row.op_growth; if (opG == null || opG * 100 < minOpG) return false; } if (getCheck('f-grw-margin')) { const minOpM = getNum('p-op-margin', 5); const opM = row.op_margin; if (opM == null || opM * 100 < minOpM) return false; } return true; } function getCheck(id) { return document.getElementById(id)?.checked ?? false; } function getNum(id, fallback) { const val = parseFloat(document.getElementById(id)?.value); return isNaN(val) ? fallback : val; } // ── 데이터 로드 ────────────────────────────────────────────────── async function loadData() { try { const requests = [ fetch('/api/screener'), fetch('/api/meta/bond-rate'), fetch('/api/companies'), ]; if (currentUser) requests.push(fetch('/api/watchlist')); const [screenerResp, bondResp, companiesResp, watchResp] = await Promise.all(requests); allRows = await screenerResp.json(); const bondData = await bondResp.json(); bondRate = bondData.rate; allCompanies = await companiesResp.json(); if (watchResp) { const watchData = await watchResp.json(); watchlistTickers = new Set(watchData.map(w => w.ticker)); } updateBondRateDisplay(bondData); if (allRows.length === 0) { document.getElementById('no-data-msg').style.display = 'block'; document.getElementById('screener-table').closest('.table-card').style.display = 'none'; } else { document.getElementById('no-data-msg').style.display = 'none'; renderTable(); } } catch (e) { console.error('데이터 로드 실패:', e); document.getElementById('no-data-msg').style.display = 'block'; } } function updateBondRateDisplay(bondData) { const el = document.getElementById('bond-rate-display'); if (bondData.rate != null) { const fetchedAt = bondData.fetched_at ? new Date(bondData.fetched_at).toLocaleDateString('ko-KR') : ''; el.textContent = `국채 3년물: ${bondData.rate.toFixed(2)}% (${fetchedAt})`; } else { el.textContent = '국채 3년물: 데이터 없음'; } } // ── 테이블 렌더링 ──────────────────────────────────────────────── function renderTable(rows) { const data = rows !== undefined ? rows : allRows; if (dtTable) { dtTable.destroy(); dtTable = null; $('#screener-table').empty(); } dtTable = $('#screener-table').DataTable({ data: data, columns: COLUMNS, order: [[4, 'desc']], pageLength: 50, scrollX: true, scrollY: "calc(100vh - 452px)", scrollCollapse: true, language: { search: '검색:', lengthMenu: '_MENU_ 개씩 보기', info: '_TOTAL_개 중 _START_–_END_', infoEmpty: '데이터 없음', infoFiltered: '(전체 _MAX_개 중 필터)', paginate: { first: '처음', previous: '이전', next: '다음', last: '마지막' }, emptyTable: '데이터가 없습니다.', zeroRecords: '조건에 맞는 종목이 없습니다.', loadingRecords: '로딩 중...', }, dom: 'rt<"dt-bottom"lip>', }); const el = document.getElementById('result-count'); if (el) el.textContent = `${data.length.toLocaleString('ko-KR')}개 종목`; } // ── 필터 적용 ──────────────────────────────────────────────────── function applyFilters() { const q = (document.getElementById('stock-search')?.value || '').trim().toLowerCase(); let filtered; if (q) { // 전체 종목 대상 검색 const screenerMap = new Map(allRows.map(r => [r.ticker, r])); filtered = allCompanies .filter(c => c.ticker.toLowerCase().includes(q) || (c.name || '').toLowerCase().includes(q)) .map(c => screenerMap.get(c.ticker) || { ticker: c.ticker, name: c.name, market: c.market, current_price: null, market_cap: null, is_stale: false, forward_per: null, pbr: null, roe_fwd: null, rev_growth: null, op_growth: null, op_margin: null, est_dividend_yield: null, payout_ratio: null, dividend_years: null, _no_consensus: true, }); } else { filtered = allRows.filter(passesFilter); } renderTable(filtered); } function onSearchInput() { applyFilters(); } // ── 인증 ───────────────────────────────────────────────────────── async function initAuth() { try { const resp = await fetch('/auth/me'); const data = await resp.json(); currentUser = data.user; } catch (e) { currentUser = null; } renderAuthArea(); } function renderAuthArea() { const area = document.getElementById('auth-area'); if (!area) return; if (currentUser) { area.innerHTML = `${currentUser.name}로그아웃`; } else { area.innerHTML = `구글로 로그인`; } } // ── 초기화 ─────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // 체크박스 변경 시 즉시 반영 const filterIds = [ 'f-div-yield', 'f-div-years', 'f-div-payout', 'f-val-per', 'f-val-pbr', 'f-val-roe', 'f-grw-rev', 'f-grw-op', 'f-grw-margin', ]; filterIds.forEach(id => { document.getElementById(id)?.addEventListener('change', applyFilters); }); // 숫자 입력 변경 시 체크박스 자동 체크 + 필터 적용 const paramMap = { 'p-spread': 'f-div-yield', 'p-div-years': 'f-div-years', 'p-max-payout': 'f-div-payout', 'p-max-per': 'f-val-per', 'p-max-pbr': 'f-val-pbr', 'p-min-roe': 'f-val-roe', 'p-rev-growth': 'f-grw-rev', 'p-op-growth': 'f-grw-op', 'p-op-margin': 'f-grw-margin', }; Object.entries(paramMap).forEach(([paramId, checkId]) => { document.getElementById(paramId)?.addEventListener('change', () => { const cb = document.getElementById(checkId); if (cb && !cb.checked) { cb.checked = true; } applyFilters(); }); }); document.getElementById('f-watchlist')?.addEventListener('change', applyFilters); initAuth().then(loadData); }); // ── 관심종목 토글 ──────────────────────────────────────────────── async function refreshPrice(ticker, el) { if (el.dataset.loading) return; el.dataset.loading = '1'; const original = el.textContent; el.textContent = '갱신중...'; el.style.opacity = '0.5'; try { const resp = await fetch(`/api/refresh/price/${ticker}`, { method: 'POST' }); if (!resp.ok) throw new Error((await resp.json()).detail || '갱신 실패'); const data = await resp.json(); // DataTables 내부 데이터 업데이트 + 현재가 의존 계산 필드 재계산 dtTable.rows().every(function () { const d = this.data(); if (d.ticker !== ticker) return; d.current_price = data.current_price; if (data.market_cap != null) d.market_cap = data.market_cap; d.forward_per = (d.current_price && d.est_eps) ? d.current_price / d.est_eps : null; d.pbr = (d.current_price && d.bps) ? d.current_price / d.bps : null; d.est_dividend_yield = (d.est_dps && d.current_price) ? (d.est_dps / d.current_price) * 100 : null; this.invalidate(); }); dtTable.draw(false); // allRows 동기화 (필터 재적용 시 정확한 값 사용) const row = allRows.find(r => r.ticker === ticker); if (row) { row.current_price = data.current_price; if (data.market_cap != null) row.market_cap = data.market_cap; row.forward_per = (row.current_price && row.est_eps) ? row.current_price / row.est_eps : null; row.pbr = (row.current_price && row.bps) ? row.current_price / row.bps : null; row.est_dividend_yield = (row.est_dps && row.current_price) ? (row.est_dps / row.current_price) * 100 : null; } } catch (e) { el.textContent = original; el.style.opacity = '1'; delete el.dataset.loading; console.error('가격 갱신 실패:', e); return; } el.style.opacity = '1'; delete el.dataset.loading; } async function toggleWatch(ticker) { if (!currentUser) { alert('관심종목을 사용하려면 로그인이 필요합니다.'); return; } try { if (watchlistTickers.has(ticker)) { await fetch(`/api/watchlist/${ticker}`, { method: 'DELETE' }); watchlistTickers.delete(ticker); } else { await fetch(`/api/watchlist/${ticker}`, { method: 'POST' }); watchlistTickers.add(ticker); } applyFilters(); } catch (e) { console.error('관심종목 오류:', e); } } // ── 수동 갱신 ──────────────────────────────────────────────────── const REFRESH_LABELS = { all: '전체 데이터', bond: '국채금리', price: '현재가', consensus: '컨센서스', }; function toggleRefreshMenu() { const menu = document.getElementById('refresh-menu'); menu.style.display = menu.style.display === 'none' ? 'block' : 'none'; } document.addEventListener('click', (e) => { if (!e.target.closest('.refresh-wrap')) { const menu = document.getElementById('refresh-menu'); if (menu) menu.style.display = 'none'; } }); async function triggerRefresh(type = 'all') { const url = type === 'all' ? '/api/refresh' : `/api/refresh/${type}`; const label = REFRESH_LABELS[type] || type; const btn = document.getElementById('refresh-btn'); document.getElementById('refresh-menu').style.display = 'none'; btn.disabled = true; btn.textContent = '수집 중...'; try { const resp = await fetch(url, { method: 'POST' }); const data = await resp.json(); if (data.status === 'already_running') { alert(`이미 수집 중입니다. 잠시 후 다시 시도해주세요.\n(진행 중: ${(data.running || []).join(', ')})`); } else { alert(`[${label}] 수집이 시작됐습니다.\n완료 후 페이지를 새로고침하면 최신 데이터를 볼 수 있습니다.`); } } catch (e) { alert('갱신 요청 실패: ' + e.message); } finally { btn.disabled = false; btn.textContent = '데이터 갱신'; } } // ── 종목 상세 모달 ─────────────────────────────────────────────── async function showDetail(ticker) { try { const resp = await fetch(`/api/stock/${ticker}`); const data = await resp.json(); renderModal(data); document.getElementById('modal-overlay').style.display = 'flex'; } catch (e) { alert('종목 정보를 불러오지 못했습니다: ' + e.message); } } function renderModal(data) { const historyItems = (data.history || []).map(h => ({ ...h, _estimate: false })); const consensusItems = (data.consensus || []).map(c => ({ ...c, _estimate: true })); const allItems = [...historyItems, ...consensusItems].sort((a, b) => b.year - a.year); const allRows = allItems.map(r => r._estimate ? ` ${r.year}E${r.is_stale ? ' ⚠' : ''} ${r.revenue != null ? Math.round(r.revenue).toLocaleString('ko-KR') : '-'} ${r.op_income != null ? Math.round(r.op_income).toLocaleString('ko-KR') : '-'} ${r.net_income != null ? Math.round(r.net_income).toLocaleString('ko-KR') : '-'} ${r.eps != null ? Math.round(r.eps).toLocaleString('ko-KR') : '-'} ${r.bps != null ? Math.round(r.bps).toLocaleString('ko-KR') : '-'} ${r.dividend_per_share != null ? Math.round(r.dividend_per_share).toLocaleString('ko-KR') : '-'} ${r.dividend_yield != null ? r.dividend_yield.toFixed(2) + '%' : '-'} ` : ` ${r.year} ${r.revenue != null ? Math.round(r.revenue).toLocaleString('ko-KR') : '-'} ${r.op_income != null ? Math.round(r.op_income).toLocaleString('ko-KR') : '-'} ${r.net_income != null ? Math.round(r.net_income).toLocaleString('ko-KR') : '-'} ${r.eps != null ? Math.round(r.eps).toLocaleString('ko-KR') : '-'} ${r.bps != null ? Math.round(r.bps).toLocaleString('ko-KR') : '-'} ${r.dividend_per_share != null ? r.dividend_per_share.toLocaleString('ko-KR') : '-'} - `).join(''); document.getElementById('modal-content').innerHTML = `

실적 (억원)

${allRows || ''}
연도매출액영업이익순이익EPS(원)BPS(원)주당배당(원)배당수익률
데이터 없음
`; } function closeModal() { document.getElementById('modal-overlay').style.display = 'none'; } // ── AI 분석 (Perplexity) ───────────────────────────────────────── function _fmt억(v) { return v != null ? Math.round(v).toLocaleString('ko-KR') : '-'; } function _fmt원(v) { return v != null ? Math.round(v).toLocaleString('ko-KR') : '-'; } function buildAnalysisPrompt(data) { const price = data.current_price != null ? data.current_price.toLocaleString('ko-KR') + '원' : '-'; const cap = data.market_cap != null ? Math.round(data.market_cap).toLocaleString('ko-KR') + '억원' : '-'; const historyLines = (data.history || []).map(h => `${h.year} | ${_fmt억(h.revenue)} | ${_fmt억(h.op_income)} | ${_fmt억(h.net_income)} | ${_fmt원(h.dividend_per_share)}` ).join('\n'); // 컨센서스는 서버에서 이미 confirmed 연도 제외 후 반환됨 const consensusLines = (data.consensus || []) .map(c => `${c.year}E | ${_fmt억(c.revenue)} | ${_fmt억(c.op_income)} | ${_fmt억(c.net_income)} | ${_fmt원(c.eps)} | ${_fmt원(c.dividend_per_share)} | ${_fmt원(c.bps)}`) .join('\n'); // 재무안정성: 가장 최근 history 기준 (WiseReport 확정연도 비율 직접 사용) const recent = (data.history || [])[0]; let stabilityLine = 'N/A'; if (recent) { const fmtRatio = (v) => v != null ? v.toFixed(1) + '%' : 'N/A'; const fmtX = (v) => v != null ? v.toFixed(1) + 'x' : 'N/A'; const fmtAmt = (v) => v != null ? Math.round(v).toLocaleString('ko-KR') + '억' : 'N/A'; stabilityLine = `부채비율: ${fmtRatio(recent.debt_ratio)} / ROE: ${fmtRatio(recent.roe)} / ROA: ${fmtRatio(recent.roa)} / 이자발생부채: ${fmtAmt(recent.interest_bearing_debt)}`; } // 밸류에이션: 현재 연도 컨센서스 우선, 없으면 eps가 있는 첫 번째 행 const currentYear = new Date().getFullYear(); const cons = (data.consensus || []).find(c => c.year === currentYear) || (data.consensus || []).find(c => c.eps != null); let valuationLine = 'N/A'; if (cons && data.current_price) { const fmtV = (v) => v != null ? v.toFixed(2) : 'N/A'; const fwdPer = (cons.eps > 0) ? data.current_price / cons.eps : null; const pbr = (cons.bps > 0) ? data.current_price / cons.bps : null; const divYield = (cons.dividend_per_share > 0) ? cons.dividend_per_share / data.current_price * 100 : null; const payout = (cons.eps > 0 && cons.dividend_per_share > 0) ? cons.dividend_per_share / cons.eps * 100 : null; const roe = (cons.bps > 0 && cons.eps > 0) ? cons.eps / cons.bps * 100 : null; valuationLine = `Forward PER: ${fmtV(fwdPer)}x / PBR: ${fmtV(pbr)}x / 배당수익률: ${fmtV(divYield)}% / 배당성향: ${fmtV(payout)}% / ROE: ${fmtV(roe)}%`; } return `${data.name}(${data.ticker}) ${data.market || ''} 투자 분석 [기본정보] 현재가: ${price} / 시가총액: ${cap} [확정실적 (억원, 최근5년)] 연도 | 매출액 | 영업이익 | 순이익 | 주당배당(원) ${historyLines || '데이터 없음'} [재무안정성 (최근 확정연도 기준: 부채비율/ROE/ROA/이자발생부채)] ${stabilityLine} [컨센서스 (억원)] 연도 | 매출액 | 영업이익 | 순이익 | EPS(원) | 주당배당(원) | BPS(원) ${consensusLines || '데이터 없음'} [컨센서스 기준 밸류에이션] ${valuationLine} 위 데이터를 바탕으로 다음을 분석해줘. ※ PER, PBR, 배당수익률, ROE 등 수치는 위 [컨센서스 기준 밸류에이션] 섹션의 값을 사용해. 외부 데이터는 위 데이터에 없는 정보(기업 개요, 사업 내용, 산업 동향 등)를 보완할 때만 활용해. 1. 기업 개요 및 주요 사업 2. 속한 산업과 시장 성장성 3. 재무제표 분석 (성장 추이, 수익성) 4. 재무 안정성 (유동성, 부채 수준, 이자 상환 능력) 5. 배당주로서의 적합성 (배당 이력, 배당성향, 지속성) 6. 가치주로서의 적합성 (현재 밸류에이션 수준) 7. 성장주로서의 적합성 (성장률, 미래 전망) 8. 주요 리스크와 투자 기회 요인`; } async function analyzeWithAI(ticker) { try { const resp = await fetch(`/api/stock/${ticker}`); const data = await resp.json(); const prompt = buildAnalysisPrompt(data); const url = 'https://www.perplexity.ai/search?q=' + encodeURIComponent(prompt); window.open(url, '_blank'); } catch (e) { alert('분석 데이터 로드 실패: ' + e.message); } } // ── 피드백 ──────────────────────────────────────────────────────── function openFeedbackModal() { document.getElementById('feedback-overlay').style.display = 'flex'; loadFeedback(); } function closeFeedbackModal() { document.getElementById('feedback-overlay').style.display = 'none'; } async function loadFeedback() { const listEl = document.getElementById('feedback-list'); const formEl = document.getElementById('feedback-form-area'); listEl.innerHTML = '

불러오는 중...

'; try { const resp = await fetch('/api/feedback'); const items = await resp.json(); if (items.length === 0) { listEl.innerHTML = '

아직 의견이 없습니다. 첫 번째 의견을 남겨보세요!

'; } else { listEl.innerHTML = items.map(item => { const date = item.created_at ? item.created_at.slice(0, 10) : ''; const deleteBtn = item.is_mine ? `` : ''; return `
${deleteBtn}
${escapeHtml(item.content)}
`; }).join(''); } } catch (e) { listEl.innerHTML = '

불러오기 실패

'; } if (currentUser) { formEl.innerHTML = `
`; document.getElementById('feedback-textarea').addEventListener('input', function() { document.getElementById('feedback-char-count').textContent = `${this.value.length} / 500`; }); } else { formEl.innerHTML = `
Google 로그인 후 의견을 남길 수 있습니다
`; } } async function submitFeedback() { const ta = document.getElementById('feedback-textarea'); const content = ta.value.trim(); if (!content) { alert('내용을 입력해 주세요.'); return; } try { const resp = await fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), }); if (!resp.ok) { const err = await resp.json(); alert(err.detail || '등록 실패'); return; } ta.value = ''; document.getElementById('feedback-char-count').textContent = '0 / 500'; loadFeedback(); } catch (e) { alert('등록 실패: ' + e.message); } } async function deleteFeedback(id) { if (!confirm('댓글을 삭제할까요?')) return; try { const resp = await fetch(`/api/feedback/${id}`, { method: 'DELETE' }); if (!resp.ok) { const err = await resp.json(); alert(err.detail || '삭제 실패'); return; } loadFeedback(); } catch (e) { alert('삭제 실패: ' + e.message); } } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }