6d3b0bacc0
- 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>
1728 lines
106 KiB
HTML
1728 lines
106 KiB
HTML
<!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&§or.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)})
|
||
T2 <b style="color:#00E676">${fmt(tg.t2)}</b>(${pct(tg.t2_pct)})
|
||
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)}% ${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">💡 오늘의 투자 지식 · ${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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.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,'<')}</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>
|