/** * 한국 주식 스크리너 — 통합 필터 (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 ? `
| 연도 | 매출액 | 영업이익 | 순이익 | EPS(원) | BPS(원) | 주당배당(원) | 배당수익률 |
|---|---|---|---|---|---|---|---|
| 데이터 없음 | |||||||
불러오는 중...
'; 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 `불러오기 실패
'; } if (currentUser) { formEl.innerHTML = `