| |
| |
| |
| |
| |
|
|
| let allRows = []; |
| let allCompanies = []; |
| let dtTable = null; |
| let bondRate = null; |
| let watchlistTickers = new Set(); |
| let currentUser = null; |
|
|
| |
|
|
| const COLUMNS = [ |
| { title: '', data: 'ticker', orderable: false, width: '32px', |
| render: (d) => `<button class="star-btn${watchlistTickers.has(d) ? ' starred' : ''}" onclick="toggleWatch('${d}');return false;">${watchlistTickers.has(d) ? 'β
' : 'β'}</button>` }, |
| { title: 'μ’
λͺ©μ½λ', data: 'ticker', |
| render: (d) => `<a href="https://finance.naver.com/item/coinfo.nhn?code=${d}&target=finsum_more" target="_blank">${d}</a>` }, |
| { title: 'μ’
λͺ©λͺ
', data: 'name', |
| render: (d, t, row) => `<a href="#" onclick="showDetail('${row.ticker}');return false;">${d}</a>` }, |
| { title: 'νμ¬κ°', data: 'current_price', |
| render: (d, t, row) => |
| `<span class="price-cell" onclick="refreshPrice('${row.ticker}', this)" title="ν΄λ¦νμ¬ νμ¬κ° κ°±μ ">${formatPrice(d)}</span>` }, |
| { 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}<span class="yield-spread">(${sign}${spread})</span>`; |
| } }, |
| { 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 = `<span class="auth-name">${currentUser.name}</span><a href="/auth/logout" class="auth-btn">λ‘κ·Έμμ</a>`; |
| } else { |
| area.innerHTML = `<a href="/auth/google" class="auth-btn login-btn">ꡬκΈλ‘ λ‘κ·ΈμΈ</a>`; |
| } |
| } |
|
|
| |
|
|
| 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(); |
|
|
| |
| 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); |
|
|
| |
| 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 ? ` |
| <tr class="${r.is_stale ? 'stale-row' : 'estimate-row'}"> |
| <td>${r.year}E${r.is_stale ? ' β ' : ''}</td> |
| <td>${r.revenue != null ? Math.round(r.revenue).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.op_income != null ? Math.round(r.op_income).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.net_income != null ? Math.round(r.net_income).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.eps != null ? Math.round(r.eps).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.bps != null ? Math.round(r.bps).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.dividend_per_share != null ? Math.round(r.dividend_per_share).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.dividend_yield != null ? r.dividend_yield.toFixed(2) + '%' : '-'}</td> |
| </tr> |
| ` : ` |
| <tr> |
| <td>${r.year}</td> |
| <td>${r.revenue != null ? Math.round(r.revenue).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.op_income != null ? Math.round(r.op_income).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.net_income != null ? Math.round(r.net_income).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.eps != null ? Math.round(r.eps).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.bps != null ? Math.round(r.bps).toLocaleString('ko-KR') : '-'}</td> |
| <td>${r.dividend_per_share != null ? r.dividend_per_share.toLocaleString('ko-KR') : '-'}</td> |
| <td>-</td> |
| </tr> |
| `).join(''); |
|
|
| document.getElementById('modal-content').innerHTML = ` |
| <div class="modal-header"> |
| <h2>${data.name} <small>${data.ticker} Β· ${data.market || ''}</small></h2> |
| <button class="btn-ai-analyze" onclick="analyzeWithAI('${data.ticker}')">π€ AI λΆμ</button> |
| </div> |
| <p class="modal-meta"> |
| νμ¬κ° <strong>${data.current_price != null ? data.current_price.toLocaleString('ko-KR') + 'μ' : '-'}</strong> |
| Β· |
| μκ°μ΄μ‘ <strong>${data.market_cap != null ? Math.round(data.market_cap).toLocaleString('ko-KR') + 'μ΅' : '-'}</strong> |
| </p> |
| <h3>μ€μ <span class="unit">(μ΅μ)</span></h3> |
| <div class="table-wrap"> |
| <table class="detail-table"> |
| <thead><tr><th>μ°λ</th><th>λ§€μΆμ‘</th><th>μμ
μ΄μ΅</th><th>μμ΄μ΅</th><th>EPS(μ)</th><th>BPS(μ)</th><th>μ£ΌλΉλ°°λΉ(μ)</th><th>λ°°λΉμμ΅λ₯ </th></tr></thead> |
| <tbody>${allRows || '<tr><td colspan="8" class="empty">λ°μ΄ν° μμ</td></tr>'}</tbody> |
| </table> |
| </div> |
| `; |
| } |
|
|
| function closeModal() { |
| document.getElementById('modal-overlay').style.display = 'none'; |
| } |
|
|
| |
|
|
| 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'); |
|
|
| |
| 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'); |
|
|
| |
| 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)}`; |
| } |
|
|
| |
| 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 = '<p class="feedback-loading">λΆλ¬μ€λ μ€...</p>'; |
|
|
| try { |
| const resp = await fetch('/api/feedback'); |
| const items = await resp.json(); |
|
|
| if (items.length === 0) { |
| listEl.innerHTML = '<p class="feedback-empty">μμ§ μκ²¬μ΄ μμ΅λλ€. 첫 λ²μ§Έ μ견μ λ¨κ²¨λ³΄μΈμ!</p>'; |
| } else { |
| listEl.innerHTML = items.map(item => { |
| const date = item.created_at ? item.created_at.slice(0, 10) : ''; |
| const deleteBtn = item.is_mine |
| ? `<button class="feedback-delete" onclick="deleteFeedback(${item.id})">μμ </button>` |
| : ''; |
| return ` |
| <div class="feedback-item"> |
| <div class="feedback-meta"> |
| <span class="feedback-author">${escapeHtml(item.author_name)}</span> |
| <span class="feedback-date">${date}</span> |
| ${deleteBtn} |
| </div> |
| <div class="feedback-content">${escapeHtml(item.content)}</div> |
| </div>`; |
| }).join(''); |
| } |
| } catch (e) { |
| listEl.innerHTML = '<p class="feedback-empty">λΆλ¬μ€κΈ° μ€ν¨</p>'; |
| } |
|
|
| if (currentUser) { |
| formEl.innerHTML = ` |
| <div class="feedback-write"> |
| <textarea id="feedback-textarea" class="feedback-textarea" maxlength="500" |
| placeholder="μλΉμ€μ λν μ견μ μμ λ‘κ² λ¨κ²¨μ£ΌμΈμ. (μ΅λ 500μ)"></textarea> |
| <div class="feedback-write-footer"> |
| <span id="feedback-char-count" class="feedback-char-count">0 / 500</span> |
| <button class="btn-apply" onclick="submitFeedback()">λ±λ‘</button> |
| </div> |
| </div>`; |
| document.getElementById('feedback-textarea').addEventListener('input', function() { |
| document.getElementById('feedback-char-count').textContent = `${this.value.length} / 500`; |
| }); |
| } else { |
| formEl.innerHTML = ` |
| <div class="feedback-login-prompt"> |
| <a href="/auth/google" class="btn-google-login">Google λ‘κ·ΈμΈ ν μ견μ λ¨κΈΈ μ μμ΅λλ€</a> |
| </div>`; |
| } |
| } |
|
|
| 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, '>') |
| .replace(/"/g, '"'); |
| } |
|
|