Files
trading/dashboard-api/index.html
T
kyu 6d3b0bacc0 Initial commit: Korean stock value-investing AI pipeline
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector,
  aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등)
- 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/
  모멘텀/Beneish/GP-A/G-Score/Amihud/BAB)
- 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인
- 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:33:56 +09:00

1728 lines
106 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trading AI Dashboard</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0 24px 40px}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:3px}
.wrap{max-width:1400px;margin:0 auto}
.header{display:flex;justify-content:space-between;align-items:center;padding:20px 0;border-bottom:1px solid rgba(255,255,255,0.06);margin-bottom:24px}
.header h1{font-size:22px;font-weight:800;letter-spacing:-0.5px}.header h1 span{color:#00E676}
.header .sub{font-size:12px;color:#546E7A;margin-top:4px}
.header .time{font-size:13px;color:#607D8B;font-family:'JetBrains Mono',monospace}
.btn{background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;cursor:pointer;font-weight:600}
.tabs{display:flex;gap:4px;margin-bottom:24px}
.tab{background:transparent;border:1px solid transparent;border-radius:8px;padding:8px 16px;color:#607D8B;font-size:13px;font-weight:500;cursor:pointer}
.tab.active{background:rgba(0,230,118,0.1);border-color:rgba(0,230,118,0.2);color:#00E676;font-weight:700}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px}
.card{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:24px 20px}
.card .label{font-size:13px;color:#90A4AE;letter-spacing:1px;margin-bottom:8px}
.card .val{font-size:36px;font-weight:800;font-family:'JetBrains Mono',monospace;line-height:1}
.card .sub{font-size:12px;color:#607D8B;margin-top:8px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
.panel{background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:16px;padding:20px;max-height:600px;overflow:auto}
.panel h3{font-size:14px;font-weight:700;color:#90A4AE;margin-bottom:16px}
.rank-item{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
.rank-num{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
.rank-num.top{background:rgba(0,230,118,0.15);color:#00E676}
.rank-num.normal{background:rgba(255,255,255,0.04);color:#546E7A}
.news-item{display:flex;gap:12px;padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
.news-icon{min-width:40px;text-align:center}
.news-icon .arrow{font-size:18px}.news-icon .score{font-size:10px;font-weight:700}
.news-body{flex:1;min-width:0}
.news-title{font-size:13px;color:#CFD8DC;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.news-meta{display:flex;gap:8px;margin-top:4px;font-size:11px}
.news-reason{font-size:11px;color:#607D8B;margin-top:4px;line-height:1.4}
.badge{border-radius:6px;padding:4px 10px;font-size:12px;font-weight:600;height:fit-content;white-space:nowrap}
.rec-card{background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:12px;padding:16px}
.rec-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}
.score-bar{width:100%;height:6px;background:rgba(255,255,255,0.05);border-radius:3px;overflow:hidden;margin-top:8px}
.score-fill{height:100%;border-radius:3px;transition:width 0.8s ease}
.alert-item{border-radius:10px;padding:12px;margin-bottom:8px}
.empty{color:#546E7A;font-size:13px;padding:40px;text-align:center}
.footer{margin-top:40px;padding:16px 0;border-top:1px solid rgba(255,255,255,0.04);text-align:center;font-size:11px;color:#37474F}
.signal-card{background:rgba(255,255,255,0.02);border-radius:14px;padding:18px;margin-bottom:12px}
.signal-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px}
.price-row{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:10px;font-size:12px}
.price-box{background:rgba(255,255,255,0.04);border-radius:8px;padding:8px;text-align:center}
.price-box .label{font-size:10px;color:#78909C;margin-bottom:4px}
.price-box .val{font-size:15px;font-weight:700;font-family:'JetBrains Mono',monospace}
.ta-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.ta-badge{background:rgba(255,255,255,0.05);border-radius:6px;padding:3px 8px;font-size:10px;color:#90A4AE}
.ta-badge.bull{background:rgba(0,230,118,0.08);color:#69F0AE}
.ta-badge.bear{background:rgba(255,82,82,0.08);color:#FF8A80}
.candidates-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px}
.cand-card{background:rgba(255,255,255,0.02);border:1px solid rgba(0,230,118,0.12);border-radius:14px;padding:18px}
/* ── 챗봇 ── */
#chat-btn{position:fixed;bottom:28px;right:28px;width:52px;height:52px;border-radius:50%;background:linear-gradient(135deg,#00C853,#00897B);border:none;cursor:pointer;font-size:22px;box-shadow:0 4px 20px rgba(0,200,83,0.35);z-index:1000;transition:transform .2s}
#chat-btn:hover{transform:scale(1.08)}
#chat-panel{position:fixed;bottom:92px;right:28px;width:380px;height:560px;background:#141B22;border:1px solid rgba(0,230,118,0.2);border-radius:20px;box-shadow:0 8px 40px rgba(0,0,0,0.6);z-index:999;display:none;flex-direction:column;overflow:hidden}
#chat-header{padding:16px 18px;border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:space-between}
#chat-header .title{font-size:14px;font-weight:700;color:#00E676}
#chat-header .subtitle{font-size:10px;color:#546E7A;margin-top:2px}
#chat-close{background:none;border:none;color:#546E7A;cursor:pointer;font-size:18px;padding:0}
#chat-messages{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.msg{max-width:85%;padding:10px 13px;border-radius:14px;font-size:13px;line-height:1.55;word-break:break-word}
.msg.user{align-self:flex-end;background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.2);color:#E0E0E0;border-bottom-right-radius:4px}
.msg.ai{align-self:flex-start;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.06);color:#CFD8DC;border-bottom-left-radius:4px}
.msg.ai a{color:#00E676}.msg p{margin:4px 0}.msg ul,.msg ol{padding-left:16px;margin:4px 0}
.msg strong{color:#fff}.msg code{background:rgba(0,0,0,0.3);padding:1px 5px;border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:11px}
.typing{display:flex;gap:4px;padding:8px 13px;align-self:flex-start}
.typing span{width:6px;height:6px;border-radius:50%;background:#546E7A;animation:bounce .8s infinite}
.typing span:nth-child(2){animation-delay:.15s}.typing span:nth-child(3){animation-delay:.3s}
@keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}
#chat-input-row{padding:12px 14px;border-top:1px solid rgba(255,255,255,0.06);display:flex;gap:8px}
#chat-input{flex:1;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:10px;padding:9px 12px;color:#E0E0E0;font-size:13px;font-family:'Noto Sans KR',sans-serif;outline:none;resize:none}
#chat-input:focus{border-color:rgba(0,230,118,0.3)}
#chat-send{background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.25);border-radius:10px;padding:9px 14px;color:#00E676;cursor:pointer;font-size:16px}
#chat-send:hover{background:rgba(0,230,118,0.25)}
#chat-send:disabled{opacity:0.4;cursor:default}
.kospi-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:12px;padding:20px;margin-bottom:20px}
.kospi-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}
.kospi-price{font-size:28px;font-weight:800;font-family:'JetBrains Mono',monospace}
.kospi-change{font-size:14px;font-weight:600;margin-top:4px}
.kospi-tip{background:rgba(0,230,118,0.06);border:1px solid rgba(0,230,118,0.15);border-radius:10px;padding:14px 18px;margin-bottom:20px}
.kospi-tip .tip-tag{font-size:10px;font-weight:700;color:#00E676;letter-spacing:1px;text-transform:uppercase;margin-bottom:6px}
.kospi-tip .tip-title{font-size:15px;font-weight:700;color:#E0E0E0;margin-bottom:6px}
.kospi-tip .tip-body{font-size:13px;color:#90A4AE;line-height:1.7}
.market-tip-box{background:rgba(255,193,7,0.06);border:1px solid rgba(255,193,7,0.2);border-radius:10px;padding:14px 18px;margin-top:12px}
.market-tip-box .mtip-label{font-size:11px;font-weight:700;color:#FFC107;margin-bottom:6px}
.market-tip-box .mtip-body{font-size:13px;color:#CFD8DC;line-height:1.7}
canvas#kospiChart{width:100%!important}
/* 클릭 가능한 종목 셀 호버 효과 */
[data-stock]{transition:background .15s, transform .1s}
[data-stock]:hover{background:rgba(0,230,118,0.06) !important}
tr[data-stock]:hover{background:rgba(0,230,118,0.06) !important}
.rec-card[data-stock]:hover, .cand-card[data-stock]:hover, .rank-item[data-stock]:hover, .signal-card[data-stock]:hover{
border-color:rgba(0,230,118,0.35) !important;
transform:translateY(-1px);
}
/* 로그인 게이트: 비인증 시 .wrap, #chat-btn 숨김 */
body.auth-required .wrap, body.auth-required #chat-btn, body.auth-required #chat-panel { display:none !important; }
body:not(.auth-required) #login-screen { display:none !important; }
#login-screen{position:fixed;inset:0;background:#0E141A;display:flex;align-items:center;justify-content:center;padding:20px;z-index:100;overflow-y:auto}
.login-card{background:#141B22;border:1px solid rgba(0,230,118,0.18);border-radius:20px;padding:40px;width:420px;max-width:100%;box-shadow:0 12px 60px rgba(0,0,0,0.6)}
.login-logo{font-size:24px;font-weight:800;color:#E0E0E0;margin-bottom:6px;letter-spacing:0.5px}
.login-logo span{color:#00E676}
.login-sub{font-size:12px;color:#78909C;margin-bottom:28px}
.login-tab{display:flex;gap:0;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,0.08)}
.login-tab button{flex:1;background:none;border:none;border-bottom:2px solid transparent;padding:12px 0;color:#78909C;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s}
.login-tab button.active{color:#00E676;border-bottom-color:#00E676}
.login-field{margin-bottom:16px}
.login-field label{display:block;font-size:11px;color:#78909C;margin-bottom:6px}
.login-field input{width:100%;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:10px;padding:12px 14px;color:#E0E0E0;font-size:14px;outline:none;box-sizing:border-box;transition:border-color .15s}
.login-field input:focus{border-color:rgba(0,230,118,0.4)}
.login-submit{width:100%;background:linear-gradient(135deg,#00C853,#00897B);border:none;border-radius:12px;padding:13px;color:#fff;font-size:14px;font-weight:700;cursor:pointer;margin-top:8px;transition:transform .1s,opacity .15s}
.login-submit:hover{transform:translateY(-1px)}
.login-submit:disabled{opacity:0.6;cursor:not-allowed}
.login-error{background:rgba(255,82,82,0.1);border:1px solid rgba(255,82,82,0.3);border-radius:10px;padding:10px 14px;color:#FF8A80;font-size:12px;margin-bottom:14px;line-height:1.5}
.login-info{background:rgba(0,230,118,0.08);border:1px solid rgba(0,230,118,0.25);border-radius:10px;padding:12px 14px;color:#69F0AE;font-size:12px;margin-bottom:14px;line-height:1.5}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-financial@0.2.1/dist/chartjs-chart-financial.min.js"></script>
</head>
<body class="auth-required">
<!-- 로그인/회원가입 풀페이지 (비인증 기본 표시) -->
<div id="login-screen">
<div class="login-card">
<div class="login-logo"><span>Trading AI</span></div>
<div class="login-sub">로그인 후 이용하실 수 있습니다</div>
<div class="login-tab">
<button id="lg-tab-login" class="active" onclick="setAuthMode('login')">로그인</button>
<button id="lg-tab-reg" onclick="setAuthMode('register')">회원가입</button>
</div>
<div id="lg-info" class="login-info" style="display:none"></div>
<div id="lg-error" class="login-error" style="display:none"></div>
<div class="login-field">
<label>이메일</label>
<input id="lg-email" type="email" autocomplete="email" placeholder="you@example.com">
</div>
<div class="login-field">
<label id="lg-pw-label">비밀번호</label>
<input id="lg-password" type="password" autocomplete="current-password" placeholder="••••••••"
onkeypress="if(event.key==='Enter')submitAuth()">
</div>
<button id="lg-submit" class="login-submit" onclick="submitAuth()">로그인</button>
<div style="text-align:center;margin-top:16px;font-size:11px;color:#546E7A">
Trading AI · 워렌 버핏 스타일 가치투자 분석
</div>
</div>
</div>
<!-- 챗봇 -->
<button id="chat-btn" onclick="toggleChat()" title="AI 애널리스트">💬</button>
<div id="chat-panel">
<div id="chat-header">
<div><div class="title">◆ AI 애널리스트</div><div class="subtitle">exaone3.5 · 버핏 스타일 분석</div></div>
<button id="chat-close" onclick="toggleChat()"></button>
</div>
<div id="chat-messages">
<div class="msg ai">안녕하세요! 오늘 시장 현황이나 종목에 대해 물어보세요.<br><small style="color:#546E7A">예: "오늘 추천종목 알려줘" / "SK하이닉스 분석해줘"</small></div>
</div>
<div id="chat-input-row">
<textarea id="chat-input" rows="1" placeholder="메시지 입력..." onkeydown="chatKey(event)"></textarea>
<button id="chat-send" onclick="sendChat()"></button>
</div>
</div>
<div class="wrap">
<div class="header">
<div><h1><span></span> Trading AI Dashboard</h1><div class="sub">뉴스 + DART 공시 기반 종목 분석 시스템</div></div>
<div style="display:flex;align-items:center;gap:16px">
<div class="time" id="clock"></div>
<div id="auth-area" style="display:flex;align-items:center;gap:8px;font-size:12px"></div>
<button class="btn" onclick="loadAll()">↻ 새로고침</button>
</div>
</div>
<div style="margin-bottom:16px;display:flex;gap:8px">
<input id="searchInput" type="text" placeholder="종목명 또는 종목코드 검색 (예: 삼성전자, 005930)"
style="flex:1;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:10px 16px;color:#E0E0E0;font-size:14px;font-family:'Noto Sans KR',sans-serif;outline:none"
onkeypress="if(event.key==='Enter')doSearch()">
<button onclick="doSearch()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:10px 20px;color:#00E676;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap">🔍 검색</button>
</div>
<div id="searchResults" style="display:none;margin-bottom:24px"></div>
<div class="tabs">
<button class="tab active" onclick="showTab('overview',this)">📊 종합</button>
<button class="tab" onclick="showTab('performance',this)">🎯 추천성과</button>
<button class="tab" onclick="showTab('signals',this)">💰 매매시그널</button>
<button class="tab" onclick="showTab('surge',this)">🚀 거래량급증</button>
<button class="tab" onclick="showTab('portfolio',this)">💼 포트폴리오</button>
<button class="tab" onclick="showTab('mystock',this)">📂 내 종목 분석</button>
<button class="tab" onclick="showTab('recommend',this)">🏆 추천종목</button>
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
</div>
<div id="overview"></div>
<div id="performance" style="display:none"></div>
<div id="signals" style="display:none"></div>
<div id="surge" style="display:none"></div>
<div id="portfolio" style="display:none"></div>
<div id="mystock" style="display:none"></div>
<div id="recommend" style="display:none"></div>
<div id="news" style="display:none"></div>
<div id="supplydemand" style="display:none"></div>
<div id="alerts" style="display:none"></div>
<div class="footer">Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요</div>
</div>
<script>
const API = window.location.origin + '/api';
const SCOL = {호재:'#00E676',악재:'#FF5252',중립:'#78909C'};
const SICON = {호재:'▲',악재:'▼',중립:'●'};
const RCOL = {강력매수:'#00E676',매수관심:'#69F0AE',관망:'#78909C',매도관심:'#FF8A80',강력매도:'#FF5252'};
function ftime(ts){if(!ts)return'-';const d=new Date(ts),m=Math.floor((Date.now()-d)/60000);if(m<60)return m+'분 전';if(m<1440)return Math.floor(m/60)+'시간 전';return d.toLocaleDateString('ko-KR',{month:'short',day:'numeric'})}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function showTab(id,el){
['overview','performance','signals','surge','portfolio','mystock','recommend','news','supplydemand','alerts'].forEach(t=>{
document.getElementById(t).style.display=t===id?'block':'none'});
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
if(el)el.classList.add('active');
if(id==='mystock'){ if(isLoggedIn()) loadPortfolio().then(()=>renderMyStock()); else renderMyStock(); }
if(id==='portfolio') renderPortfolio();
if(id==='performance') renderPerformance();
if(id==='supplydemand') renderSupplyDemand();
if(id==='surge') renderVolumeSurge();
}
async function renderVolumeSurge(){
const el=document.getElementById('surge');
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">키움증권 실시간 거래량 급증 상위 50종목 (5분 캐시)</div>
<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
try{
const d=await api('/volume-surge');
const list=d?.data||[];
if(!list.length){el.innerHTML='<div class="empty">데이터가 없습니다 (장 마감 상태일 수 있음)</div>';return;}
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">키움증권 실시간 거래량 급증 상위 ${list.length}종목 ${d.cached?'<span style="color:#90A4AE">(캐시)</span>':'<span style="color:#69F0AE">(실시간)</span>'}</div>`;
h+=`<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">`;
list.forEach((r,i)=>{
const chgC=r.change_pct>=0?'#00E676':'#FF5252';
const surgeC=r.surge_rate>=500?'#FFD740':r.surge_rate>=200?'#69F0AE':'#90A4AE';
h+=`<div data-stock="${r.code}" data-stock-name="${esc(r.name)}" style="cursor:pointer;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:10px;padding:12px 14px;transition:background .15s" onmouseover="this.style.background='rgba(255,255,255,0.06)'" onmouseout="this.style.background='rgba(255,255,255,0.03)'">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
<div>
<div style="font-size:13px;font-weight:700;color:#E0E0E0">${esc(r.name||r.code)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${r.code}</div>
</div>
<div style="font-size:11px;color:${surgeC};font-weight:700">×${(r.surge_rate||0).toFixed(0)}%</div>
</div>
<div style="display:flex;justify-content:space-between;font-size:11px;color:#90A4AE">
<span>${fmt(r.price)}원 <b style="color:${chgC}">${r.change_pct>=0?'+':''}${(r.change_pct||0).toFixed(2)}%</b></span>
<span>거래량 ${(r.volume/10000).toFixed(0)}만</span>
</div>
</div>`;
});
h+=`</div>`;
el.innerHTML=h;
}catch(e){
el.innerHTML='<div class="empty">로드 실패: '+esc(e.message)+'</div>';
}
}
async function api(path){try{const r=await fetch(API+path);return await r.json()}catch(e){return null}}
async function loadAll(){
const [summary,ranking,recs,avoid,recent,alerts,signals,cands,kospi,tip]=await Promise.all([
api('/summary'),api('/ranking'),api('/recommendations'),api('/avoid'),
api('/recent?limit=40'),api('/alerts'),api('/signals?days=3&limit=60'),api('/buy-candidates?limit=20'),
api('/kospi?days=30'),api('/daily-tip')
]);
renderOverview(summary,ranking,recent,kospi,tip);
renderSignals(signals,cands);
renderRecs(recs,avoid);
renderNews(recent);
renderAlerts(alerts);
}
function fmt(n){return n?Number(n).toLocaleString():'0'}
function pct(n){return n?(n>0?'+':'')+Number(n).toFixed(1)+'%':'0%'}
const INP_STYLE='width:100%;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 14px;color:#E0E0E0;font-size:14px;outline:none;box-sizing:border-box';
// ── 인증 (JWT) ─────────────────────────────────────────────
let _authToken = localStorage.getItem('auth_token') || '';
let _authUser = JSON.parse(localStorage.getItem('auth_user') || 'null');
let _authMode = 'login'; // 'login' | 'register'
function isLoggedIn(){return !!_authToken}
function isAdmin(){return _authUser?.role==='admin'}
function setAuth(token,user){_authToken=token;_authUser=user;localStorage.setItem('auth_token',token);localStorage.setItem('auth_user',JSON.stringify(user));renderAuthArea()}
function clearAuth(){_authToken='';_authUser=null;localStorage.removeItem('auth_token');localStorage.removeItem('auth_user');_portfolio=[];renderAuthArea()}
function authHeaders(){return _authToken?{'Authorization':'Bearer '+_authToken}:{}}
async function authedJson(path,opts={}){
const headers={'Content-Type':'application/json',...authHeaders(),...(opts.headers||{})};
const r=await fetch(API+path,{...opts,headers});
if(r.status===401){clearAuth();showLoginPage();throw new Error('unauthorized');}
if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.detail||('HTTP '+r.status));}
return r.json();
}
// 화면 게이트
function showDashboard(){document.body.classList.remove('auth-required');renderAuthArea();}
function showLoginPage(){
document.body.classList.add('auth-required');
setAuthMode('login');
document.getElementById('lg-info').style.display='none';
document.getElementById('lg-error').style.display='none';
document.getElementById('lg-password').value='';
setTimeout(()=>document.getElementById('lg-email')?.focus(),50);
}
function renderAuthArea(){
const el=document.getElementById('auth-area');
if(!el||!isLoggedIn()) return;
const adminBtn=isAdmin()?`<button class="btn" onclick="showAdminModal()" style="font-size:11px;padding:4px 10px;background:rgba(255,193,7,0.1);border-color:rgba(255,193,7,0.3);color:#FFC107">👥 회원관리</button>`:'';
el.innerHTML=`<span style="color:#90A4AE">${esc(_authUser?.email||'')}</span>
${adminBtn}
<button class="btn" onclick="logoutUser()" style="font-size:11px;padding:4px 10px">로그아웃</button>`;
}
function setAuthMode(mode){
_authMode=mode;
const isReg=mode==='register';
document.getElementById('lg-tab-login').classList.toggle('active',!isReg);
document.getElementById('lg-tab-reg').classList.toggle('active',isReg);
document.getElementById('lg-submit').textContent=isReg?'회원가입':'로그인';
document.getElementById('lg-pw-label').textContent=isReg?'비밀번호 (8자 이상)':'비밀번호';
document.getElementById('lg-password').setAttribute('autocomplete',isReg?'new-password':'current-password');
document.getElementById('lg-info').style.display='none';
document.getElementById('lg-error').style.display='none';
}
async function submitAuth(){
const email=document.getElementById('lg-email').value.trim();
const password=document.getElementById('lg-password').value;
const errEl=document.getElementById('lg-error');
const infoEl=document.getElementById('lg-info');
const btn=document.getElementById('lg-submit');
errEl.style.display='none'; infoEl.style.display='none';
if(!email||!password){errEl.textContent='이메일과 비밀번호를 입력하세요';errEl.style.display='block';return;}
if(_authMode==='register'&&password.length<8){errEl.textContent='비밀번호는 8자 이상이어야 합니다';errEl.style.display='block';return;}
btn.disabled=true;
try{
const path=_authMode==='register'?'/auth/register':'/auth/login';
const r=await fetch(API+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const d=await r.json();
if(!r.ok){errEl.textContent=d.detail||'요청 실패';errEl.style.display='block';return;}
if(d.pending){
infoEl.textContent=d.message||'관리자 승인 대기 중입니다';
infoEl.style.display='block';
setAuthMode('login');
document.getElementById('lg-password').value='';
return;
}
setAuth(d.access_token,d.user);
showDashboard();
await loadPortfolio();
loadAll();
maybeOfferImport();
}catch(e){errEl.textContent='네트워크 오류: '+e.message;errEl.style.display='block';}
finally{btn.disabled=false;}
}
async function logoutUser(){
clearAuth();
showLoginPage();
}
// ── 관리자: 회원 관리 모달 ────────────────────────────────
async function showAdminModal(){
document.getElementById('admin-modal').style.display='flex';
await loadAdminUsers();
}
function hideAdminModal(){document.getElementById('admin-modal').style.display='none';}
async function loadAdminUsers(){
const el=document.getElementById('admin-users-list');
el.textContent='불러오는 중...';
try{
const users=await authedJson('/admin/users');
if(!users.length){el.innerHTML='<div style="text-align:center;padding:40px;color:#546E7A">등록된 회원이 없습니다</div>';return;}
let h=`<div style="display:grid;grid-template-columns:1fr;gap:8px">`;
users.forEach(u=>{
const status=u.is_approved?(u.role==='admin'?'<span style="color:#FFC107">관리자</span>':'<span style="color:#69F0AE">승인됨</span>'):'<span style="color:#FFC107">대기중</span>';
const lastLogin=u.last_login_at?new Date(u.last_login_at.replace(' ','T')).toLocaleString('ko-KR'):'없음';
const locked=u.locked_until?` <span style="color:#FF5252;font-size:10px">잠김</span>`:'';
h+=`<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0">
<div style="color:#E0E0E0;font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(u.email)} ${locked}</div>
<div style="color:#78909C;font-size:11px;margin-top:2px">${status} · 가입 ${new Date(u.created_at.replace(' ','T')).toLocaleDateString('ko-KR')} · 최근 ${lastLogin}</div>
</div>
${!u.is_approved?`<button onclick="approveUser(${u.id})" style="background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);color:#00E676;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">승인</button>`:''}
${u.locked_until?`<button onclick="unlockUser(${u.id})" style="background:rgba(255,193,7,0.1);border:1px solid rgba(255,193,7,0.3);color:#FFC107;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">잠금해제</button>`:''}
${u.role!=='admin'?`<button onclick="deleteUser(${u.id},'${esc(u.email)}')" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);color:#FF8A80;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">삭제</button>`:''}
</div>`;
});
h+=`</div>`;
el.innerHTML=h;
}catch(e){el.innerHTML=`<div style="color:#FF8A80">로드 실패: ${esc(e.message)}</div>`;}
}
async function approveUser(id){
try{await authedJson('/admin/users/'+id+'/approve',{method:'POST'});await loadAdminUsers();}
catch(e){alert('승인 실패: '+e.message);}
}
async function unlockUser(id){
try{await authedJson('/admin/users/'+id+'/unlock',{method:'POST'});await loadAdminUsers();}
catch(e){alert('잠금해제 실패: '+e.message);}
}
async function deleteUser(id,email){
if(!confirm(`${email} 계정을 삭제할까요? (포트폴리오도 함께 삭제됩니다)`)) return;
try{await authedJson('/admin/users/'+id,{method:'DELETE'});await loadAdminUsers();}
catch(e){alert('삭제 실패: '+e.message);}
}
// ── 종목 차트 모달 ────────────────────────────────────────
let _scCharts = {candle:null, volume:null, flow:null};
let _scState = {code:'', name:'', timeframe:'day'};
// 클릭 위임: data-stock 속성이 있는 요소(또는 그 자식)를 클릭하면 차트 열림
document.addEventListener('click',(e)=>{
const el=e.target.closest('[data-stock]');
if(!el) return;
// 카드 내부 버튼/링크는 차트 열지 않음
if(e.target.closest('button,a,input,select,textarea')) return;
e.preventDefault();
openStockChart(el.dataset.stock, el.dataset.stockName||'');
});
function hideStockChart(){
document.getElementById('stock-chart-modal').style.display='none';
['candle','volume','flow'].forEach(k=>{ if(_scCharts[k]){_scCharts[k].destroy(); _scCharts[k]=null;} });
}
async function changeTimeframe(tf){
_scState.timeframe = tf;
document.querySelectorAll('.tf-btn').forEach(b=>{
const active = b.dataset.tf===tf;
b.style.background = active?'rgba(0,230,118,0.15)':'rgba(255,255,255,0.04)';
b.style.borderColor = active?'rgba(0,230,118,0.3)':'rgba(255,255,255,0.08)';
b.style.color = active?'#00E676':'#90A4AE';
b.classList.toggle('active', active);
});
await drawStockChart(null);
}
// 일봉 또는 분봉 데이터로 차트 3개 그리기
async function drawStockChart(prefetchedOhlcv){
const tf = _scState.timeframe;
const code = _scState.code;
if(!code) return;
['candle','volume','flow'].forEach(k=>{ if(_scCharts[k]){_scCharts[k].destroy(); _scCharts[k]=null;} });
const flowWrap = document.getElementById('sc-flow-wrap');
let rows = [];
let isMinute = tf !== 'day';
if(isMinute){
const d = await api('/minute/'+code+'?scope='+tf).catch(()=>null);
if(!d?.data?.length){
flowWrap.style.display='none';
return;
}
// 분봉 dt = "20260508153000" → ms
rows = [...d.data].reverse().slice(-200).map(r=>{
const s = String(r.dt);
const iso = `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)||'00'}`;
return {x:new Date(iso).valueOf(), o:r.open, h:r.high, l:r.low, c:r.close, v:r.volume, fnet:0, inet:0};
});
flowWrap.style.display='none'; // 분봉엔 외국인 순매수 데이터 없음
} else {
const ohlcv = prefetchedOhlcv || await api('/ohlcv/'+code+'?days=90').catch(()=>null);
if(!ohlcv?.data?.length){
flowWrap.style.display='none';
return;
}
rows = [...ohlcv.data].reverse().map(d=>{
const ds=String(d.dt); const iso=`${ds.slice(0,4)}-${ds.slice(4,6)}-${ds.slice(6,8)}`;
return {x:new Date(iso).valueOf(),
o:d.open||d.open_price||0, h:d.high||d.high_price||0, l:d.low||d.low_price||0, c:d.close||d.close_price||0,
v:d.volume, fnet:d.foreign_net||0, inet:d.institution_net||0};
});
flowWrap.style.display='block';
}
const closes=rows.map(r=>r.c);
const ma=(n)=>closes.map((_,i)=> i<n-1?null:closes.slice(i-n+1,i+1).reduce((s,v)=>s+v,0)/n);
const ma5=ma(5), ma20=ma(20), ma60=ma(60);
const candleData=rows.map(r=>({x:r.x,o:r.o,h:r.h,l:r.l,c:r.c}));
const volColors=rows.map(r=>r.c>=r.o?'rgba(0,230,118,0.6)':'rgba(255,82,82,0.6)');
const xUnit = isMinute ? (tf==='1'||tf==='5'?'minute':'hour') : 'day';
const xFmt = isMinute ? {minute:'HH:mm', hour:'M/d HH:mm'} : {day:'M/d'};
const baseScales={
x:{type:'time',time:{unit:xUnit,displayFormats:xFmt},grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},maxTicksLimit:10}},
y:{position:'right',grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},callback:v=>v.toLocaleString()}}
};
_scCharts.candle = new Chart(document.getElementById('sc-candle'),{
type:'candlestick',
data:{datasets:[
{label:'캔들',data:candleData,color:{up:'#00E676',down:'#FF5252',unchanged:'#90A4AE'},borderColor:{up:'#00E676',down:'#FF5252',unchanged:'#90A4AE'}},
{type:'line',label:'MA5',data:rows.map((r,i)=>({x:r.x,y:ma5[i]})),borderColor:'#FFD740',borderWidth:1.2,pointRadius:0,fill:false,tension:0.2,spanGaps:true},
{type:'line',label:'MA20',data:rows.map((r,i)=>({x:r.x,y:ma20[i]})),borderColor:'#40C4FF',borderWidth:1.5,pointRadius:0,fill:false,tension:0.2,spanGaps:true},
{type:'line',label:'MA60',data:rows.map((r,i)=>({x:r.x,y:ma60[i]})),borderColor:'#B388FF',borderWidth:1.5,pointRadius:0,fill:false,tension:0.2,spanGaps:true},
]},
options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,labels:{color:'#90A4AE',font:{size:10},boxWidth:14}},tooltip:{mode:'index',intersect:false}},
scales:baseScales}
});
_scCharts.volume = new Chart(document.getElementById('sc-volume'),{
type:'bar',
data:{labels:rows.map(r=>r.x),datasets:[{label:'거래량',data:rows.map(r=>r.v),backgroundColor:volColors,borderWidth:0}]},
options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>'거래량 '+Number(ctx.parsed.y).toLocaleString()}}},
scales:{...baseScales,y:{...baseScales.y,ticks:{...baseScales.y.ticks,callback:v=>(v/10000).toFixed(0)+'만'}}}}
});
if(!isMinute){
_scCharts.flow = new Chart(document.getElementById('sc-flow'),{
type:'bar',
data:{labels:rows.map(r=>r.x),datasets:[
{label:'외국인',data:rows.map(r=>r.fnet),backgroundColor:'rgba(64,196,255,0.7)'},
{label:'기관',data:rows.map(r=>r.inet),backgroundColor:'rgba(255,167,38,0.7)'},
]},
options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,labels:{color:'#90A4AE',font:{size:10}}}},
scales:{...baseScales,y:{...baseScales.y,ticks:{...baseScales.y.ticks,callback:v=>(v/10000).toFixed(0)+'만'}}}}
});
}
}
async function loadOrderbook(code){
const el=document.getElementById('sc-orderbook');
el.innerHTML='<div style="text-align:center;color:#546E7A;padding:20px">호가 불러오는 중...</div>';
try{
const d=await api('/orderbook/'+code);
const ask=(d.ask||[]).slice().reverse(); // 매도 위에서부터 높은 가격
const bid=d.bid||[];
const askMax=Math.max(...ask.map(x=>x.qty),1);
const bidMax=Math.max(...bid.map(x=>x.qty),1);
const max=Math.max(askMax,bidMax);
let h=`<div style="font-size:10px;color:#78909C;margin-bottom:6px;text-align:center">호가 (10단계)</div>`;
ask.forEach(a=>{
const w=(a.qty/max*100).toFixed(0);
h+=`<div style="display:flex;justify-content:space-between;padding:3px 6px;background:linear-gradient(to left,rgba(255,82,82,0.15) ${w}%,transparent ${w}%);border-radius:3px;margin-bottom:1px">
<span style="color:#FF8A80">${fmt(a.price)}</span>
<span style="color:#90A4AE">${fmt(a.qty)}</span></div>`;
});
h+=`<div style="height:1px;background:rgba(255,255,255,0.1);margin:4px 0"></div>`;
bid.forEach(b=>{
const w=(b.qty/max*100).toFixed(0);
h+=`<div style="display:flex;justify-content:space-between;padding:3px 6px;background:linear-gradient(to right,rgba(0,230,118,0.15) ${w}%,transparent ${w}%);border-radius:3px;margin-bottom:1px">
<span style="color:#69F0AE">${fmt(b.price)}</span>
<span style="color:#90A4AE">${fmt(b.qty)}</span></div>`;
});
h+=`<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.06);font-size:10px;color:#78909C">
매도잔량 <span style="color:#FF8A80">${fmt(d.ask_total)}</span><br>
매수잔량 <span style="color:#69F0AE">${fmt(d.bid_total)}</span>
</div>`;
el.innerHTML=h;
}catch(e){
el.innerHTML='<div style="text-align:center;color:#546E7A;padding:20px;font-size:11px">호가 데이터 없음</div>';
}
}
async function openStockChart(code, name){
if(!code) return;
_scState = {code, name: name||'', timeframe:'day'};
// 시간프레임 버튼 초기화
document.querySelectorAll('.tf-btn').forEach(b=>{
const active = b.dataset.tf==='day';
b.style.background = active?'rgba(0,230,118,0.15)':'rgba(255,255,255,0.04)';
b.style.borderColor = active?'rgba(0,230,118,0.3)':'rgba(255,255,255,0.08)';
b.style.color = active?'#00E676':'#90A4AE';
b.classList.toggle('active', active);
});
const modal=document.getElementById('stock-chart-modal');
modal.style.display='flex';
document.getElementById('sc-title').textContent=name||code;
document.getElementById('sc-subtitle').textContent=code;
document.getElementById('sc-info').innerHTML='<div style="grid-column:1/-1;text-align:center;color:#546E7A;padding:30px">데이터 불러오는 중...</div>';
document.getElementById('sc-fundamentals').innerHTML='';
loadOrderbook(code); // 호가 비동기로
const [stock, ohlcvResp, fundi, nameResp] = await Promise.all([
api('/stock/'+code).catch(()=>null),
api('/ohlcv/'+code+'?days=90').catch(()=>null),
api('/fundamentals/'+code).catch(()=>null),
name?null:api('/name/'+code).catch(()=>null),
]);
// 종목명 보정
if(nameResp?.name) document.getElementById('sc-title').textContent=nameResp.name;
// 정보 카드 (stock 응답: {price, technical, scores[]})
const price = stock?.price || {};
const ta = stock?.technical || {};
const sc0 = (stock?.scores && stock.scores[0]) || {};
const ch = parseFloat(price.change_pct||0);
const chC = ch>=0?'#00E676':'#FF5252';
document.getElementById('sc-info').innerHTML=`
<div class="price-box"><div class="label">현재가</div><div class="val">${fmt(price.price)}</div></div>
<div class="price-box"><div class="label">등락</div><div class="val" style="color:${chC}">${ch>=0?'+':''}${ch.toFixed(2)}%</div></div>
<div class="price-box"><div class="label">시가총액</div><div class="val">${fmt(price.market_cap)}</div></div>
<div class="price-box"><div class="label">PER</div><div class="val">${parseFloat(price.per||0).toFixed(2)}</div></div>
<div class="price-box"><div class="label">PBR</div><div class="val">${parseFloat(price.pbr||0).toFixed(2)}</div></div>
<div class="price-box"><div class="label">기술점수</div><div class="val" style="color:${(ta.tech_score||0)>=30?'#69F0AE':(ta.tech_score||0)<=-30?'#FF8A80':'#E0E0E0'}">${(ta.tech_score||0).toFixed(0)}</div></div>
<div class="price-box"><div class="label">AI점수</div><div class="val">${sc0.total_score!=null?Number(sc0.total_score).toFixed(1):'-'}</div></div>
<div class="price-box"><div class="label">추천</div><div class="val" style="font-size:12px;color:${RCOL[sc0.recommendation]||'#90A4AE'}">${sc0.recommendation||ta.signal||'-'}</div></div>`;
const ohlcv = ohlcvResp;
// 캔들/거래량/외국인 차트
if(!ohlcv?.data?.length){
document.getElementById('sc-fundamentals').innerHTML='<div style="text-align:center;color:#FF8A80;padding:20px">OHLCV 데이터 없음</div>';
return;
}
await drawStockChart(ohlcv);
// 펀더멘털 요약
if(fundi?.length){
const f=fundi[0];
document.getElementById('sc-fundamentals').innerHTML=`
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:10px;padding:14px">
<div style="font-size:12px;font-weight:700;color:#FFD740;margin-bottom:8px">📊 재무 (${f.bsns_year}${f.reprt_name||''})</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;font-size:11px">
<div>매출 <b style="color:#E0E0E0">${fmt(f.revenue)}억</b></div>
<div>영업이익 <b style="color:#E0E0E0">${fmt(f.operating_profit)}억</b></div>
<div>순이익 <b style="color:#E0E0E0">${fmt(f.net_income)}억</b></div>
<div>ROE <b style="color:${(f.roe||0)>=10?'#69F0AE':'#FF8A80'}">${(f.roe||0).toFixed(1)}%</b></div>
<div>영업이익률 <b style="color:#E0E0E0">${(f.operating_margin||0).toFixed(1)}%</b></div>
<div>부채비율 <b style="color:${(f.debt_ratio||0)<=200?'#69F0AE':'#FF8A80'}">${(f.debt_ratio||0).toFixed(1)}%</b></div>
<div>매출성장 <b style="color:#E0E0E0">${(f.revenue_growth||0).toFixed(1)}%</b></div>
<div>FCF비율 <b style="color:#E0E0E0">${(f.fcf_ratio||0).toFixed(1)}%</b></div>
</div>
</div>`;
}
}
// ── 포트폴리오 (서버 연동) ─────────────────────────────────
let _portfolio = []; // 서버 정규화: {id, code, name, buy_price, qty, memo, pnl, pnl_pct}
async function loadPortfolio(){
if(!isLoggedIn()){_portfolio=[];return _portfolio;}
try{
const list=await authedJson('/portfolio');
let priceMap={};
const codes=list.map(p=>p.stock_code).filter(Boolean).join(',');
if(codes){
const prices=await api('/portfolio/prices?codes='+codes);
if(Array.isArray(prices)) priceMap=Object.fromEntries(prices.map(p=>[p.code,p]));
}
_portfolio=list.map(p=>{
const cur=(priceMap[p.stock_code]||{}).price||0;
const pnl=cur?(cur-p.buy_price)*p.qty:0;
const pnl_pct=cur?(cur-p.buy_price)/p.buy_price*100:0;
return {id:p.id,code:p.stock_code,name:p.stock_name,buy_price:p.buy_price,qty:p.qty,memo:p.memo||'',pnl,pnl_pct};
});
}catch(e){_portfolio=[];}
return _portfolio;
}
function getPortfolio(){return _portfolio}
async function maybeOfferImport(){
const legacy=JSON.parse(localStorage.getItem('portfolio')||'[]');
if(!legacy.length||!isLoggedIn()) return;
if(!confirm(`이전에 브라우저에 저장된 포지션 ${legacy.length}건이 있습니다. 서버 계정으로 가져올까요?`)) return;
const items=legacy.filter(p=>p.code&&p.buy_price&&p.qty).map(p=>({
stock_code:String(p.code),stock_name:p.name||'',buy_price:Number(p.buy_price),qty:Number(p.qty),memo:''
}));
if(!items.length){alert('가져올 유효한 항목이 없습니다');return;}
try{
const r=await authedJson('/portfolio/import',{method:'POST',body:JSON.stringify(items)});
alert(`가져오기 완료 — 추가 ${r.inserted}건 / 중복 스킵 ${r.skipped}`);
localStorage.removeItem('portfolio');
await loadPortfolio();
renderPortfolioList();
}catch(e){alert('가져오기 실패: '+e.message);}
}
function renderMyStock(){
const el=document.getElementById('mystock');
if(!isLoggedIn()){
el.innerHTML=`<div style="max-width:540px;margin:60px auto;text-align:center;padding:40px 24px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px">
<div style="font-size:32px;margin-bottom:12px">🔒</div>
<h3 style="font-size:16px;color:#E0E0E0;margin-bottom:8px">로그인이 필요합니다</h3>
<div style="font-size:13px;color:#90A4AE;line-height:1.6;margin-bottom:20px">내 종목 분석 데이터는<br>로그인 계정에 안전하게 저장됩니다</div>
<button onclick="showLoginModal()" style="background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);border-radius:10px;padding:10px 28px;color:#00E676;font-size:14px;font-weight:700;cursor:pointer">로그인 / 회원가입</button>
</div>`;
return;
}
el.innerHTML=`
<div style="max-width:760px;margin:0 auto">
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px;padding:24px;margin-bottom:20px">
<h3 style="font-size:14px;color:#90A4AE;margin-bottom:16px">📂 보유 종목 입력</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">종목코드 (6자리)</div>
<input id="ms-code" type="text" maxlength="6" placeholder="예: 329180"
style="${INP_STYLE};font-family:'JetBrains Mono',monospace"
oninput="onCodeInput(this.value)">
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">종목명</div>
<input id="ms-name" type="text" placeholder="코드 입력 시 자동 조회"
style="${INP_STYLE};color:#69F0AE" readonly>
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">매입단가 (원)</div>
<input id="ms-price" type="number" placeholder="예: 698000"
style="${INP_STYLE};font-family:'JetBrains Mono',monospace">
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">보유수량 (주)</div>
<input id="ms-qty" type="number" placeholder="예: 17"
style="${INP_STYLE};font-family:'JetBrains Mono',monospace">
</div>
</div>
<button onclick="analyzeMyStock()"
style="width:100%;background:rgba(0,230,118,0.12);border:1px solid rgba(0,230,118,0.25);
border-radius:10px;padding:12px;color:#00E676;font-size:14px;font-weight:700;cursor:pointer">
🔍 AI 포지션 분석 + 저장
</button>
</div>
<div id="ms-portfolio" style="margin-bottom:20px"></div>
<div id="ms-result"></div>
</div>`;
renderPortfolioList();
}
async function onCodeInput(v){
if(v.length===6&&/^\d{6}$/.test(v)){
const nameEl=document.getElementById('ms-name');
nameEl.value='조회 중...';
const d=await api('/name/'+v);
nameEl.value=d&&d.name?d.name:'알 수 없음';
}
}
function renderPortfolioList(){
const list=getPortfolio();
const el=document.getElementById('ms-portfolio');
if(!el)return;
if(!list.length){el.innerHTML='';return;}
let h=`<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:14px;color:#90A4AE">💼 저장된 포지션 (${list.length}개)</h3>
<button onclick="clearPortfolio()" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);border-radius:6px;padding:4px 12px;color:#FF8A80;font-size:11px;cursor:pointer">전체 삭제</button>
</div>`;
list.forEach((p,i)=>{
const pnlC=p.pnl>=0?'#00E676':'#FF5252';
h+=`<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<div style="flex:1">
<div style="font-size:14px;font-weight:700;color:#E0E0E0">${esc(p.name)} <span style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${p.code}</span></div>
<div style="font-size:11px;color:#78909C;margin-top:2px">매입 ${fmt(p.buy_price)}× ${p.qty}주 | 평가손익 <span style="color:${pnlC}">${p.pnl>=0?'+':''}${fmt(p.pnl)}원 (${p.pnl>=0?'+':''}${(p.pnl_pct||0).toFixed(2)}%)</span></div>
</div>
<button onclick="reanalyze(${i})" style="background:rgba(0,230,118,0.08);border:1px solid rgba(0,230,118,0.2);border-radius:6px;padding:4px 10px;color:#00E676;font-size:11px;cursor:pointer;white-space:nowrap">재분석</button>
<button onclick="deletePortfolio(${p.id})" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);border-radius:6px;padding:4px 10px;color:#FF8A80;font-size:11px;cursor:pointer">삭제</button>
</div>`;
});
h+=`</div>`;
el.innerHTML=h;
}
async function deletePortfolio(id){
if(!id||!confirm('이 포지션을 삭제할까요?')) return;
try{
await authedJson('/portfolio/'+id,{method:'DELETE'});
await loadPortfolio();
renderPortfolioList();
}catch(e){alert('삭제 실패: '+e.message);}
}
async function clearPortfolio(){
if(!confirm('저장된 포지션을 모두 삭제할까요?')) return;
const list=getPortfolio();
try{
await Promise.all(list.map(p=>authedJson('/portfolio/'+p.id,{method:'DELETE'}).catch(()=>{})));
await loadPortfolio();
renderPortfolioList();
}catch(e){alert('삭제 실패: '+e.message);}
}
function reanalyze(i){
const p=getPortfolio()[i];
if(!p) return;
document.getElementById('ms-code').value=p.code;
document.getElementById('ms-name').value=p.name;
document.getElementById('ms-price').value=p.buy_price;
document.getElementById('ms-qty').value=p.qty;
analyzeMyStock(false);
}
async function analyzeMyStock(saveResult=true){
const code=document.getElementById('ms-code').value.trim();
const name=document.getElementById('ms-name').value.trim();
const buyPrice=parseInt(document.getElementById('ms-price').value);
const qty=parseInt(document.getElementById('ms-qty').value);
if(!code||!buyPrice||!qty){alert('종목코드, 매입단가, 수량을 모두 입력하세요');return;}
document.getElementById('ms-result').scrollIntoView({behavior:'smooth'});
const el=document.getElementById('ms-result');
el.innerHTML=`<div style="text-align:center;padding:40px;color:#546E7A">분석 중... (10~30초 소요)</div>`;
try{
const r=await fetch(API+'/position?ai=true',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({code,name,buy_price:buyPrice,qty})
});
const d=await r.json();
if(d.error){el.innerHTML=`<div class="empty">${esc(d.error)}</div>`;return;}
// 포트폴리오 저장 (서버)
if(saveResult && isLoggedIn()){
try{
const list=getPortfolio();
const existing=list.find(p=>p.code===code);
const body={stock_code:code,stock_name:d.name||name||code,buy_price:buyPrice,qty,memo:''};
if(existing){
await authedJson('/portfolio/'+existing.id,{method:'PUT',body:JSON.stringify(body)});
}else{
await authedJson('/portfolio',{method:'POST',body:JSON.stringify(body)});
}
await loadPortfolio();
renderPortfolioList();
}catch(e){console.error('save failed',e);}
}
const pos=d.position||{};
const ind=d.indicators||{};
const tg=d.targets||{};
const sig=d.signal||'관망';
const sc=sig==='매수'?'#00E676':sig==='매도'?'#FF5252':'#78909C';
const pnlC=pos.pnl>=0?'#00E676':'#FF5252';
el.innerHTML=`
<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:20px;margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div>
<div style="font-size:18px;font-weight:700;color:#E0E0E0">${esc(d.name||code)}</div>
<div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${code}</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:${sc};font-weight:700">${sig} (${(d.tech_score||0).toFixed(0)}점)</div>
</div>
</div>
<div class="price-row" style="margin-bottom:16px">
<div class="price-box"><div class="label">매입단가</div><div class="val" style="color:#78909C">${fmt(buyPrice)}</div></div>
<div class="price-box"><div class="label">현재가</div><div class="val" style="color:#E0E0E0">${fmt(d.current_price)}</div></div>
<div class="price-box"><div class="label">평가손익</div><div class="val" style="color:${pnlC}">${pos.pnl>=0?'+':''}${fmt(pos.pnl)}</div></div>
<div class="price-box"><div class="label">수익률</div><div class="val" style="color:${pnlC}">${pos.pnl_pct>=0?'+':''}${(pos.pnl_pct||0).toFixed(2)}%</div></div>
</div>
<div class="price-row" style="margin-bottom:12px">
<div class="price-box" style="border:1px solid #FF8A8030"><div class="label">손절가</div><div class="val" style="color:#FF8A80">${fmt(pos.stop_loss)}</div><div style="font-size:9px;color:#607D8B">손실 ${fmt(pos.stop_pnl)}</div></div>
<div class="price-box" style="border:1px solid #69F0AE30"><div class="label">1차 목표</div><div class="val" style="color:#69F0AE">${fmt(pos.t1)}</div><div style="font-size:9px;color:#607D8B">수익 +${fmt(pos.t1_pnl)}</div></div>
<div class="price-box" style="border:1px solid #00E67630"><div class="label">2차 목표</div><div class="val" style="color:#00E676">${fmt(pos.t2)}</div><div style="font-size:9px;color:#607D8B">수익 +${fmt(pos.t2_pnl)}</div></div>
<div class="price-box" style="border:1px solid #00BFA530"><div class="label">3차 목표</div><div class="val" style="color:#00BFA5">${fmt(pos.t3)}</div><div style="font-size:9px;color:#607D8B">수익 +${fmt(pos.t3_pnl)}</div></div>
</div>
<div style="background:rgba(255,255,255,0.03);border-radius:10px;padding:12px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#90A4AE;margin-bottom:8px">기술적 지표</div>
<div style="display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#78909C">
<span>MA5 <b style="color:${ind.price>ind.ma5?'#69F0AE':'#FF8A80'}">${fmt(ind.ma5)}</b></span>
<span>MA20 <b style="color:${ind.price>ind.ma20?'#69F0AE':'#FF8A80'}">${fmt(ind.ma20)}</b></span>
<span>MA60 <b style="color:${ind.price>ind.ma60?'#69F0AE':'#FF8A80'}">${fmt(ind.ma60)}</b></span>
<span>RSI <b style="color:${(ind.rsi||50)<40?'#69F0AE':(ind.rsi||50)>70?'#FF8A80':'#E0E0E0'}">${(ind.rsi||0).toFixed(1)}</b></span>
<span>MACD <b style="color:${(ind.macd_hist||0)>0?'#69F0AE':'#FF8A80'}">${(ind.macd_hist||0)>0?'골든':'데드'}</b></span>
<span>볼밴%B <b style="color:#FFD740">${((ind.pct_b||0.5)*100).toFixed(0)}%</b></span>
<span>거래량비 <b style="color:#40C4FF">${(ind.vol_ratio||1).toFixed(1)}x</b></span>
</div>
<div class="ta-row" style="margin-top:8px">${(d.signals||[]).map(s=>`<span class="ta-badge">${esc(s)}</span>`).join('')}</div>
</div>
${(pos.avg_down||[]).length?`
<div style="background:rgba(255,255,255,0.03);border-radius:10px;padding:12px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#90A4AE;margin-bottom:8px">분할매수 시나리오</div>
${pos.avg_down.map(ad=>`
<div style="display:flex;justify-content:space-between;font-size:11px;color:#78909C;padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<span>${fmt(ad.price)}원에 ${ad.add_qty}주 추가 (${fmt(ad.add_cost)}원)</span>
<span>→ 평균단가 <b style="color:#FFD740">${fmt(ad.new_avg)}원</b></span>
</div>`).join('')}
</div>`:''}
<div style="background:rgba(255,165,0,0.05);border:1px solid rgba(255,165,0,0.15);border-radius:10px;padding:14px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#FFD740;margin-bottom:6px">🤖 AI 판단</div>
<div style="font-size:12px;color:#CFD8DC;line-height:1.7">${esc(d.ai_opinion||'vLLM 서버 연결 후 활성화됩니다.')}</div>
</div>
<div style="text-align:center;padding:10px;background:rgba(${pos.action==='손절 고려'?'255,82,82':'0,230,118'},0.08);border-radius:8px">
<span style="font-size:14px;font-weight:700;color:${pos.action==='손절 고려'?'#FF5252':'#00E676'}">
권장 액션: ${pos.action||'홀드'}
</span>
</div>
</div>`;
}catch(e){el.innerHTML=`<div class="empty">분석 오류: ${esc(e.message)}</div>`;}
}
async function renderPerformance(){
const el=document.getElementById('performance');
el.innerHTML='<div class="empty">성과 데이터 불러오는 중...</div>';
const [perf,sector]=await Promise.all([api('/performance'),api('/sector-ranking')]);
if(!perf){el.innerHTML='<div class="empty">데이터 없음</div>';return;}
const s=perf.summary||{};
const winRate7=s.measured_7d>0?Math.round(s.wins_7d/s.measured_7d*100):null;
const winRate30=s.measured_30d>0?Math.round(s.wins_30d/s.measured_30d*100):null;
const ret7C=(s.avg_return_7d||0)>=0?'#00E676':'#FF5252';
const ret30C=(s.avg_return_30d||0)>=0?'#00E676':'#FF5252';
let h=`<div style="background:rgba(0,230,118,0.05);border:1px solid rgba(0,230,118,0.15);border-radius:12px;padding:14px 18px;margin-bottom:18px">
<div style="font-size:13px;font-weight:700;color:#00E676;margin-bottom:6px">🎯 추천 성과 추적</div>
<div style="font-size:12px;color:#90A4AE;line-height:1.6">
과거 AI가 추천한 종목들이 <b style="color:#E0E0E0">7일/30일 후 실제로 어떤 수익률</b>을 냈는지 추적합니다.<br>
평균 수익률·승률을 통해 AI 추천의 신뢰도를 검증하는 지표입니다. 종목명을 클릭하면 차트 모달이 열립니다.
</div>
</div>
<div class="cards">
<div class="card"><div class="label">📊 7일 평균수익률</div>
<div class="val" style="font-size:22px;color:${ret7C}">${s.avg_return_7d!=null?(s.avg_return_7d>=0?'+':'')+s.avg_return_7d+'%':'데이터없음'}</div>
<div class="sub">${winRate7!=null?'승률 '+winRate7+'% ('+s.measured_7d+'건)':'측정 전'}</div></div>
<div class="card"><div class="label">📈 30일 평균수익률</div>
<div class="val" style="font-size:22px;color:${ret30C}">${s.avg_return_30d!=null?(s.avg_return_30d>=0?'+':'')+s.avg_return_30d+'%':'데이터없음'}</div>
<div class="sub">${winRate30!=null?'승률 '+winRate30+'% ('+s.measured_30d+'건)':'측정 전'}</div></div>
<div class="card"><div class="label">🔥 강력매수 7일</div>
<div class="val" style="font-size:22px;color:${(s.strong_buy_avg_7d||0)>=0?'#00E676':'#FF5252'}">${s.strong_buy_avg_7d!=null?(s.strong_buy_avg_7d>=0?'+':'')+s.strong_buy_avg_7d+'%':'데이터없음'}</div>
<div class="sub">강력매수 단독 성과</div></div>
<div class="card"><div class="label">📋 총 추천 건수</div>
<div class="val">${s.total||0}</div><div class="sub">최근 90일</div></div>
</div>`;
// 섹터별 상대 강도
if(sector&&sector.length){
h+=`<div class="panel" style="max-height:none;margin-bottom:16px">
<h3 style="margin-bottom:16px">🏭 섹터별 AI 점수 순위</h3>
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
<th style="text-align:left;padding:8px 6px">섹터</th>
<th style="text-align:right;padding:8px 6px">종목수</th>
<th style="text-align:right;padding:8px 6px">평균점수</th>
<th style="text-align:right;padding:8px 6px">뉴스</th>
<th style="text-align:right;padding:8px 6px">기술</th>
<th style="text-align:right;padding:8px 6px">매수종목</th>
<th style="text-align:right;padding:8px 6px">매도종목</th>
</tr></thead><tbody>`;
sector.forEach((sec,i)=>{
const sc=sec.avg_score>=30?'#00E676':sec.avg_score<=-30?'#FF5252':'#78909C';
h+=`<tr style="border-bottom:1px solid rgba(255,255,255,0.04);${i%2?'background:rgba(255,255,255,0.01)':''}">
<td style="padding:9px 6px;font-weight:600;color:#CFD8DC">${esc(sec.sector)}</td>
<td style="text-align:right;padding:9px 6px;color:#78909C">${sec.stock_count}</td>
<td style="text-align:right;padding:9px 6px;color:${sc};font-weight:700">${sec.avg_score}</td>
<td style="text-align:right;padding:9px 6px;color:#69F0AE">${sec.avg_news}</td>
<td style="text-align:right;padding:9px 6px;color:#CE93D8">${sec.avg_tech}</td>
<td style="text-align:right;padding:9px 6px;color:#00E676">${sec.buy_count}</td>
<td style="text-align:right;padding:9px 6px;color:#FF8A80">${sec.sell_count}</td>
</tr>`;
});
h+=`</tbody></table></div></div>`;
}
// 최근 성과 이력
const recent=perf.recent||[];
if(recent.length){
h+=`<div class="panel" style="max-height:none">
<h3 style="margin-bottom:16px">📜 추천 성과 이력</h3>
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
<th style="text-align:left;padding:8px 6px">종목</th>
<th style="text-align:center;padding:8px 6px">추천등급</th>
<th style="text-align:right;padding:8px 6px">진입가</th>
<th style="text-align:right;padding:8px 6px">7일수익률</th>
<th style="text-align:right;padding:8px 6px">30일수익률</th>
<th style="text-align:right;padding:8px 6px">추천일</th>
</tr></thead><tbody>`;
recent.forEach((r,i)=>{
const rc=r.recommendation==='강력매수'?'#00E676':r.recommendation==='매수관심'?'#69F0AE':r.recommendation==='매도관심'?'#FF8A80':'#FF5252';
const r7c=(r.return_7d||0)>=0?'#00E676':'#FF5252';
const r30c=(r.return_30d||0)>=0?'#00E676':'#FF5252';
h+=`<tr data-stock="${r.stock_code}" data-stock-name="${esc(r.stock_name||'')}" style="border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer;${i%2?'background:rgba(255,255,255,0.01)':''}">
<td style="padding:9px 6px"><div style="font-weight:600;color:#CFD8DC">${esc(r.stock_name)}</div>
<div style="font-size:10px;color:#546E7A">${r.stock_code}</div></td>
<td style="text-align:center;padding:9px 6px"><span style="color:${rc};font-weight:700;font-size:11px">${esc(r.recommendation)}</span></td>
<td style="text-align:right;padding:9px 6px;color:#78909C;font-family:'JetBrains Mono',monospace">${fmt(r.entry_price)}</td>
<td style="text-align:right;padding:9px 6px;color:${r7c};font-weight:700">
${r.return_7d!=null?(r.return_7d>=0?'+':'')+r.return_7d.toFixed(2)+'%':'-'}</td>
<td style="text-align:right;padding:9px 6px;color:${r30c};font-weight:700">
${r.return_30d!=null?(r.return_30d>=0?'+':'')+r.return_30d.toFixed(2)+'%':'-'}</td>
<td style="text-align:right;padding:9px 6px;color:#546E7A;font-size:11px">${r.rec_date}</td>
</tr>`;
});
h+=`</tbody></table></div></div>`;
} else {
h+=`<div class="panel"><div class="empty">오늘부터 추천 성과가 기록됩니다. 7일 후 수익률이 표시됩니다.</div></div>`;
}
el.innerHTML=h;
}
async function renderPortfolio(){
const el=document.getElementById('portfolio');
const list=getPortfolio();
if(!list.length){
el.innerHTML=`<div style="text-align:center;padding:80px 20px;color:#546E7A">
<div style="font-size:48px;margin-bottom:16px">💼</div>
<div style="font-size:16px;margin-bottom:8px;color:#78909C">보유 종목이 없습니다</div>
<div style="font-size:13px">📂 내 종목 분석 탭에서 종목을 추가하면 여기에 표시됩니다</div>
</div>`;
return;
}
el.innerHTML='<div class="empty">현재가 · AI점수 불러오는 중...</div>';
const codes=list.map(p=>p.code).join(',');
const priceData=await api('/portfolio/prices?codes='+codes)||[];
const priceMap={};
priceData.forEach(d=>priceMap[d.code]=d);
let totalCost=0,totalValue=0;
const holdings=list.map(p=>{
const pd=priceMap[p.code]||{};
const cur=pd.price||0;
const cost=p.buy_price*p.qty;
const val=cur>0?cur*p.qty:0;
totalCost+=cost;
if(cur>0)totalValue+=val;
return{...p,cur,changePct:pd.change_pct||0,cost,val,
pnl:val-cost,pnlPct:cost>0?(val-cost)/cost*100:0,
techScore:pd.tech_score||0,signal:pd.signal||'관망',
aiScore:pd.ai_score,recommendation:pd.recommendation};
});
const totalPnl=totalValue-totalCost;
const totalPnlPct=totalCost>0?totalPnl/totalCost*100:0;
holdings.forEach(h=>{h.weight=totalValue>0&&h.val>0?h.val/totalValue*100:0;});
holdings.sort((a,b)=>b.val-a.val);
const tC=totalPnl>=0?'#00E676':'#FF5252';
let h=`<div class="cards">
<div class="card"><div class="label">💰 총 매입금액</div><div class="val" style="font-size:22px">${fmt(totalCost)}</div><div class="sub">원</div></div>
<div class="card"><div class="label">📊 총 평가금액</div><div class="val" style="font-size:22px;color:${tC}">${totalValue>0?fmt(totalValue):'-'}</div><div class="sub">원</div></div>
<div class="card"><div class="label">💹 평가손익</div>
<div class="val" style="font-size:22px;color:${tC}">${totalValue>0?(totalPnl>=0?'+':'')+fmt(totalPnl):'-'}</div>
<div class="sub" style="color:${tC}">${totalValue>0?(totalPnlPct>=0?'+':'')+totalPnlPct.toFixed(2)+'%':'-'}</div></div>
<div class="card"><div class="label">📂 보유 종목</div><div class="val">${holdings.length}</div><div class="sub">개 종목</div></div>
</div>
<div class="panel" style="max-height:none;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3>💼 보유 종목 현황</h3>
<button onclick="renderPortfolio()" style="background:rgba(0,230,118,0.08);border:1px solid rgba(0,230,118,0.2);border-radius:6px;padding:4px 12px;color:#00E676;font-size:11px;cursor:pointer">↻ 새로고침</button>
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
<th style="text-align:left;padding:8px 6px;font-weight:600">종목</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">매입가</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">현재가</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">수량</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">평가손익</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">수익률</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">비중</th>
<th style="text-align:center;padding:8px 6px;font-weight:600">AI점수</th>
<th style="text-align:center;padding:8px 6px;font-weight:600">신호</th>
</tr></thead>
<tbody>`;
holdings.forEach((s,i)=>{
const pC=s.pnl>=0?'#00E676':'#FF5252';
const sigC=s.signal==='매수'?'#00E676':s.signal==='매도'?'#FF5252':'#78909C';
const aiC=s.aiScore>=30?'#00E676':s.aiScore<=-30?'#FF5252':'#78909C';
const chgC=s.changePct>=0?'#00E676':'#FF5252';
h+=`<tr data-stock="${s.code}" data-stock-name="${esc(s.name||'')}" style="border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer;${i%2?'background:rgba(255,255,255,0.01)':''}">
<td style="padding:10px 6px">
<div style="font-weight:600;color:#CFD8DC">${esc(s.name)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${s.code}</div>
</td>
<td style="text-align:right;padding:10px 6px;color:#78909C;font-family:'JetBrains Mono',monospace">${fmt(s.buy_price)}</td>
<td style="text-align:right;padding:10px 6px;font-family:'JetBrains Mono',monospace">
<div style="color:#E0E0E0">${s.cur>0?fmt(s.cur):'-'}</div>
${s.cur>0?`<div style="font-size:10px;color:${chgC}">${s.changePct>=0?'+':''}${s.changePct.toFixed(2)}%</div>`:''}
</td>
<td style="text-align:right;padding:10px 6px;color:#90A4AE">${s.qty.toLocaleString()}</td>
<td style="text-align:right;padding:10px 6px;color:${pC};font-family:'JetBrains Mono',monospace;font-weight:600">
${s.cur>0?(s.pnl>=0?'+':'')+fmt(s.pnl):'-'}
</td>
<td style="text-align:right;padding:10px 6px;color:${pC};font-weight:700">
${s.cur>0?(s.pnlPct>=0?'+':'')+s.pnlPct.toFixed(2)+'%':'-'}
</td>
<td style="text-align:right;padding:10px 6px">
<div style="display:flex;align-items:center;justify-content:flex-end;gap:6px">
<div style="width:40px;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;overflow:hidden">
<div style="height:100%;width:${Math.min(100,s.weight).toFixed(0)}%;background:#40C4FF;border-radius:2px"></div>
</div>
<span style="color:#40C4FF;font-size:11px">${s.weight.toFixed(1)}%</span>
</div>
</td>
<td style="text-align:center;padding:10px 6px">
${s.aiScore!=null?`<span style="color:${aiC};font-weight:700;font-family:'JetBrains Mono',monospace">${s.aiScore.toFixed(0)}</span>
<div style="font-size:9px;color:${aiC}">${s.recommendation||''}</div>`:'<span style="color:#455A64">-</span>'}
</td>
<td style="text-align:center;padding:10px 6px">
<span style="color:${sigC};font-weight:700;font-size:11px">${esc(s.signal)}</span>
</td>
</tr>`;
});
h+=`</tbody></table></div></div>`;
el.innerHTML=h;
}
function renderSignals(signals,cands){
let h=`<div style="margin-bottom:20px">
<div style="font-size:12px;color:#546E7A;margin-bottom:16px">
MA/RSI/MACD/볼린저밴드 기반 기술적 분석 + AI 뉴스 감성 종합 추천
</div>`;
// 매수 후보 (통합)
h+=`<h3 style="font-size:14px;font-weight:700;color:#69F0AE;margin-bottom:12px">🎯 통합 매수 후보 (기술적+펀더멘탈)</h3>`;
if(cands&&cands.length){
h+=`<div class="candidates-grid">`;
cands.forEach(c=>{
const tg=c.targets||{};
const sigs=(c.signals||[]).slice(0,4);
const ts=c.tech_score||0;
const fs=c.fundamental_score||0;
const combined=((ts+fs)/2).toFixed(1);
h+=`<div class="cand-card" data-stock="${c.stock_code}" data-stock-name="${esc(c.stock_name||c.stock_code)}" style="cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">
<div>
<div style="font-size:16px;font-weight:700;color:#E0E0E0">${esc(c.stock_name||c.stock_code)}</div>
<div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${c.stock_code} · 현재가 <b style="color:#E0E0E0">${fmt(c.price)}원</b></div>
</div>
<div style="text-align:right">
<div style="font-size:12px;font-weight:700;color:#00E676">종합 ${combined}</div>
<div style="font-size:10px;color:#69F0AE">기술${ts.toFixed(0)} / 펀더${fs.toFixed(0)}</div>
</div>
</div>
<div class="price-row">
<div class="price-box"><div class="label">진입 추천가</div><div class="val" style="color:#40C4FF">${fmt(tg.entry_price)}</div></div>
<div class="price-box"><div class="label">목표1 (${pct(tg.t1_pct)})</div><div class="val" style="color:#69F0AE">${fmt(tg.t1)}</div></div>
<div class="price-box"><div class="label">목표2 (${pct(tg.t2_pct)})</div><div class="val" style="color:#00E676">${fmt(tg.t2)}</div></div>
<div class="price-box"><div class="label">손절가 (${pct(tg.stop_pct)})</div><div class="val" style="color:#FF8A80">${fmt(tg.stop_loss)}</div></div>
</div>
<div style="display:flex;gap:12px;font-size:11px;color:#78909C;margin-top:10px;flex-wrap:wrap">
<span>RSI <b style="color:${(c.rsi||50)<40?'#69F0AE':(c.rsi||50)>70?'#FF8A80':'#E0E0E0'}">${(c.rsi||0).toFixed(0)}</b></span>
<span>MACD히스 <b style="color:${(c.macd_hist||0)>0?'#69F0AE':'#FF8A80'}">${(c.macd_hist||0).toFixed(3)}</b></span>
<span>볼밴%B <b style="color:#FFD740">${((c.pct_b||0.5)*100).toFixed(0)}%</b></span>
<span>거래량비 <b style="color:#40C4FF">${(c.vol_ratio||1).toFixed(1)}x</b></span>
<span>리스크비 <b style="color:#E0E0E0">${tg.risk_reward||0}:1</b></span>
</div>
<div class="ta-row">${sigs.map(s=>{
const bull=s.includes('상승')||s.includes('골든')||s.includes('과매도')||s.includes('바닥')||s.includes('정배열')||s.includes('위')||s.includes('반등')||s.includes('매수');
const bear=s.includes('하락')||s.includes('데드')||s.includes('과매수')||s.includes('역배열')||s.includes('아래')||s.includes('매도');
return `<span class="ta-badge ${bull?'bull':bear?'bear':''}">${esc(s)}</span>`;
}).join('')}</div>
</div>`;
});
h+=`</div>`;
} else {
h+=`<div class="empty">기술적 분석 데이터 수집 중... (첫 실행 후 30분 이내 생성됩니다)</div>`;
}
// 매매 시그널 (KIS 기반)
h+=`<h3 style="font-size:14px;font-weight:700;color:#90A4AE;margin:24px 0 12px">📈 최근 매매 시그널 (3일)</h3>`;
const buySigs=(signals||[]).filter(s=>s.signal_type==='매수').slice(0,10);
const sellSigs=(signals||[]).filter(s=>s.signal_type==='매도').slice(0,5);
if(buySigs.length||sellSigs.length){
h+=`<div class="signal-grid">`;
[...buySigs,...sellSigs].forEach(s=>{
const isBuy=s.signal_type==='매수';
const sc=isBuy?'#00E676':'#FF5252';
const tg=s.targets||{};
const taSigs=(s.ta_signals||[]).slice(0,3);
h+=`<div class="signal-card" data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name||'')}" style="border:1px solid ${sc}20;cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<div>
<span style="font-size:15px;font-weight:700;color:${sc}">${s.signal_type}</span>
<span style="font-size:14px;color:#E0E0E0;margin-left:8px">${esc(s.stock_name)}</span>
<span style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace;margin-left:6px">${s.stock_code}</span>
</div>
<span style="font-size:12px;color:#78909C">신뢰도 <b style="color:#FFD740">${(s.confidence||0).toFixed(1)}%</b></span>
</div>
<div class="price-row">
<div class="price-box"><div class="label">현재가</div><div class="val" style="color:#E0E0E0">${fmt(s.current_price)}</div></div>
<div class="price-box"><div class="label">${isBuy?'목표가':'목표가'}</div><div class="val" style="color:${sc}">${fmt(s.target_price)}</div></div>
<div class="price-box"><div class="label">손절가</div><div class="val" style="color:#FF8A80">${fmt(s.stop_loss)}</div></div>
<div class="price-box"><div class="label">예상수익</div><div class="val" style="color:#FFD740">${pct(s.expected_return_pct)}</div></div>
</div>
${tg.t1?`<div style="font-size:11px;color:#78909C;margin-top:8px">
T1 <b style="color:#69F0AE">${fmt(tg.t1)}</b>(${pct(tg.t1_pct)}) &nbsp;
T2 <b style="color:#00E676">${fmt(tg.t2)}</b>(${pct(tg.t2_pct)}) &nbsp;
T3 <b style="color:#00BFA5">${fmt(tg.t3)}</b>(${pct(tg.t3_pct)})
</div>`:''}
${taSigs.length?`<div class="ta-row">${taSigs.map(sg=>`<span class="ta-badge">${esc(sg)}</span>`).join('')}</div>`:''}
${s.reason?`<div style="font-size:11px;color:#607D8B;margin-top:8px">${esc(s.reason.substring(0,120))}</div>`:''}
</div>`;
});
h+=`</div>`;
} else {
h+=`<div class="empty">최근 3일간 시그널이 없습니다. (장 중 5분마다 생성됩니다)</div>`;
}
h+=`</div>`;
document.getElementById('signals').innerHTML=h;
}
function getMarketTip(close, changePct){
if(!close) return null;
if(changePct >= 1.5) return {icon:'🚀', label:'강세장', color:'#00E676', text:'코스피가 강하게 상승 중입니다. 추세 추종 전략이 유효한 구간이지만, 과매수 지표(RSI 70↑)가 나타나면 신규 매수보다 기존 보유 종목 관리에 집중하세요.'};
if(changePct >= 0.5) return {icon:'📈', label:'완만한 상승', color:'#69F0AE', text:'시장이 우호적입니다. 실적 개선 종목과 외국인 순매수 종목을 중심으로 관심 가져볼 만한 구간입니다.'};
if(changePct <= -1.5) return {icon:'⚠️', label:'급락 주의', color:'#FF5252', text:'코스피가 급락 중입니다. 신규 매수보다 손절 라인 점검이 우선입니다. 시장 전반이 흔들릴 때는 현금 비중을 높이고 낙폭 과대 우량주 저점 매수 기회를 기다리세요.'};
if(changePct <= -0.5) return {icon:'📉', label:'약세 흐름', color:'#FF7043', text:'시장이 약세입니다. 실적 대비 낙폭이 과도한 종목을 발굴할 기회이기도 합니다. PBR 1 미만, ROE 10% 이상 종목을 관심 목록에 추가해 보세요.'};
if(close >= 2700) return {icon:'💹', label:'박스권 상단', color:'#FFC107', text:'코스피 2700 이상은 역사적 고평가 구간 진입 신호일 수 있습니다. 신규 진입보다 보유 종목 비중 조절과 이익 실현을 고려하세요.'};
if(close <= 2400) return {icon:'💎', label:'저점 구간', color:'#40C4FF', text:'코스피 2400 이하는 역사적으로 장기 투자자에게 좋은 진입 구간이었습니다. 우량 가치주를 분할 매수하는 전략이 유효합니다.'};
return {icon:'⚖️', label:'중립 구간', color:'#90A4AE', text:'코스피가 횡보 중입니다. 시장 전체보다 개별 종목의 실적과 모멘텀에 집중하는 보텀업 전략이 효과적인 구간입니다.'};
}
function renderOverview(s,ranking,recent,kospi,tip){
if(!s)return;
let h='';
// KOSPI 차트 + 시장 팁
if(kospi && kospi.data && kospi.data.length){
const cur=kospi.current||{};
const chg=parseFloat(cur.change_pct||0);
const chgColor=chg>=0?'#00E676':'#FF5252';
const chgSign=chg>=0?'+':'';
const mTip=getMarketTip(cur.close, chg);
h+=`<div class="kospi-panel">
<div class="kospi-header">
<div>
<div style="font-size:12px;color:#546E7A;font-weight:600;letter-spacing:1px;margin-bottom:6px">📊 KOSPI</div>
<div class="kospi-price" style="color:${chgColor}">${cur.close?Number(cur.close).toLocaleString('ko',{minimumFractionDigits:2}):'--'}</div>
<div class="kospi-change" style="color:${chgColor}">${chgSign}${chg.toFixed(2)}% &nbsp;${chg>=0?'▲':'▼'} ${Math.abs(cur.change||0).toFixed(2)}</div>
</div>
<div style="text-align:right;font-size:11px;color:#546E7A">${cur.date||''} 기준</div>
</div>
<canvas id="kospiChart" height="80"></canvas>
${mTip?`<div class="market-tip-box" style="border-color:${mTip.color}33;background:${mTip.color}0D">
<div class="mtip-label" style="color:${mTip.color}">${mTip.icon} 시장 상황: ${mTip.label}</div>
<div class="mtip-body">${mTip.text}</div>
</div>`:''}
</div>`;
}
// 오늘의 투자 팁
if(tip && tip.title){
h+=`<div class="kospi-tip">
<div class="tip-tag">💡 오늘의 투자 지식 &nbsp;·&nbsp; ${tip.tag||''}</div>
<div class="tip-title">${esc(tip.title)}</div>
<div class="tip-body">${esc(tip.body)}</div>
</div>`;
}
// 요약 카드
h+=`<div class="cards">
<div class="card"><div class="label">📰 분석 뉴스</div><div class="val">${s.total||0}</div><div class="sub">최근 7일</div></div>
<div class="card"><div class="label">▲ 호재</div><div class="val" style="color:#00E676">${s.positive||0}</div><div class="sub">${s.sentiment_ratio||50}%</div></div>
<div class="card"><div class="label">▼ 악재</div><div class="val" style="color:#FF5252">${s.negative||0}</div></div>
<div class="card"><div class="label">📋 DART 공시</div><div class="val">${s.dart||0}</div></div>
<div class="card"><div class="label">🏢 분석 종목</div><div class="val">${s.stocks_analyzed||0}</div></div>
</div><div class="grid2">`;
// 랭킹
h+=`<div class="panel"><h3>🏆 종목 랭킹</h3>`;
if(ranking&&ranking.length){
ranking.slice(0,15).forEach((item,i)=>{
const rc=RCOL[item.recommendation]||'#78909C';
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer"><div class="rank-num ${i<3?'top':'normal'}">${i+1}</div>
<div style="flex:1"><div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name)}</div><div style="font-size:10px;color:#546E7A">${item.stock_code}</div></div>
<div style="text-align:right"><div style="font-size:14px;font-weight:700;color:${rc}">${(item.total_score||0).toFixed(1)}</div><div style="font-size:10px;color:${rc}">${item.recommendation||'관망'}</div></div></div>`;
});
} else h+=`<div class="empty">데이터 수집 중...</div>`;
h+=`</div>`;
// 최근 뉴스
h+=`<div class="panel"><h3>📰 최근 분석</h3>`;
if(recent&&recent.length){
recent.slice(0,15).forEach(item=>{h+=newsItemHTML(item)});
} else h+=`<div class="empty">데이터 수집 중...</div>`;
h+=`</div></div>`;
document.getElementById('overview').innerHTML=h;
// KOSPI 차트 그리기
if(kospi && kospi.data && kospi.data.length && document.getElementById('kospiChart')){
const labels=kospi.data.map(d=>d.date.slice(5));
const closes=kospi.data.map(d=>d.close);
const last=closes[closes.length-1]||0;
const borderColor=last>=closes[0]?'#00E676':'#FF5252';
new Chart(document.getElementById('kospiChart'),{
type:'line',
data:{
labels,
datasets:[{
data:closes,
borderColor,
backgroundColor:borderColor+'18',
borderWidth:2,
fill:true,
tension:0.3,
pointRadius:0,
pointHoverRadius:4,
}]
},
options:{
responsive:true,
plugins:{legend:{display:false},tooltip:{
mode:'index',intersect:false,
callbacks:{label:ctx=>`KOSPI ${Number(ctx.parsed.y).toLocaleString('ko',{minimumFractionDigits:2})}`}
}},
scales:{
x:{grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},maxTicksLimit:10}},
y:{grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},callback:v=>v.toLocaleString()},position:'right'}
}
}
});
}
}
function newsItemHTML(item){
const sc=SCOL[item.sentiment]||'#78909C', si=SICON[item.sentiment]||'●';
const titleHtml=item.url
?`<a href="${esc(item.url)}" target="_blank" rel="noopener" style="color:#CFD8DC;text-decoration:none" onmouseover="this.style.color='#00E676'" onmouseout="this.style.color='#CFD8DC'">${esc(item.title||'')}</a>`
:esc(item.title||'');
// 종목 코드가 있으면 클릭 가능한 칩으로
const stockChip = item.primary_stock
? `<span data-stock="${item.primary_stock}" style="color:#40C4FF;cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px">${esc(item.primary_stock)}</span>`
: `<span style="color:#546E7A">-</span>`;
return `<div class="news-item"><div class="news-icon"><div class="arrow" style="color:${sc}">${si}</div><div class="score" style="color:${sc}">${item.intensity||0}</div></div>
<div class="news-body"><div class="news-title">${titleHtml}</div>
<div class="news-meta">${stockChip}<span style="color:#546E7A">${esc(item.source||'')}</span><span style="color:#455A64">${ftime(item.analyzed_at)}</span></div>
${item.reason?`<div class="news-reason">${esc((item.reason||'').substring(0,100))}</div>`:''}</div>
<div class="badge" style="background:${sc}12;border:1px solid ${sc}30;color:${sc}">${item.investment_action||'관망'}</div></div>`;
}
function recCardHTML(item){
const rc=RCOL[item.recommendation]||'#78909C';
const pctVal=Math.min(100,Math.max(0,((item.total_score||0)+100)/2));
const bc=item.total_score>=30?'#00E676':item.total_score>=-30?'#78909C':'#FF5252';
return `<div class="rec-card" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer"><div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px">
<div><div style="font-size:15px;font-weight:700;color:#E0E0E0">${esc(item.stock_name)}</div><div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code}</div></div>
<div class="badge" style="background:${rc}15;border:1px solid ${rc}30;color:${rc}">${item.recommendation}</div></div>
<div style="display:flex;gap:10px;font-size:11px;color:#78909C;flex-wrap:wrap">
<span>종합 <b style="color:#E0E0E0;font-size:14px">${(item.total_score||0).toFixed(1)}</b></span>
<span>뉴스 <b style="color:#69F0AE">${(item.news_score||0).toFixed(0)}</b></span>
<span>기술 <b style="color:#CE93D8">${(item.technical_score||0).toFixed(0)}</b></span>
<span>공시 <b style="color:#40C4FF">${(item.dart_score||0).toFixed(0)}</b></span>
<span>외국인 <b style="color:${(item.foreign_score||0)>=0?'#00E676':'#FF5252'}">${(item.foreign_score||0).toFixed(0)}</b></span>
<span>공매도 <b style="color:${(item.short_score||0)>=0?'#78909C':'#FF8A80'}">${(item.short_score||0).toFixed(0)}</b></span></div>
${(item.foreign_ratio||0)>0?`<div style="font-size:10px;color:#546E7A;margin-top:4px">외국인보유 ${(item.foreign_ratio||0).toFixed(2)}% · 공매도비중 ${(item.short_weight||0).toFixed(2)}%</div>`:''}
<div class="score-bar"><div class="score-fill" style="width:${pctVal}%;background:${bc}"></div></div>
${item.top_reasons?`<div style="font-size:11px;color:#607D8B;line-height:1.5;border-top:1px solid rgba(255,255,255,0.04);padding-top:8px;margin-top:10px">${esc((item.top_reasons||'').split('|')[0].trim().substring(0,120))}...</div>`:''}</div>`;
}
function renderRecs(recs,avoid){
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">뉴스 감성 + DART 공시 + 가격 모멘텀 + 기술적 분석 종합 점수 (종목별 중복 제거)</div>`;
h+=`<h3 style="font-size:14px;font-weight:700;color:#69F0AE;margin-bottom:12px">🟢 매수 추천 종목</h3><div class="rec-grid">`;
if(recs&&recs.length){
recs.forEach(item=>{h+=recCardHTML(item)});
} else h+=`<div class="empty" style="grid-column:1/-1">추천 데이터가 아직 없습니다.</div>`;
h+=`</div>`;
h+=`<h3 style="font-size:14px;font-weight:700;color:#FF8A80;margin:28px 0 12px">⚠️ 매도관심 / 회피 종목</h3>`;
h+=`<div style="font-size:12px;color:#546E7A;margin-bottom:12px">악재 뉴스·공시가 누적된 종목입니다. 신규 매수보다 관망을 권장합니다.</div>`;
h+=`<div class="rec-grid">`;
if(avoid&&avoid.length){
avoid.forEach(item=>{h+=recCardHTML(item)});
} else h+=`<div class="empty" style="grid-column:1/-1">현재 회피 종목 없음</div>`;
h+=`</div>`;
document.getElementById('recommend').innerHTML=h;
}
let _newsOnlyStock = (localStorage.getItem('news_only_stock') ?? '1') === '1';
async function renderNews(recent){
// 토글 상태에 따라 다시 fetch (loadAll의 첫 호출은 only_stock=true 기본값)
const list = recent || await api('/recent?limit=80&only_stock='+(_newsOnlyStock?'true':'false'));
let h=`<div class="panel" style="max-height:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">${_newsOnlyStock?'📰 종목 관련 뉴스':'📰 전체 뉴스'}</h3>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:#90A4AE;cursor:pointer">
<input type="checkbox" id="news-only-stock" ${_newsOnlyStock?'checked':''} onchange="toggleNewsFilter(this.checked)" style="cursor:pointer">
종목 관련만 보기
</label>
</div>`;
if(list&&list.length){list.forEach(item=>{h+=newsItemHTML(item)})} else h+=`<div class="empty">뉴스 데이터 수집 중...</div>`;
h+=`</div>`;
document.getElementById('news').innerHTML=h;
}
async function toggleNewsFilter(checked){
_newsOnlyStock = checked;
localStorage.setItem('news_only_stock', checked?'1':'0');
const list = await api('/recent?limit=80&only_stock='+(checked?'true':'false'));
renderNews(list);
}
function renderAlerts(alerts){
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">강도 3 이상 뉴스/공시 (최근 24시간) · 종목코드 클릭 시 차트 모달이 열립니다</div>`;
if(alerts&&alerts.length){
alerts.forEach(item=>{
const sc=SCOL[item.sentiment]||'#78909C', si=SICON[item.sentiment]||'●';
const stockChip = item.primary_stock
? `<span data-stock="${item.primary_stock}" style="color:#40C4FF;cursor:pointer;font-family:'JetBrains Mono',monospace;background:rgba(64,196,255,0.08);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:6px">${esc(item.primary_stock)}</span>`
: '';
h+=`<div class="alert-item" style="background:${sc}08;border:1px solid ${sc}20">
<div style="display:flex;justify-content:space-between;align-items:center">
<div style="font-size:13px;font-weight:600;color:${sc}">${si} ${item.sentiment} (강도 ${item.intensity})</div>
<div style="font-size:10px;color:#546E7A">${ftime(item.analyzed_at)}</div></div>
<div style="font-size:12px;color:#CFD8DC;margin-top:6px;line-height:1.4">${stockChip}${esc(item.title||'')}</div>
<div style="font-size:11px;color:#78909C;margin-top:4px">${esc((item.reason||'').substring(0,100))}</div></div>`;
});
} else h+=`<div class="panel"><div class="empty">현재 긴급 알림이 없습니다.</div></div>`;
document.getElementById('alerts').innerHTML=h;
}
async function renderSupplyDemand(){
const el=document.getElementById('supplydemand');
el.innerHTML='<div class="empty">로딩 중...</div>';
const [supplyData,shortData]=await Promise.all([api('/supply-demand?limit=20'),api('/short-ranking?limit=20')]);
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">키움증권 API 기반 외국인/기관 수급 + 공매도 현황 (장 종료 후 17시 갱신)</div>`;
h+=`<div class="grid2">`;
// 외국인 순매수 상위
h+=`<div class="panel"><h3>🌏 외국인 순매수 상위 (5일 누적)</h3>`;
if(supplyData&&supplyData.length){
supplyData.slice(0,15).forEach((item,i)=>{
const net=item.net_5d||0;
const col=net>=0?'#00E676':'#FF5252';
const fscore=(item.foreign_score||0).toFixed(1);
const fscoreCol=(item.foreign_score||0)>=0?'#69F0AE':'#FF8A80';
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer">
<div class="rank-num ${i<3?'top':'normal'}">${i+1}</div>
<div style="flex:1">
<div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name||item.stock_code)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code} · 보유 ${(item.hold_ratio||0).toFixed(2)}%</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;font-weight:700;color:${col};font-family:'JetBrains Mono',monospace">${net>=0?'+':''}${Number(net).toLocaleString()}주</div>
<div style="font-size:10px;color:${fscoreCol}">수급점수 ${fscore}</div>
</div>
</div>`;
});
} else h+=`<div class="empty">외국인 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>`;
h+=`</div>`;
// 공매도 비중 상위
h+=`<div class="panel"><h3>📉 공매도 비중 상위 (최근일 기준)</h3>`;
if(shortData&&shortData.length){
shortData.slice(0,15).forEach((item,i)=>{
const w=item.trade_weight||0;
const col=w>=5?'#FF5252':w>=2?'#FF8A80':'#78909C';
const sscore=(item.short_score||0).toFixed(1);
const sscoreCol=(item.short_score||0)>=0?'#78909C':'#FF8A80';
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer">
<div class="rank-num ${i<3?'top':'normal'}" style="${i<3?'background:rgba(255,82,82,0.12);color:#FF5252':''}">${i+1}</div>
<div style="flex:1">
<div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name||item.stock_code)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code} · 잔고 ${Number(item.balance||0).toLocaleString()}주</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;font-weight:700;color:${col}">${w.toFixed(2)}%</div>
<div style="font-size:10px;color:${sscoreCol}">공매도점수 ${sscore}</div>
</div>
</div>`;
});
} else h+=`<div class="empty">공매도 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>`;
h+=`</div>`;
h+=`</div>`;
// 설명 카드
h+=`<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:14px;padding:20px;margin-top:16px">
<div style="font-size:13px;font-weight:700;color:#90A4AE;margin-bottom:12px">📌 수급 점수 해석</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;font-size:12px;color:#78909C;line-height:1.8">
<div>
<div style="color:#69F0AE;font-weight:600;margin-bottom:4px">외국인 점수 (+)</div>
<div>순매수 연속 3일 이상 → +40</div>
<div>보유비율 5일 상승 추세 → +40</div>
<div>절대 보유비율 10%↑ → +20</div>
</div>
<div>
<div style="color:#FF8A80;font-weight:600;margin-bottom:4px">공매도 점수 (-)</div>
<div>공매도 비중 10%↑ → -60</div>
<div>공매도 비중 5~10% → -35</div>
<div>잔고 5일 20%↑ → -20</div>
</div>
</div>
</div>`;
el.innerHTML=h;
}
setInterval(()=>{const c=document.getElementById('clock');if(c)c.textContent=new Date().toLocaleTimeString('ko-KR')},1000);
// 인증 후에만 동작하는 자동 새로고침
let _refreshTimer=null;
function startAutoRefresh(){if(_refreshTimer)return;_refreshTimer=setInterval(()=>{if(isLoggedIn())loadAll();},30000);}
function stopAutoRefresh(){if(_refreshTimer){clearInterval(_refreshTimer);_refreshTimer=null;}}
// 페이지 로드: 토큰 검증 → 대시보드 또는 로그인
(async()=>{
if(!isLoggedIn()){showLoginPage();return;}
try{
const r=await fetch(API+'/auth/me',{headers:authHeaders()});
if(!r.ok){clearAuth();showLoginPage();return;}
const me=await r.json();
setAuth(_authToken,me); // role 포함 갱신
showDashboard();
await Promise.all([loadPortfolio(), loadAll()]);
startAutoRefresh();
setTimeout(maybeOfferImport,500);
}catch(e){clearAuth();showLoginPage();}
})();
// 로그인 직후 자동 새로고침 시작
const _origShowDashboard=showDashboard;
showDashboard=function(){_origShowDashboard();startAutoRefresh();};
const _origLogout=logoutUser;
logoutUser=async function(){stopAutoRefresh();await _origLogout();};
async function doSearch(){
const q=document.getElementById('searchInput').value.trim();
if(!q){document.getElementById('searchResults').style.display='none';return}
const code6=q.match(/^\d{6}$/)?q:'';
const [news,sigs,ta]=await Promise.all([
api('/search?q='+encodeURIComponent(q)),
fetch(API.replace('8989','8585')+'/signals/'+q).then(r=>r.json()).catch(()=>[]),
code6?api('/technical/'+code6):Promise.resolve(null)
]);
let h=`<div class="panel" style="max-height:none"><h3>🔍 "${esc(q)}" 검색 결과</h3>`;
// 기술적 분석 상세
if(ta&&ta.indicators){
const ind=ta.indicators, tg=ta.targets||{};
const sc=ta.signal==='매수'?'#00E676':ta.signal==='매도'?'#FF5252':'#78909C';
h+=`<div style="background:${sc}06;border:1px solid ${sc}20;border-radius:12px;padding:16px;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="font-size:14px;color:${sc}">📊 기술적 분석 (${ta.signal||'관망'})</h4>
<span style="font-size:12px;color:#78909C">기술점수 <b style="color:${sc};font-size:16px">${(ta.tech_score||0).toFixed(1)}</b></span>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;font-size:12px;margin-bottom:12px">
<div style="color:#78909C">현재가 <b style="color:#E0E0E0">${fmt(ind.price)}</b></div>
<div style="color:#78909C">MA5 <b style="color:${ind.price>ind.ma5?'#69F0AE':'#FF8A80'}">${fmt(ind.ma5)}</b></div>
<div style="color:#78909C">MA20 <b style="color:${ind.price>ind.ma20?'#69F0AE':'#FF8A80'}">${fmt(ind.ma20)}</b></div>
<div style="color:#78909C">MA60 <b style="color:${ind.price>ind.ma60?'#69F0AE':'#FF8A80'}">${fmt(ind.ma60)}</b></div>
<div style="color:#78909C">RSI <b style="color:${ind.rsi<40?'#69F0AE':ind.rsi>70?'#FF8A80':'#E0E0E0'}">${(ind.rsi||0).toFixed(1)}</b></div>
<div style="color:#78909C">MACD <b style="color:${(ind.macd_hist||0)>0?'#69F0AE':'#FF8A80'}">${(ind.macd_hist||0).toFixed(3)}</b></div>
<div style="color:#78909C">볼밴%B <b style="color:#FFD740">${((ind.pct_b||0.5)*100).toFixed(0)}%</b></div>
<div style="color:#78909C">거래량비 <b style="color:#40C4FF">${(ind.vol_ratio||1).toFixed(1)}x</b></div>
</div>
${tg.t1?`<div class="price-row">
<div class="price-box"><div class="label">진입 추천가</div><div class="val" style="color:#40C4FF">${fmt(tg.entry_price)}</div></div>
<div class="price-box"><div class="label">목표1 (${pct(tg.t1_pct)})</div><div class="val" style="color:#69F0AE">${fmt(tg.t1)}</div></div>
<div class="price-box"><div class="label">목표2 (${pct(tg.t2_pct)})</div><div class="val" style="color:#00E676">${fmt(tg.t2)}</div></div>
<div class="price-box"><div class="label">손절가 (${pct(tg.stop_pct)})</div><div class="val" style="color:#FF8A80">${fmt(tg.stop_loss)}</div></div>
</div>`:''}
<div class="ta-row" style="margin-top:10px">${(ta.signals||[]).map(s=>`<span class="ta-badge">${esc(s)}</span>`).join('')}</div>
</div>`;
}
if(sigs&&sigs.length){
h+=`<div style="margin-bottom:16px"><h4 style="font-size:13px;color:#69F0AE;margin-bottom:8px">💰 매매 시그널</h4>`;
sigs.forEach(s=>{
const sc=s.signal_type==='매수'?'#00E676':'#FF5252';
h+=`<div data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name||'')}" style="background:${sc}08;border:1px solid ${sc}20;border-radius:10px;padding:12px;margin-bottom:8px;cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:14px;font-weight:700;color:${sc}">${s.signal_type} ${esc(s.stock_name)} (${s.stock_code})</span>
<span style="font-size:12px;color:#78909C">신뢰도 ${(s.confidence||0).toFixed(1)}%</span></div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;font-size:12px">
<div style="color:#78909C">현재가 <b style="color:#E0E0E0">${fmt(s.current_price)}원</b></div>
<div style="color:#78909C">목표가 <b style="color:#69F0AE">${fmt(s.target_price)}원</b></div>
<div style="color:#78909C">손절가 <b style="color:#FF8A80">${fmt(s.stop_loss)}원</b></div>
<div style="color:#78909C">예상수익 <b style="color:#FFD740">${pct(s.expected_return_pct)}</b></div></div>
${s.reason?`<div style="font-size:11px;color:#607D8B;margin-top:8px">${esc(s.reason.substring(0,150))}</div>`:''}</div>`;
});
h+=`</div>`;
}
if(news&&news.length){
h+=`<h4 style="font-size:13px;color:#90A4AE;margin:12px 0 8px">📰 관련 뉴스/공시 (${news.length}건)</h4>`;
news.forEach(item=>{h+=newsItemHTML(item)});
} else if(!sigs||!sigs.length){
h+=`<div class="empty">검색 결과가 없습니다.</div>`;
}
h+=`</div>`;
const el=document.getElementById('searchResults');
el.innerHTML=h;el.style.display='block';
// 검색 결과가 종목코드면 차트 자동 열기 옵션
if(code6){
const ctaBtn = `<div style="margin-bottom:12px"><button onclick="openStockChart('${code6}')" style="background:linear-gradient(135deg,#00C853,#00897B);border:none;border-radius:8px;padding:8px 18px;color:#fff;font-size:12px;font-weight:700;cursor:pointer">📊 ${code6} 차트 모달 열기</button></div>`;
el.querySelector('.panel').insertAdjacentHTML('afterbegin', ctaBtn);
}
}
// ── 챗봇 ──────────────────────────────────────────────────
let chatHistory=[], chatOpen=false;
function toggleChat(){
chatOpen=!chatOpen;
const p=document.getElementById('chat-panel');
p.style.display=chatOpen?'flex':'none';
if(chatOpen) document.getElementById('chat-input').focus();
}
function chatKey(e){
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();}
}
function mdToHtml(t){
return t
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/`(.+?)`/g,'<code>$1</code>')
.replace(/^### (.+)$/gm,'<strong>$1</strong>')
.replace(/^## (.+)$/gm,'<strong>$1</strong>')
.replace(/^- (.+)$/gm,'• $1')
.replace(/\n/g,'<br>');
}
async function sendChat(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg) return;
input.value='';
const msgs=document.getElementById('chat-messages');
// 유저 메시지
msgs.innerHTML+=`<div class="msg user">${msg.replace(/</g,'&lt;')}</div>`;
// 타이핑 표시
const typingId='typing-'+Date.now();
msgs.innerHTML+=`<div class="typing" id="${typingId}"><span></span><span></span><span></span></div>`;
msgs.scrollTop=msgs.scrollHeight;
document.getElementById('chat-send').disabled=true;
chatHistory.push({role:'user',content:msg});
// AI 답변 버블 생성
const aiId='ai-'+Date.now();
let fullText='';
try{
const resp=await fetch('/api/chat',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:msg, history:chatHistory.slice(-10)})
});
const reader=resp.body.getReader();
const dec=new TextDecoder();
let buf='';
// 타이핑 제거, AI 버블 추가
document.getElementById(typingId)?.remove();
msgs.innerHTML+=`<div class="msg ai" id="${aiId}"></div>`;
while(true){
const{done,value}=await reader.read();
if(done) break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n');
buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: ')) continue;
const payload=line.slice(6);
if(payload==='[DONE]') break;
try{
const delta=JSON.parse(payload).content||'';
fullText+=delta;
document.getElementById(aiId).innerHTML=mdToHtml(fullText);
msgs.scrollTop=msgs.scrollHeight;
}catch{}
}
}
}catch(e){
document.getElementById(typingId)?.remove();
msgs.innerHTML+=`<div class="msg ai" style="color:#FF8A80">오류가 발생했습니다. 다시 시도해주세요.</div>`;
}
if(fullText) chatHistory.push({role:'assistant',content:fullText});
document.getElementById('chat-send').disabled=false;
msgs.scrollTop=msgs.scrollHeight;
}
</script>
<!-- 종목 차트 모달 -->
<div id="stock-chart-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:1500;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)hideStockChart()">
<div style="background:#141B22;border:1px solid rgba(0,230,118,0.18);border-radius:16px;padding:24px;width:1100px;max-width:100%;max-height:92vh;overflow:auto;box-shadow:0 12px 60px rgba(0,0,0,0.7)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<div>
<div id="sc-title" style="font-size:18px;font-weight:700;color:#E0E0E0">종목 차트</div>
<div id="sc-subtitle" style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace;margin-top:2px"></div>
</div>
<button onclick="hideStockChart()" style="background:none;border:none;color:#78909C;font-size:22px;cursor:pointer">×</button>
</div>
<div id="sc-info" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;margin-bottom:12px"></div>
<!-- 시간프레임 토글 -->
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="tf-btn active" data-tf="day" onclick="changeTimeframe('day')" style="background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);color:#00E676;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer">일봉</button>
<button class="tf-btn" data-tf="60" onclick="changeTimeframe('60')" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer">60분</button>
<button class="tf-btn" data-tf="30" onclick="changeTimeframe('30')" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer">30분</button>
<button class="tf-btn" data-tf="15" onclick="changeTimeframe('15')" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer">15분</button>
<button class="tf-btn" data-tf="5" onclick="changeTimeframe('5')" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer">5분</button>
<button class="tf-btn" data-tf="1" onclick="changeTimeframe('1')" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer">1분</button>
</div>
<!-- 차트 + 호가 좌우 -->
<div style="display:grid;grid-template-columns:1fr 220px;gap:10px;align-items:start">
<div>
<div style="position:relative;height:340px;background:rgba(0,0,0,0.2);border-radius:10px;padding:8px"><canvas id="sc-candle"></canvas></div>
<div style="position:relative;height:110px;margin-top:8px;background:rgba(0,0,0,0.2);border-radius:10px;padding:8px"><canvas id="sc-volume"></canvas></div>
<div id="sc-flow-wrap" style="position:relative;height:110px;margin-top:8px;background:rgba(0,0,0,0.2);border-radius:10px;padding:8px"><canvas id="sc-flow"></canvas></div>
</div>
<div id="sc-orderbook" style="background:rgba(0,0,0,0.25);border:1px solid rgba(255,255,255,0.06);border-radius:10px;padding:10px;font-size:11px;font-family:'JetBrains Mono',monospace"></div>
</div>
<div id="sc-fundamentals" style="margin-top:14px;font-size:12px;color:#90A4AE"></div>
</div>
</div>
<!-- 관리자 회원관리 모달 -->
<div id="admin-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:2000;align-items:center;justify-content:center;padding:20px">
<div style="background:#141B22;border:1px solid rgba(0,230,118,0.2);border-radius:16px;padding:28px;width:680px;max-width:100%;max-height:84vh;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,0.6)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="font-size:18px;color:#E0E0E0;margin:0">👥 회원 관리</h2>
<button onclick="hideAdminModal()" style="background:none;border:none;color:#78909C;font-size:22px;cursor:pointer">×</button>
</div>
<div id="admin-users-list" style="font-size:13px;color:#90A4AE">불러오는 중...</div>
</div>
</div>
</body>
</html>