StocKing / static /app.js
adoomy's picture
fix: μ’…λͺ©λͺ… 검색 μ‹œ μ»¨μ„Όμ„œμŠ€ μ—†λŠ” μ’…λͺ© fallback에 λˆ„λ½λœ DataTables 컬럼 ν•„λ“œ μΆ”κ°€
44adcb5
/**
* ν•œκ΅­ 주식 μŠ€ν¬λ¦¬λ„ˆ β€” 톡합 ν•„ν„° (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) => `<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();
// 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 ? `
<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>
&nbsp;Β·&nbsp;
μ‹œκ°€μ΄μ•‘ <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';
}
// ── 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 = '<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}