Files
kyu 8b200e6cc6 feat: 핫종목 렌즈 + 뉴스 파서 견고화 + 텔레그램 알림 2회 축소
- news-collector: EXAONE JSON 파서 견고화(max_tokens 800 + 관대 파싱 → 파싱실패율 9.3%→3.3%),
  평일 18:30 종목뉴스 스위프(중형주 신선도 사각지대 해소), /process/raw reprocess 플래그,
  파싱실패→Gemini 폴백 라우팅(일일캡 1→50 상수화)
- score-engine: /hot 모멘텀 렌즈(뉴스+거래량, 백테스트로 가격 제외) + /hot/backtest,
  텔레그램 정기 브리핑 2회로 축소(중복 send_briefing 자동스케줄 제거)
- dashboard-api: /api/hot 프록시 + 🔥 지금뜨는 탭
- telegram-bot: /hot 명령 + 한글(뜨는/핫/급등) 라우팅

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:22:23 +09:00

2532 lines
165 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trading AI Dashboard</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0}
::-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}
.sidebar{position:fixed;left:0;top:0;bottom:0;width:212px;background:#0A0E14;border-right:1px solid rgba(255,255,255,0.06);padding:18px 12px;overflow-y:auto;z-index:200;transition:transform .25s ease}
.brand{font-size:16px;font-weight:800;letter-spacing:-0.5px;padding:4px 10px 16px;display:flex;align-items:center;gap:7px;color:#E0E0E0}
.brand span{color:#00E676}
.tabs{display:flex;flex-direction:column;gap:2px;margin:0}
.tab{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:1px solid transparent;border-radius:8px;padding:10px 12px;color:#90A4AE;font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;transition:background .15s}
.tab:hover{background:rgba(255,255,255,0.04);color:#CFD8DC}
.tab.active{background:rgba(0,230,118,0.1);border-color:rgba(0,230,118,0.2);color:#00E676;font-weight:700}
.main{margin-left:212px;padding:0 24px 40px;min-height:100vh}
.hamburger{display:none;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;width:40px;height:40px;color:#CFD8DC;font-size:18px;cursor:pointer;flex-shrink:0;align-items:center;justify-content:center}
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:150}
@media(max-width:860px){
.sidebar{transform:translateX(-100%);width:248px;box-shadow:6px 0 28px rgba(0,0,0,0.55)}
.sidebar.open{transform:translateX(0)}
.main{margin-left:0;padding:0 14px 40px}
.hamburger{display:flex}
.sidebar-overlay.show{display:block}
.header{flex-wrap:wrap}
.header h1{font-size:17px}
.header .sub{display:none}
}
.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}}
@media(max-width:680px){.usbrief-grid{grid-template-columns:1fr !important}}
.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:300;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>
<aside class="sidebar" id="sidebar">
<div class="brand"><span></span> Trading AI</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('hot',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>
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
<button class="tab" onclick="showTab('glossary',this)">📖 용어설명</button>
</div>
</aside>
<div class="sidebar-overlay" id="sbOverlay" onclick="toggleSidebar(false)"></div>
<div class="main">
<div class="wrap">
<div class="header">
<button class="hamburger" onclick="toggleSidebar()"></button>
<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 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="hot" 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 id="formulas" style="display:none"></div>
<div id="backtest" style="display:none"></div>
<div id="usmarket" style="display:none"></div>
<div id="macro" style="display:none"></div>
<div id="glossary" style="display:none"></div>
<div class="footer">Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요</div>
</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 toggleSidebar(force){
const sb=document.getElementById('sidebar'), ov=document.getElementById('sbOverlay');
if(!sb)return;
const open = force===undefined ? !sb.classList.contains('open') : !!force;
sb.classList.toggle('open',open);
if(ov)ov.classList.toggle('show',open);
}
function showTab(id,el){
['overview','performance','signals','surge','hot','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro','glossary'].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');
toggleSidebar(false);
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();
if(id==='hot') renderHot();
if(id==='formulas') renderFormulas();
if(id==='backtest') renderBacktest();
if(id==='usmarket') renderUsMarket();
if(id==='macro') renderMacro();
if(id==='glossary') renderGlossary();
}
// ── 용어 설명 ────────────────────────────────────────────────
const GLOSSARY=[
{t:'📊 차트 보는 법',items:[
['캔들(봉)','하루 주가 움직임을 막대 하나로 표시. 끝가가 시작가보다 <b>높으면 빨강(양봉=올랐다)</b>, <b>낮으면 파랑(음봉=내렸다)</b>. 위·아래로 삐져나온 가는 선은 그날의 최고가·최저가.'],
['거래량','그날 사고판 주식 수. 많을수록 시장 관심이 크다는 뜻. 거래량 없이 슬금슬금 오르는 상승은 힘이 약하다.'],
['시가/종가/고가/저가','시가=장 시작 가격, 종가=장 끝 가격, 고가=그날 최고가, 저가=그날 최저가.'],
]},
{t:'📈 이동평균선 (MA = Moving Average)',items:[
['MA5','최근 <b>5일</b> 종가의 평균선. 약 1주일치 단기 흐름.'],
['MA20','최근 <b>20일</b> 평균선. 약 한 달 흐름이라 "생명선"이라 부른다. 주가가 이 위면 단기 분위기 양호.'],
['MA60','최근 <b>60일</b> 평균선. 약 3개월, 중기 추세.'],
['MA120','최근 <b>120일</b> 평균선. 약 6개월, 장기 추세라 "경기선"이라 부른다.'],
['정배열 / 역배열','MA5>MA20>MA60 순으로 짧은 선이 위에 있으면 <b>정배열=상승 추세</b>. 거꾸로면 역배열=하락 추세.'],
['골든크로스 / 데드크로스','짧은 선이 긴 선을 아래→위로 뚫으면 <b>골든크로스(매수 신호)</b>, 위→아래로 뚫으면 데드크로스(매도 신호).'],
]},
{t:'🎯 기술적 지표 (매매 타이밍)',items:[
['RSI','0~100 숫자. <b>70 이상이면 과열</b>(너무 많이 사서 비쌈), <b>30 이하면 과매도</b>(너무 많이 팔려 쌈). 되돌림 가능성을 본다.'],
['MACD','단기·장기 이동평균의 차이로 추세 전환을 잡는 지표. 0선 위로 올라오면 상승 힘이 붙는 것.'],
['볼린저밴드','주가가 보통 움직이는 범위를 상단·중간·하단 띠로 표시. 상단에 닿으면 단기 비쌈, 하단이면 쌈.'],
['스토캐스틱','RSI처럼 과열/과매도를 보는 지표. 20 이하 과매도, 80 이상 과열.'],
['OBV','거래량으로 돈이 들어오는지(매집)·나가는지(분산)를 추적. 주가보다 먼저 움직이기도 한다.'],
['VWAP','거래량 가중 평균가. 기관 투자자가 "본전" 기준으로 삼는 가격.'],
['일목균형표','구름대(저항·지지 구간)로 추세를 보는 일본식 종합 지표.'],
]},
{t:'💰 재무 지표 (회사가 돈을 잘 버나)',items:[
['ROE','자기자본이익률. 주주 돈 100원으로 1년에 얼마 벌었나. <b>높을수록 좋고 15% 이상이면 우수</b>. 워런 버핏이 가장 중시하는 숫자.'],
['영업이익률','매출 100원 중 본업으로 남긴 이익 비율. 높을수록 사업 경쟁력이 강하다.'],
['순이익률','매출에서 모든 비용·이자·세금을 다 빼고 최종으로 남은 비율.'],
['부채비율','자기자본 대비 빚의 비율. <b>100% 이하면 안전</b>, 200%를 넘으면 위험 신호.'],
['FCF비율','잉여현금흐름(Free Cash Flow). 회사가 벌어서 쓸 거 다 쓰고 <b>실제로 남긴 진짜 현금</b>. 회계상 이익보다 정직한 숫자라 플러스면 건강.'],
['매출성장률','작년 대비 올해 매출이 얼마나 늘었나. 회사가 커가고 있는지를 본다.'],
]},
{t:'🏷️ 가치 지표 (주가가 싼가 비싼가)',items:[
['PER','주가 ÷ 주당순이익. 회사 이익 대비 주가가 몇 배인가. <b>낮을수록 싸다</b>. 보통 10배 이하면 저평가 경향.'],
['PBR','주가 ÷ 주당순자산. <b>1배 미만이면 회사가 가진 자산보다 주가가 싸게</b> 거래되는 것.'],
['EPS / BPS','EPS=주당순이익(1주가 1년에 번 돈). BPS=주당순자산(회사 청산 시 1주가 받을 몫).'],
['시가총액','주가 × 총 주식 수 = 회사 전체의 시장 몸값.'],
['배당수익률','주가 대비 1년간 받는 배당금 비율. 은행 이자처럼 보면 된다.'],
]},
{t:'🧮 이 시스템의 점수',items:[
['종합점수 (-100~100)','재무·뉴스·기술·공시·수급을 다 합친 최종 점수. <b>높을수록 매수 매력</b>이 크다.'],
['펀더멘털 점수','회사의 본질 가치 점수 (ROE·이익률·부채·현금흐름 등 기반).'],
['기술점수','차트·기술 지표 기반의 단기 매매 타이밍 점수.'],
['뉴스점수','최근 7일 뉴스의 호재/악재를 종합한 점수.'],
['10공식 보팅','매직포뮬러·F-Score 등 학계·실전 검증된 10개 투자 공식이 각자 매수/매도를 투표한 결과.'],
['추천등급','강력매수 &gt; 매수관심 &gt; 관망 &gt; 매도관심 &gt; 강력매도 순. 점수와 공식 투표를 함께 본다.'],
]},
{t:'🏛️ 시장 제도·거래 용어',items:[
['공매도','내가 갖고 있지도 않은 주식을 <b>빌려서 먼저 판 뒤</b>, 값이 떨어지면 싸게 되사서 갚아 차익을 먹는 기법. 주가 <b>하락에 베팅</b>하는 것. 공매도 물량이 많으면 하락 압력 신호로 본다 (이 시스템의 공매도 점수에 반영).'],
['사이드카','선물 가격이 ±5~6% 급등락하면 <b>프로그램 매매(컴퓨터 자동매매)를 5분간 정지</b>시키는 장치. 시장 쏠림을 잠깐 식히는 가벼운 브레이크. 5분 후 자동 해제.'],
['서킷브레이커','사이드카보다 센 비상정지. 지수가 8%·15%·20% 급락하면 <b>주식 거래 전체를 20분간 멈춤</b>. 패닉 매도를 막는 장치.'],
['VI (변동성완화장치)','개별 종목이 갑자기 급등락하면 2~3분간 단일가 매매로 바꿔 진정시키는 장치. 사이드카의 개별 종목 버전.'],
['상한가 / 하한가','하루에 주가가 오를·내릴 수 있는 한계. 한국은 전일 종가 대비 <b>±30%</b>.'],
['ISA','개인종합자산관리계좌. 주식·펀드·예금을 한 계좌에 담아 굴리는 <b>절세 계좌</b>. 수익 일정액까지 세금 면제·저율 과세. 의무 보유기간(보통 3년) 있음. "세금 혜택 받는 장기투자용 계좌".'],
['배당','회사가 번 이익의 일부를 주주에게 나눠주는 돈. 1년에 한 번(결산배당)이 보통이고 분기마다 주는 회사도 있다.'],
]},
{t:'💡 투자 스타일·철학',items:[
['가치투자','회사의 본질가치보다 <b>싸게 거래되는 주식</b>을 사서 제값을 찾아갈 때까지 기다리는 방식. 대표: 벤저민 그레이엄·워런 버핏. 이 시스템의 기본 관점.'],
['성장투자','지금 좀 비싸도 <b>매출·이익이 빠르게 크는 회사</b>면 산다. 미래 성장이 현재 가격을 정당화한다는 관점. 대표: 필립 피셔·피터 린치.'],
['모멘텀투자','<b>오르는 주식이 더 오른다</b>는 경험칙에 기대 추세에 올라타는 방식(추세추종). 대표: AQR·제시 리버모어. 이 시스템의 12-1 모멘텀 공식이 여기 해당.'],
['퀀트투자','감정을 배제하고 <b>데이터·통계로 패턴을 찾아 기계적으로</b> 매매. 대표: 짐 사이먼스(르네상스). 이 시스템의 10공식 엔진 자체가 퀀트 방식.'],
['매크로투자','개별 종목보다 <b>금리·환율·경기 같은 큰 흐름</b>에 베팅. 대표: 조지 소로스·레이 달리오.'],
['역발상투자','남들이 공포에 팔 때 사고 열광할 때 판다 — <b>대중과 반대로</b>. 대표: 존 템플턴.'],
['인덱스투자','시장을 이기기 어려우니 <b>지수 전체를 싸게 사서 장기 보유</b>(패시브). 대표: 존 보글. 버핏도 일반인에겐 이걸 권한다.'],
['기술적분석','재무가 아니라 <b>차트·거래량·지표로 매매 타이밍</b>을 잡는 방식. 가격이 모든 정보를 반영한다고 본다.'],
['배당투자','<b>배당을 꾸준히·많이 주는 회사</b>로 안정적인 현금흐름을 추구. 주가 등락보다 배당 수입을 중시.'],
['멀티팩터','가치·성장·모멘텀·퀄리티 등 <b>여러 방식을 데이터로 섞는</b> 현대 퀀트 접근. 한 스타일도 항상 이기진 못하기 때문 — <b>이 시스템이 실제로 쓰는 방식.</b>'],
]},
];
function renderGlossary(){
const el=document.getElementById('glossary');
let h=`<div style="font-size:13px;color:#90A4AE;margin-bottom:16px;line-height:1.6">
📖 주식 표·차트에 나오는 용어를 <b style="color:#00E676">쉬운 말</b>로 풀이했습니다. 모르는 단어가 보이면 여기서 찾아보세요.</div>`;
GLOSSARY.forEach(g=>{
h+=`<div class="panel" style="max-height:none;margin-bottom:14px"><h3 style="margin-bottom:10px">${g.t}</h3>`;
g.items.forEach(it=>{
h+=`<div style="display:flex;gap:14px;padding:11px 2px;border-bottom:1px solid rgba(255,255,255,0.05)">
<div style="flex:0 0 96px;color:#00E676;font-weight:700;font-size:13px">${it[0]}</div>
<div style="flex:1;color:#B0BEC5;font-size:12.5px;line-height:1.65">${it[1]}</div>
</div>`;
});
h+=`</div>`;
});
el.innerHTML=h;
}
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 renderHot(){
const el=document.getElementById('hot');
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
try{
const list=await api('/hot')||[];
if(!list.length){el.innerHTML='<div class="empty">지금 뜨는 종목 없음</div>';return;}
let h=`<div style="font-size:12px;color:#FFB74D;background:rgba(255,152,0,0.08);border:1px solid rgba(255,152,0,0.2);border-radius:8px;padding:10px 12px;margin-bottom:14px">
🔥 <b>지금 뜨는 종목</b> — 최근 뉴스 호재 + 거래량 급증 기준. <b style="color:#FF8A65">※ 관찰용입니다.</b> 백테스트상 핫종목은 5~10일 들면 시장에 지므로(되돌림) 매수신호가 아니라 "지금 뭐가 움직이나" 보는 창으로만 쓰세요.</div>`;
h+=`<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px">`;
list.forEach((r,i)=>{
const recoC=r.value_reco==='강력매수'?'#00E676':r.value_reco==='매수관심'?'#69F0AE':(r.value_reco||'').includes('매도')?'#FF5252':'#90A4AE';
const p5=r.price_5d_pct||0, p5C=p5>=0?'#00E676':'#FF5252';
h+=`<div data-stock="${r.stock_code}" data-stock-name="${esc(r.stock_name)}" style="cursor:pointer;background:rgba(255,255,255,0.03);border:1px solid rgba(255,152,0,0.12);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">${i+1}. ${esc(r.stock_name||r.stock_code)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${r.stock_code}</div>
</div>
<div style="font-size:15px;color:#FFB74D;font-weight:700">🔥 ${(r.hot_score||0).toFixed(0)}</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:#90A4AE">
${r.pos_news_3d?`<span>호재 ${r.pos_news_3d}건</span>`:''}
<span>5일 <b style="color:${p5C}">${p5>=0?'+':''}${p5.toFixed(1)}%</b></span>
${r.vol_ratio>=1.5?`<span>거래량 ${(r.vol_ratio||0).toFixed(1)}배</span>`:''}
</div>
<div style="margin-top:6px;font-size:11px;color:#78909C">가치판단: <b style="color:${recoC}">${r.value_reco||'-'}</b>${r.value_score!=null?` (${r.value_score}점)`:''}</div>
</div>`;
});
h+=`</div>`;
el.innerHTML=h;
}catch(e){
el.innerHTML='<div class="empty">로드 실패: '+esc(e.message)+'</div>';
}
}
async function api(path){try{const r=await fetch(API+path);return await r.json()}catch(e){return null}}
async function loadAll(){
const [summary,ranking,recs,avoid,recent,alerts,signals,cands,kospi,tip]=await Promise.all([
api('/summary'),api('/ranking'),api('/recommendations'),api('/avoid'),
api('/recent?limit=40'),api('/alerts'),api('/signals?days=3&limit=60'),api('/buy-candidates?limit=20'),
api('/kospi?days=30'),api('/daily-tip')
]);
renderOverview(summary,ranking,recent,kospi,tip);
renderSignals(signals,cands);
renderRecs(recs,avoid);
renderNews(recent);
renderAlerts(alerts);
}
function fmt(n){return n?Number(n).toLocaleString():'0'}
function pct(n){return n?(n>0?'+':'')+Number(n).toFixed(1)+'%':'0%'}
const INP_STYLE='width:100%;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 14px;color:#E0E0E0;font-size:14px;outline:none;box-sizing:border-box';
// ── 인증 (JWT) ─────────────────────────────────────────────
let _authToken = localStorage.getItem('auth_token') || '';
let _authUser = JSON.parse(localStorage.getItem('auth_user') || 'null');
let _authMode = 'login'; // 'login' | 'register'
function isLoggedIn(){return !!_authToken}
function isAdmin(){return _authUser?.role==='admin'}
function setAuth(token,user){_authToken=token;_authUser=user;localStorage.setItem('auth_token',token);localStorage.setItem('auth_user',JSON.stringify(user));renderAuthArea()}
function clearAuth(){_authToken='';_authUser=null;localStorage.removeItem('auth_token');localStorage.removeItem('auth_user');_portfolio=[];renderAuthArea()}
function authHeaders(){return _authToken?{'Authorization':'Bearer '+_authToken}:{}}
async function authedJson(path,opts={}){
const headers={'Content-Type':'application/json',...authHeaders(),...(opts.headers||{})};
const r=await fetch(API+path,{...opts,headers});
if(r.status===401){clearAuth();showLoginPage();throw new Error('unauthorized');}
if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.detail||('HTTP '+r.status));}
return r.json();
}
// 화면 게이트
function showDashboard(){document.body.classList.remove('auth-required');renderAuthArea();}
function showLoginPage(){
document.body.classList.add('auth-required');
setAuthMode('login');
document.getElementById('lg-info').style.display='none';
document.getElementById('lg-error').style.display='none';
document.getElementById('lg-password').value='';
setTimeout(()=>document.getElementById('lg-email')?.focus(),50);
}
function renderAuthArea(){
const el=document.getElementById('auth-area');
if(!el||!isLoggedIn()) return;
const adminBtn=isAdmin()?`<button class="btn" onclick="showAdminModal()" style="font-size:11px;padding:4px 10px;background:rgba(255,193,7,0.1);border-color:rgba(255,193,7,0.3);color:#FFC107">👥 회원관리</button>`:'';
el.innerHTML=`<span style="color:#90A4AE">${esc(_authUser?.email||'')}</span>
${adminBtn}
<button class="btn" onclick="logoutUser()" style="font-size:11px;padding:4px 10px">로그아웃</button>`;
}
function setAuthMode(mode){
_authMode=mode;
const isReg=mode==='register';
document.getElementById('lg-tab-login').classList.toggle('active',!isReg);
document.getElementById('lg-tab-reg').classList.toggle('active',isReg);
document.getElementById('lg-submit').textContent=isReg?'회원가입':'로그인';
document.getElementById('lg-pw-label').textContent=isReg?'비밀번호 (8자 이상)':'비밀번호';
document.getElementById('lg-password').setAttribute('autocomplete',isReg?'new-password':'current-password');
document.getElementById('lg-info').style.display='none';
document.getElementById('lg-error').style.display='none';
}
async function submitAuth(){
const email=document.getElementById('lg-email').value.trim();
const password=document.getElementById('lg-password').value;
const errEl=document.getElementById('lg-error');
const infoEl=document.getElementById('lg-info');
const btn=document.getElementById('lg-submit');
errEl.style.display='none'; infoEl.style.display='none';
if(!email||!password){errEl.textContent='이메일과 비밀번호를 입력하세요';errEl.style.display='block';return;}
if(_authMode==='register'&&password.length<8){errEl.textContent='비밀번호는 8자 이상이어야 합니다';errEl.style.display='block';return;}
btn.disabled=true;
try{
const path=_authMode==='register'?'/auth/register':'/auth/login';
const r=await fetch(API+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const d=await r.json();
if(!r.ok){errEl.textContent=d.detail||'요청 실패';errEl.style.display='block';return;}
if(d.pending){
infoEl.textContent=d.message||'관리자 승인 대기 중입니다';
infoEl.style.display='block';
setAuthMode('login');
document.getElementById('lg-password').value='';
return;
}
setAuth(d.access_token,d.user);
showDashboard();
await loadPortfolio();
loadAll();
maybeOfferImport();
}catch(e){errEl.textContent='네트워크 오류: '+e.message;errEl.style.display='block';}
finally{btn.disabled=false;}
}
async function logoutUser(){
clearAuth();
showLoginPage();
}
// ── 관리자: 회원 관리 모달 ────────────────────────────────
async function showAdminModal(){
document.getElementById('admin-modal').style.display='flex';
await loadAdminUsers();
}
function hideAdminModal(){document.getElementById('admin-modal').style.display='none';}
async function loadAdminUsers(){
const el=document.getElementById('admin-users-list');
el.textContent='불러오는 중...';
try{
const users=await authedJson('/admin/users');
if(!users.length){el.innerHTML='<div style="text-align:center;padding:40px;color:#546E7A">등록된 회원이 없습니다</div>';return;}
let h=`<div style="display:grid;grid-template-columns:1fr;gap:8px">`;
users.forEach(u=>{
const status=u.is_approved?(u.role==='admin'?'<span style="color:#FFC107">관리자</span>':'<span style="color:#69F0AE">승인됨</span>'):'<span style="color:#FFC107">대기중</span>';
const lastLogin=u.last_login_at?new Date(u.last_login_at.replace(' ','T')).toLocaleString('ko-KR'):'없음';
const locked=u.locked_until?` <span style="color:#FF5252;font-size:10px">잠김</span>`:'';
h+=`<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0">
<div style="color:#E0E0E0;font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(u.email)} ${locked}</div>
<div style="color:#78909C;font-size:11px;margin-top:2px">${status} · 가입 ${new Date(u.created_at.replace(' ','T')).toLocaleDateString('ko-KR')} · 최근 ${lastLogin}</div>
</div>
${!u.is_approved?`<button onclick="approveUser(${u.id})" style="background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);color:#00E676;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">승인</button>`:''}
${u.locked_until?`<button onclick="unlockUser(${u.id})" style="background:rgba(255,193,7,0.1);border:1px solid rgba(255,193,7,0.3);color:#FFC107;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">잠금해제</button>`:''}
${u.role!=='admin'?`<button onclick="deleteUser(${u.id},'${esc(u.email)}')" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);color:#FF8A80;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">삭제</button>`:''}
</div>`;
});
h+=`</div>`;
el.innerHTML=h;
}catch(e){el.innerHTML=`<div style="color:#FF8A80">로드 실패: ${esc(e.message)}</div>`;}
}
async function approveUser(id){
try{await authedJson('/admin/users/'+id+'/approve',{method:'POST'});await loadAdminUsers();}
catch(e){alert('승인 실패: '+e.message);}
}
async function unlockUser(id){
try{await authedJson('/admin/users/'+id+'/unlock',{method:'POST'});await loadAdminUsers();}
catch(e){alert('잠금해제 실패: '+e.message);}
}
async function deleteUser(id,email){
if(!confirm(`${email} 계정을 삭제할까요? (포트폴리오도 함께 삭제됩니다)`)) return;
try{await authedJson('/admin/users/'+id,{method:'DELETE'});await loadAdminUsers();}
catch(e){alert('삭제 실패: '+e.message);}
}
// ── 종목 차트 모달 ────────────────────────────────────────
let _scCharts = {candle:null, volume:null, flow:null};
let _scState = {code:'', name:'', timeframe:'day'};
// 클릭 위임: data-stock 속성이 있는 요소(또는 그 자식)를 클릭하면 차트 열림
document.addEventListener('click',(e)=>{
const el=e.target.closest('[data-stock]');
if(!el) return;
// 카드 내부 버튼/링크는 차트 열지 않음
if(e.target.closest('button,a,input,select,textarea')) return;
e.preventDefault();
openStockChart(el.dataset.stock, el.dataset.stockName||'');
});
function hideStockChart(){
document.getElementById('stock-chart-modal').style.display='none';
['candle','volume','flow'].forEach(k=>{ if(_scCharts[k]){_scCharts[k].destroy(); _scCharts[k]=null;} });
}
async function changeTimeframe(tf){
_scState.timeframe = tf;
document.querySelectorAll('.tf-btn').forEach(b=>{
const active = b.dataset.tf===tf;
b.style.background = active?'rgba(0,230,118,0.15)':'rgba(255,255,255,0.04)';
b.style.borderColor = active?'rgba(0,230,118,0.3)':'rgba(255,255,255,0.08)';
b.style.color = active?'#00E676':'#90A4AE';
b.classList.toggle('active', active);
});
await drawStockChart(null);
}
// 일봉 또는 분봉 데이터로 차트 3개 그리기
async function drawStockChart(prefetchedOhlcv){
const tf = _scState.timeframe;
const code = _scState.code;
if(!code) return;
['candle','volume','flow'].forEach(k=>{ if(_scCharts[k]){_scCharts[k].destroy(); _scCharts[k]=null;} });
const flowWrap = document.getElementById('sc-flow-wrap');
let rows = [];
let isMinute = tf !== 'day';
if(isMinute){
const d = await api('/minute/'+code+'?scope='+tf).catch(()=>null);
if(!d?.data?.length){
flowWrap.style.display='none';
return;
}
// 분봉 dt = "20260508153000" → ms
rows = [...d.data].reverse().slice(-200).map(r=>{
const s = String(r.dt);
const iso = `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)||'00'}`;
return {x:new Date(iso).valueOf(), o:r.open, h:r.high, l:r.low, c:r.close, v:r.volume, fnet:0, inet:0};
});
flowWrap.style.display='none'; // 분봉엔 외국인 순매수 데이터 없음
} else {
const ohlcv = prefetchedOhlcv || await api('/ohlcv/'+code+'?days=90').catch(()=>null);
if(!ohlcv?.data?.length){
flowWrap.style.display='none';
return;
}
rows = [...ohlcv.data].reverse().map(d=>{
const ds=String(d.dt); const iso=`${ds.slice(0,4)}-${ds.slice(4,6)}-${ds.slice(6,8)}`;
return {x:new Date(iso).valueOf(),
o:d.open||d.open_price||0, h:d.high||d.high_price||0, l:d.low||d.low_price||0, c:d.close||d.close_price||0,
v:d.volume, fnet:d.foreign_net||0, inet:d.institution_net||0};
});
flowWrap.style.display='block';
}
const closes=rows.map(r=>r.c);
const ma=(n)=>closes.map((_,i)=> i<n-1?null:closes.slice(i-n+1,i+1).reduce((s,v)=>s+v,0)/n);
const ma5=ma(5), ma20=ma(20), ma60=ma(60);
const candleData=rows.map(r=>({x:r.x,o:r.o,h:r.h,l:r.l,c:r.c}));
const volColors=rows.map(r=>r.c>=r.o?'rgba(0,230,118,0.6)':'rgba(255,82,82,0.6)');
const xUnit = isMinute ? (tf==='1'||tf==='5'?'minute':'hour') : 'day';
const xFmt = isMinute ? {minute:'HH:mm', hour:'M/d HH:mm'} : {day:'M/d'};
const baseScales={
x:{type:'time',time:{unit:xUnit,displayFormats:xFmt},grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},maxTicksLimit:10}},
y:{position:'right',grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},callback:v=>v.toLocaleString()}}
};
_scCharts.candle = new Chart(document.getElementById('sc-candle'),{
type:'candlestick',
data:{datasets:[
{label:'캔들',data:candleData,color:{up:'#00E676',down:'#FF5252',unchanged:'#90A4AE'},borderColor:{up:'#00E676',down:'#FF5252',unchanged:'#90A4AE'}},
{type:'line',label:'MA5',data:rows.map((r,i)=>({x:r.x,y:ma5[i]})),borderColor:'#FFD740',borderWidth:1.2,pointRadius:0,fill:false,tension:0.2,spanGaps:true},
{type:'line',label:'MA20',data:rows.map((r,i)=>({x:r.x,y:ma20[i]})),borderColor:'#40C4FF',borderWidth:1.5,pointRadius:0,fill:false,tension:0.2,spanGaps:true},
{type:'line',label:'MA60',data:rows.map((r,i)=>({x:r.x,y:ma60[i]})),borderColor:'#B388FF',borderWidth:1.5,pointRadius:0,fill:false,tension:0.2,spanGaps:true},
]},
options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,labels:{color:'#90A4AE',font:{size:10},boxWidth:14}},tooltip:{mode:'index',intersect:false}},
scales:baseScales}
});
_scCharts.volume = new Chart(document.getElementById('sc-volume'),{
type:'bar',
data:{labels:rows.map(r=>r.x),datasets:[{label:'거래량',data:rows.map(r=>r.v),backgroundColor:volColors,borderWidth:0}]},
options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>'거래량 '+Number(ctx.parsed.y).toLocaleString()}}},
scales:{...baseScales,y:{...baseScales.y,ticks:{...baseScales.y.ticks,callback:v=>(v/10000).toFixed(0)+'만'}}}}
});
if(!isMinute){
_scCharts.flow = new Chart(document.getElementById('sc-flow'),{
type:'bar',
data:{labels:rows.map(r=>r.x),datasets:[
{label:'외국인',data:rows.map(r=>r.fnet),backgroundColor:'rgba(64,196,255,0.7)'},
{label:'기관',data:rows.map(r=>r.inet),backgroundColor:'rgba(255,167,38,0.7)'},
]},
options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:true,labels:{color:'#90A4AE',font:{size:10}}}},
scales:{...baseScales,y:{...baseScales.y,ticks:{...baseScales.y.ticks,callback:v=>(v/10000).toFixed(0)+'만'}}}}
});
}
}
async function loadOrderbook(code){
const el=document.getElementById('sc-orderbook');
el.innerHTML='<div style="text-align:center;color:#546E7A;padding:20px">호가 불러오는 중...</div>';
try{
const d=await api('/orderbook/'+code);
const ask=(d.ask||[]).slice().reverse(); // 매도 위에서부터 높은 가격
const bid=d.bid||[];
const askMax=Math.max(...ask.map(x=>x.qty),1);
const bidMax=Math.max(...bid.map(x=>x.qty),1);
const max=Math.max(askMax,bidMax);
let h=`<div style="font-size:10px;color:#78909C;margin-bottom:6px;text-align:center">호가 (10단계)</div>`;
ask.forEach(a=>{
const w=(a.qty/max*100).toFixed(0);
h+=`<div style="display:flex;justify-content:space-between;padding:3px 6px;background:linear-gradient(to left,rgba(255,82,82,0.15) ${w}%,transparent ${w}%);border-radius:3px;margin-bottom:1px">
<span style="color:#FF8A80">${fmt(a.price)}</span>
<span style="color:#90A4AE">${fmt(a.qty)}</span></div>`;
});
h+=`<div style="height:1px;background:rgba(255,255,255,0.1);margin:4px 0"></div>`;
bid.forEach(b=>{
const w=(b.qty/max*100).toFixed(0);
h+=`<div style="display:flex;justify-content:space-between;padding:3px 6px;background:linear-gradient(to right,rgba(0,230,118,0.15) ${w}%,transparent ${w}%);border-radius:3px;margin-bottom:1px">
<span style="color:#69F0AE">${fmt(b.price)}</span>
<span style="color:#90A4AE">${fmt(b.qty)}</span></div>`;
});
h+=`<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.06);font-size:10px;color:#78909C">
매도잔량 <span style="color:#FF8A80">${fmt(d.ask_total)}</span><br>
매수잔량 <span style="color:#69F0AE">${fmt(d.bid_total)}</span>
</div>`;
el.innerHTML=h;
}catch(e){
el.innerHTML='<div style="text-align:center;color:#546E7A;padding:20px;font-size:11px">호가 데이터 없음</div>';
}
}
async function openStockChart(code, name){
if(!code) return;
_scState = {code, name: name||'', timeframe:'day'};
// 시간프레임 버튼 초기화
document.querySelectorAll('.tf-btn').forEach(b=>{
const active = b.dataset.tf==='day';
b.style.background = active?'rgba(0,230,118,0.15)':'rgba(255,255,255,0.04)';
b.style.borderColor = active?'rgba(0,230,118,0.3)':'rgba(255,255,255,0.08)';
b.style.color = active?'#00E676':'#90A4AE';
b.classList.toggle('active', active);
});
const modal=document.getElementById('stock-chart-modal');
modal.style.display='flex';
document.getElementById('sc-title').textContent=name||code;
document.getElementById('sc-subtitle').textContent=code;
document.getElementById('sc-info').innerHTML='<div style="grid-column:1/-1;text-align:center;color:#546E7A;padding:30px">데이터 불러오는 중...</div>';
document.getElementById('sc-fundamentals').innerHTML='';
loadOrderbook(code); // 호가 비동기로
const [stock, ohlcvResp, fundi, nameResp] = await Promise.all([
api('/stock/'+code).catch(()=>null),
api('/ohlcv/'+code+'?days=90').catch(()=>null),
api('/fundamentals/'+code).catch(()=>null),
name?null:api('/name/'+code).catch(()=>null),
]);
// 종목명 보정
if(nameResp?.name) document.getElementById('sc-title').textContent=nameResp.name;
// 정보 카드 (stock 응답: {price, technical, scores[]})
const price = stock?.price || {};
const ta = stock?.technical || {};
const sc0 = (stock?.scores && stock.scores[0]) || {};
const ch = parseFloat(price.change_pct||0);
const chC = ch>=0?'#00E676':'#FF5252';
document.getElementById('sc-info').innerHTML=`
<div class="price-box"><div class="label">현재가</div><div class="val">${fmt(price.price)}</div></div>
<div class="price-box"><div class="label">등락</div><div class="val" style="color:${chC}">${ch>=0?'+':''}${ch.toFixed(2)}%</div></div>
<div class="price-box"><div class="label">시가총액</div><div class="val">${fmt(price.market_cap)}</div></div>
<div class="price-box"><div class="label">PER</div><div class="val">${parseFloat(price.per||0).toFixed(2)}</div></div>
<div class="price-box"><div class="label">PBR</div><div class="val">${parseFloat(price.pbr||0).toFixed(2)}</div></div>
<div class="price-box"><div class="label">기술점수</div><div class="val" style="color:${(ta.tech_score||0)>=30?'#69F0AE':(ta.tech_score||0)<=-30?'#FF8A80':'#E0E0E0'}">${(ta.tech_score||0).toFixed(0)}</div></div>
<div class="price-box"><div class="label">AI점수</div><div class="val">${sc0.total_score!=null?Number(sc0.total_score).toFixed(1):'-'}</div></div>
<div class="price-box"><div class="label">추천</div><div class="val" style="font-size:12px;color:${RCOL[sc0.recommendation]||'#90A4AE'}">${sc0.recommendation||ta.signal||'-'}</div></div>`;
const ohlcv = ohlcvResp;
// 캔들/거래량/외국인 차트
if(!ohlcv?.data?.length){
document.getElementById('sc-fundamentals').innerHTML='<div style="text-align:center;color:#FF8A80;padding:20px">OHLCV 데이터 없음</div>';
return;
}
await drawStockChart(ohlcv);
// 펀더멘털 요약
if(fundi?.length){
const f=fundi[0];
document.getElementById('sc-fundamentals').innerHTML=`
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:10px;padding:14px">
<div style="font-size:12px;font-weight:700;color:#FFD740;margin-bottom:8px">📊 재무 (${f.bsns_year}${f.reprt_name||''})</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;font-size:11px">
<div>매출 <b style="color:#E0E0E0">${fmt(f.revenue)}억</b></div>
<div>영업이익 <b style="color:#E0E0E0">${fmt(f.operating_profit)}억</b></div>
<div>순이익 <b style="color:#E0E0E0">${fmt(f.net_income)}억</b></div>
<div>ROE <b style="color:${(f.roe||0)>=10?'#69F0AE':'#FF8A80'}">${(f.roe||0).toFixed(1)}%</b></div>
<div>영업이익률 <b style="color:#E0E0E0">${(f.operating_margin||0).toFixed(1)}%</b></div>
<div>부채비율 <b style="color:${(f.debt_ratio||0)<=200?'#69F0AE':'#FF8A80'}">${(f.debt_ratio||0).toFixed(1)}%</b></div>
<div>매출성장 <b style="color:#E0E0E0">${(f.revenue_growth||0).toFixed(1)}%</b></div>
<div>FCF비율 <b style="color:#E0E0E0">${(f.fcf_ratio||0).toFixed(1)}%</b></div>
</div>
</div>`;
}
}
// ── 포트폴리오 (서버 연동) ─────────────────────────────────
let _portfolio = []; // 서버 정규화: {id, code, name, buy_price, qty, memo, pnl, pnl_pct}
async function loadPortfolio(){
if(!isLoggedIn()){_portfolio=[];return _portfolio;}
try{
const list=await authedJson('/portfolio');
let priceMap={};
const codes=list.map(p=>p.stock_code).filter(Boolean).join(',');
if(codes){
const prices=await api('/portfolio/prices?codes='+codes);
if(Array.isArray(prices)) priceMap=Object.fromEntries(prices.map(p=>[p.code,p]));
}
_portfolio=list.map(p=>{
const cur=(priceMap[p.stock_code]||{}).price||0;
const pnl=cur?(cur-p.buy_price)*p.qty:0;
const pnl_pct=cur?(cur-p.buy_price)/p.buy_price*100:0;
return {id:p.id,code:p.stock_code,name:p.stock_name,buy_price:p.buy_price,qty:p.qty,memo:p.memo||'',pnl,pnl_pct};
});
}catch(e){_portfolio=[];}
return _portfolio;
}
function getPortfolio(){return _portfolio}
async function maybeOfferImport(){
const legacy=JSON.parse(localStorage.getItem('portfolio')||'[]');
if(!legacy.length||!isLoggedIn()) return;
if(!confirm(`이전에 브라우저에 저장된 포지션 ${legacy.length}건이 있습니다. 서버 계정으로 가져올까요?`)) return;
const items=legacy.filter(p=>p.code&&p.buy_price&&p.qty).map(p=>({
stock_code:String(p.code),stock_name:p.name||'',buy_price:Number(p.buy_price),qty:Number(p.qty),memo:''
}));
if(!items.length){alert('가져올 유효한 항목이 없습니다');return;}
try{
const r=await authedJson('/portfolio/import',{method:'POST',body:JSON.stringify(items)});
alert(`가져오기 완료 — 추가 ${r.inserted}건 / 중복 스킵 ${r.skipped}`);
localStorage.removeItem('portfolio');
await loadPortfolio();
renderPortfolioList();
}catch(e){alert('가져오기 실패: '+e.message);}
}
function renderMyStock(){
const el=document.getElementById('mystock');
if(!isLoggedIn()){
el.innerHTML=`<div style="max-width:540px;margin:60px auto;text-align:center;padding:40px 24px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px">
<div style="font-size:32px;margin-bottom:12px">🔒</div>
<h3 style="font-size:16px;color:#E0E0E0;margin-bottom:8px">로그인이 필요합니다</h3>
<div style="font-size:13px;color:#90A4AE;line-height:1.6;margin-bottom:20px">내 종목 분석 데이터는<br>로그인 계정에 안전하게 저장됩니다</div>
<button onclick="showLoginModal()" style="background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);border-radius:10px;padding:10px 28px;color:#00E676;font-size:14px;font-weight:700;cursor:pointer">로그인 / 회원가입</button>
</div>`;
return;
}
el.innerHTML=`
<div style="max-width:760px;margin:0 auto">
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px;padding:24px;margin-bottom:20px">
<h3 style="font-size:14px;color:#90A4AE;margin-bottom:16px">📂 보유 종목 입력</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">종목코드 (6자리)</div>
<input id="ms-code" type="text" maxlength="6" placeholder="예: 329180"
style="${INP_STYLE};font-family:'JetBrains Mono',monospace"
oninput="onCodeInput(this.value)">
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">종목명</div>
<input id="ms-name" type="text" placeholder="코드 입력 시 자동 조회"
style="${INP_STYLE};color:#69F0AE" readonly>
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">매입단가 (원)</div>
<input id="ms-price" type="number" placeholder="예: 698000"
style="${INP_STYLE};font-family:'JetBrains Mono',monospace">
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">보유수량 (주)</div>
<input id="ms-qty" type="number" placeholder="예: 17"
style="${INP_STYLE};font-family:'JetBrains Mono',monospace">
</div>
</div>
<button onclick="analyzeMyStock()"
style="width:100%;background:rgba(0,230,118,0.12);border:1px solid rgba(0,230,118,0.25);
border-radius:10px;padding:12px;color:#00E676;font-size:14px;font-weight:700;cursor:pointer">
🔍 AI 포지션 분석 + 저장
</button>
</div>
<div id="ms-portfolio" style="margin-bottom:20px"></div>
<div id="ms-result"></div>
</div>`;
renderPortfolioList();
}
async function onCodeInput(v){
if(v.length===6&&/^\d{6}$/.test(v)){
const nameEl=document.getElementById('ms-name');
nameEl.value='조회 중...';
const d=await api('/name/'+v);
nameEl.value=d&&d.name?d.name:'알 수 없음';
}
}
function renderPortfolioList(){
const list=getPortfolio();
const el=document.getElementById('ms-portfolio');
if(!el)return;
if(!list.length){el.innerHTML='';return;}
let h=`<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:14px;color:#90A4AE">💼 저장된 포지션 (${list.length}개)</h3>
<button onclick="clearPortfolio()" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);border-radius:6px;padding:4px 12px;color:#FF8A80;font-size:11px;cursor:pointer">전체 삭제</button>
</div>`;
list.forEach((p,i)=>{
const pnlC=p.pnl>=0?'#00E676':'#FF5252';
h+=`<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<div style="flex:1">
<div style="font-size:14px;font-weight:700;color:#E0E0E0">${esc(p.name)} <span style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${p.code}</span></div>
<div style="font-size:11px;color:#78909C;margin-top:2px">매입 ${fmt(p.buy_price)}× ${p.qty}주 | 평가손익 <span style="color:${pnlC}">${p.pnl>=0?'+':''}${fmt(p.pnl)}원 (${p.pnl>=0?'+':''}${(p.pnl_pct||0).toFixed(2)}%)</span></div>
</div>
<button onclick="reanalyze(${i})" style="background:rgba(0,230,118,0.08);border:1px solid rgba(0,230,118,0.2);border-radius:6px;padding:4px 10px;color:#00E676;font-size:11px;cursor:pointer;white-space:nowrap">재분석</button>
<button onclick="deletePortfolio(${p.id})" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);border-radius:6px;padding:4px 10px;color:#FF8A80;font-size:11px;cursor:pointer">삭제</button>
</div>`;
});
h+=`</div>`;
el.innerHTML=h;
}
async function deletePortfolio(id){
if(!id||!confirm('이 포지션을 삭제할까요?')) return;
try{
await authedJson('/portfolio/'+id,{method:'DELETE'});
await loadPortfolio();
renderPortfolioList();
}catch(e){alert('삭제 실패: '+e.message);}
}
async function clearPortfolio(){
if(!confirm('저장된 포지션을 모두 삭제할까요?')) return;
const list=getPortfolio();
try{
await Promise.all(list.map(p=>authedJson('/portfolio/'+p.id,{method:'DELETE'}).catch(()=>{})));
await loadPortfolio();
renderPortfolioList();
}catch(e){alert('삭제 실패: '+e.message);}
}
function reanalyze(i){
const p=getPortfolio()[i];
if(!p) return;
document.getElementById('ms-code').value=p.code;
document.getElementById('ms-name').value=p.name;
document.getElementById('ms-price').value=p.buy_price;
document.getElementById('ms-qty').value=p.qty;
analyzeMyStock(false);
}
async function analyzeMyStock(saveResult=true){
const code=document.getElementById('ms-code').value.trim();
const name=document.getElementById('ms-name').value.trim();
const buyPrice=parseInt(document.getElementById('ms-price').value);
const qty=parseInt(document.getElementById('ms-qty').value);
if(!code||!buyPrice||!qty){alert('종목코드, 매입단가, 수량을 모두 입력하세요');return;}
document.getElementById('ms-result').scrollIntoView({behavior:'smooth'});
const el=document.getElementById('ms-result');
el.innerHTML=`<div style="text-align:center;padding:40px;color:#546E7A">분석 중... (10~30초 소요)</div>`;
try{
const r=await fetch(API+'/position?ai=true',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({code,name,buy_price:buyPrice,qty})
});
const d=await r.json();
if(d.error){el.innerHTML=`<div class="empty">${esc(d.error)}</div>`;return;}
// 포트폴리오 저장 (서버)
if(saveResult && isLoggedIn()){
try{
const list=getPortfolio();
const existing=list.find(p=>p.code===code);
const body={stock_code:code,stock_name:d.name||name||code,buy_price:buyPrice,qty,memo:''};
if(existing){
await authedJson('/portfolio/'+existing.id,{method:'PUT',body:JSON.stringify(body)});
}else{
await authedJson('/portfolio',{method:'POST',body:JSON.stringify(body)});
}
await loadPortfolio();
renderPortfolioList();
}catch(e){console.error('save failed',e);}
}
const pos=d.position||{};
const ind=d.indicators||{};
const tg=d.targets||{};
const sig=d.signal||'관망';
const sc=sig==='매수'?'#00E676':sig==='매도'?'#FF5252':'#78909C';
const pnlC=pos.pnl>=0?'#00E676':'#FF5252';
el.innerHTML=`
<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:20px;margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div>
<div style="font-size:18px;font-weight:700;color:#E0E0E0">${esc(d.name||code)}</div>
<div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${code}</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:${sc};font-weight:700">${sig} (${(d.tech_score||0).toFixed(0)}점)</div>
</div>
</div>
<div class="price-row" style="margin-bottom:16px">
<div class="price-box"><div class="label">매입단가</div><div class="val" style="color:#78909C">${fmt(buyPrice)}</div></div>
<div class="price-box"><div class="label">현재가</div><div class="val" style="color:#E0E0E0">${fmt(d.current_price)}</div></div>
<div class="price-box"><div class="label">평가손익</div><div class="val" style="color:${pnlC}">${pos.pnl>=0?'+':''}${fmt(pos.pnl)}</div></div>
<div class="price-box"><div class="label">수익률</div><div class="val" style="color:${pnlC}">${pos.pnl_pct>=0?'+':''}${(pos.pnl_pct||0).toFixed(2)}%</div></div>
</div>
<div class="price-row" style="margin-bottom:12px">
<div class="price-box" style="border:1px solid #FF8A8030"><div class="label">손절가</div><div class="val" style="color:#FF8A80">${fmt(pos.stop_loss)}</div><div style="font-size:9px;color:#607D8B">손실 ${fmt(pos.stop_pnl)}</div></div>
<div class="price-box" style="border:1px solid #69F0AE30"><div class="label">1차 목표</div><div class="val" style="color:#69F0AE">${fmt(pos.t1)}</div><div style="font-size:9px;color:#607D8B">수익 +${fmt(pos.t1_pnl)}</div></div>
<div class="price-box" style="border:1px solid #00E67630"><div class="label">2차 목표</div><div class="val" style="color:#00E676">${fmt(pos.t2)}</div><div style="font-size:9px;color:#607D8B">수익 +${fmt(pos.t2_pnl)}</div></div>
<div class="price-box" style="border:1px solid #00BFA530"><div class="label">3차 목표</div><div class="val" style="color:#00BFA5">${fmt(pos.t3)}</div><div style="font-size:9px;color:#607D8B">수익 +${fmt(pos.t3_pnl)}</div></div>
</div>
<div style="background:rgba(255,255,255,0.03);border-radius:10px;padding:12px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#90A4AE;margin-bottom:8px">기술적 지표</div>
<div style="display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#78909C">
<span>MA5 <b style="color:${ind.price>ind.ma5?'#69F0AE':'#FF8A80'}">${fmt(ind.ma5)}</b></span>
<span>MA20 <b style="color:${ind.price>ind.ma20?'#69F0AE':'#FF8A80'}">${fmt(ind.ma20)}</b></span>
<span>MA60 <b style="color:${ind.price>ind.ma60?'#69F0AE':'#FF8A80'}">${fmt(ind.ma60)}</b></span>
<span>RSI <b style="color:${(ind.rsi||50)<40?'#69F0AE':(ind.rsi||50)>70?'#FF8A80':'#E0E0E0'}">${(ind.rsi||0).toFixed(1)}</b></span>
<span>MACD <b style="color:${(ind.macd_hist||0)>0?'#69F0AE':'#FF8A80'}">${(ind.macd_hist||0)>0?'골든':'데드'}</b></span>
<span>볼밴%B <b style="color:#FFD740">${((ind.pct_b||0.5)*100).toFixed(0)}%</b></span>
<span>거래량비 <b style="color:#40C4FF">${(ind.vol_ratio||1).toFixed(1)}x</b></span>
</div>
<div class="ta-row" style="margin-top:8px">${(d.signals||[]).map(s=>`<span class="ta-badge">${esc(s)}</span>`).join('')}</div>
</div>
${(pos.avg_down||[]).length?`
<div style="background:rgba(255,255,255,0.03);border-radius:10px;padding:12px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#90A4AE;margin-bottom:8px">분할매수 시나리오</div>
${pos.avg_down.map(ad=>`
<div style="display:flex;justify-content:space-between;font-size:11px;color:#78909C;padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<span>${fmt(ad.price)}원에 ${ad.add_qty}주 추가 (${fmt(ad.add_cost)}원)</span>
<span>→ 평균단가 <b style="color:#FFD740">${fmt(ad.new_avg)}원</b></span>
</div>`).join('')}
</div>`:''}
<div style="background:rgba(255,165,0,0.05);border:1px solid rgba(255,165,0,0.15);border-radius:10px;padding:14px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#FFD740;margin-bottom:6px">🤖 AI 판단</div>
<div style="font-size:12px;color:#CFD8DC;line-height:1.7">${esc(d.ai_opinion||'vLLM 서버 연결 후 활성화됩니다.')}</div>
</div>
<div style="text-align:center;padding:10px;background:rgba(${pos.action==='손절 고려'?'255,82,82':'0,230,118'},0.08);border-radius:8px">
<span style="font-size:14px;font-weight:700;color:${pos.action==='손절 고려'?'#FF5252':'#00E676'}">
권장 액션: ${pos.action||'홀드'}
</span>
</div>
</div>`;
}catch(e){el.innerHTML=`<div class="empty">분석 오류: ${esc(e.message)}</div>`;}
}
async function renderPerformance(){
const el=document.getElementById('performance');
el.innerHTML='<div class="empty">성과 데이터 불러오는 중...</div>';
const [perf,sector]=await Promise.all([api('/performance'),api('/sector-ranking')]);
if(!perf){el.innerHTML='<div class="empty">데이터 없음</div>';return;}
const s=perf.summary||{};
const winRate7=s.measured_7d>0?Math.round(s.wins_7d/s.measured_7d*100):null;
const winRate30=s.measured_30d>0?Math.round(s.wins_30d/s.measured_30d*100):null;
const ret7C=(s.avg_return_7d||0)>=0?'#00E676':'#FF5252';
const ret30C=(s.avg_return_30d||0)>=0?'#00E676':'#FF5252';
let h=`<div style="background:rgba(0,230,118,0.05);border:1px solid rgba(0,230,118,0.15);border-radius:12px;padding:14px 18px;margin-bottom:18px">
<div style="font-size:13px;font-weight:700;color:#00E676;margin-bottom:6px">🎯 추천 성과 추적</div>
<div style="font-size:12px;color:#90A4AE;line-height:1.6">
과거 AI가 추천한 종목들이 <b style="color:#E0E0E0">7일/30일 후 실제로 어떤 수익률</b>을 냈는지 추적합니다.<br>
평균 수익률·승률을 통해 AI 추천의 신뢰도를 검증하는 지표입니다. 종목명을 클릭하면 차트 모달이 열립니다.
</div>
</div>
<div class="cards">
<div class="card"><div class="label">📊 7일 평균수익률</div>
<div class="val" style="font-size:22px;color:${ret7C}">${s.avg_return_7d!=null?(s.avg_return_7d>=0?'+':'')+s.avg_return_7d+'%':'데이터없음'}</div>
<div class="sub">${winRate7!=null?'승률 '+winRate7+'% ('+s.measured_7d+'건)':'측정 전'}</div></div>
<div class="card"><div class="label">📈 30일 평균수익률</div>
<div class="val" style="font-size:22px;color:${ret30C}">${s.avg_return_30d!=null?(s.avg_return_30d>=0?'+':'')+s.avg_return_30d+'%':'데이터없음'}</div>
<div class="sub">${winRate30!=null?'승률 '+winRate30+'% ('+s.measured_30d+'건)':'측정 전'}</div></div>
<div class="card"><div class="label">🔥 강력매수 7일</div>
<div class="val" style="font-size:22px;color:${(s.strong_buy_avg_7d||0)>=0?'#00E676':'#FF5252'}">${s.strong_buy_avg_7d!=null?(s.strong_buy_avg_7d>=0?'+':'')+s.strong_buy_avg_7d+'%':'데이터없음'}</div>
<div class="sub">강력매수 단독 성과</div></div>
<div class="card"><div class="label">📋 총 추천 건수</div>
<div class="val">${s.total||0}</div><div class="sub">최근 90일</div></div>
</div>`;
// 섹터별 상대 강도
if(sector&&sector.length){
h+=`<div class="panel" style="max-height:none;margin-bottom:16px">
<h3 style="margin-bottom:16px">🏭 섹터별 AI 점수 순위</h3>
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
<th style="text-align:left;padding:8px 6px">섹터</th>
<th style="text-align:right;padding:8px 6px">종목수</th>
<th style="text-align:right;padding:8px 6px">평균점수</th>
<th style="text-align:right;padding:8px 6px">뉴스</th>
<th style="text-align:right;padding:8px 6px">기술</th>
<th style="text-align:right;padding:8px 6px">매수종목</th>
<th style="text-align:right;padding:8px 6px">매도종목</th>
</tr></thead><tbody>`;
sector.forEach((sec,i)=>{
const sc=sec.avg_score>=30?'#00E676':sec.avg_score<=-30?'#FF5252':'#78909C';
h+=`<tr style="border-bottom:1px solid rgba(255,255,255,0.04);${i%2?'background:rgba(255,255,255,0.01)':''}">
<td style="padding:9px 6px;font-weight:600;color:#CFD8DC">${esc(sec.sector)}</td>
<td style="text-align:right;padding:9px 6px;color:#78909C">${sec.stock_count}</td>
<td style="text-align:right;padding:9px 6px;color:${sc};font-weight:700">${sec.avg_score}</td>
<td style="text-align:right;padding:9px 6px;color:#69F0AE">${sec.avg_news}</td>
<td style="text-align:right;padding:9px 6px;color:#CE93D8">${sec.avg_tech}</td>
<td style="text-align:right;padding:9px 6px;color:#00E676">${sec.buy_count}</td>
<td style="text-align:right;padding:9px 6px;color:#FF8A80">${sec.sell_count}</td>
</tr>`;
});
h+=`</tbody></table></div></div>`;
}
// 최근 성과 이력
const recent=perf.recent||[];
if(recent.length){
h+=`<div class="panel" style="max-height:none">
<h3 style="margin-bottom:16px">📜 추천 성과 이력</h3>
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
<th style="text-align:left;padding:8px 6px">종목</th>
<th style="text-align:center;padding:8px 6px">추천등급</th>
<th style="text-align:right;padding:8px 6px">진입가</th>
<th style="text-align:right;padding:8px 6px">7일수익률</th>
<th style="text-align:right;padding:8px 6px">30일수익률</th>
<th style="text-align:right;padding:8px 6px">추천일</th>
</tr></thead><tbody>`;
recent.forEach((r,i)=>{
const rc=r.recommendation==='강력매수'?'#00E676':r.recommendation==='매수관심'?'#69F0AE':r.recommendation==='매도관심'?'#FF8A80':'#FF5252';
const r7c=(r.return_7d||0)>=0?'#00E676':'#FF5252';
const r30c=(r.return_30d||0)>=0?'#00E676':'#FF5252';
h+=`<tr data-stock="${r.stock_code}" data-stock-name="${esc(r.stock_name||'')}" style="border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer;${i%2?'background:rgba(255,255,255,0.01)':''}">
<td style="padding:9px 6px"><div style="font-weight:600;color:#CFD8DC">${esc(r.stock_name)}</div>
<div style="font-size:10px;color:#546E7A">${r.stock_code}</div></td>
<td style="text-align:center;padding:9px 6px"><span style="color:${rc};font-weight:700;font-size:11px">${esc(r.recommendation)}</span></td>
<td style="text-align:right;padding:9px 6px;color:#78909C;font-family:'JetBrains Mono',monospace">${fmt(r.entry_price)}</td>
<td style="text-align:right;padding:9px 6px;color:${r7c};font-weight:700">
${r.return_7d!=null?(r.return_7d>=0?'+':'')+r.return_7d.toFixed(2)+'%':'-'}</td>
<td style="text-align:right;padding:9px 6px;color:${r30c};font-weight:700">
${r.return_30d!=null?(r.return_30d>=0?'+':'')+r.return_30d.toFixed(2)+'%':'-'}</td>
<td style="text-align:right;padding:9px 6px;color:#546E7A;font-size:11px">${r.rec_date}</td>
</tr>`;
});
h+=`</tbody></table></div></div>`;
} else {
h+=`<div class="panel"><div class="empty">오늘부터 추천 성과가 기록됩니다. 7일 후 수익률이 표시됩니다.</div></div>`;
}
el.innerHTML=h;
}
async function renderPortfolio(){
const el=document.getElementById('portfolio');
const list=getPortfolio();
if(!list.length){
el.innerHTML='<div class="empty">AI 추천 포트폴리오 불러오는 중...</div>';
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
el.innerHTML=`<div style="background:rgba(64,196,255,0.06);border:1px solid rgba(64,196,255,0.2);border-radius:12px;padding:16px 18px;margin-bottom:16px;color:#CFD8DC;font-size:13px;line-height:1.6">💼 아직 등록된 보유 종목이 없습니다. 아래 <b style="color:#69F0AE">AI 추천 포트폴리오</b>를 참고하거나, <b>📂 내 종목 분석</b> 탭에서 종목을 추가하세요.</div>`+recoPortfolioHTML(reco);
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,sector:pd.sector||'기타'};
});
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>`;
h+=portfolioSectorHTML(holdings);
h+=portfolioRebalanceHTML(holdings);
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
h+=recoPortfolioHTML(reco);
el.innerHTML=h;
}
let recoAmount=0;
async function applyRecoAmount(){
const inp=document.getElementById('reco-amt');
const v=parseInt((inp&&inp.value)||'0',10);
recoAmount=isNaN(v)?0:v;
renderPortfolio();
}
function portfolioSectorHTML(holdings){
const sec={};
holdings.forEach(s=>{ if(s.weight>0) sec[s.sector]=(sec[s.sector]||0)+s.weight; });
const arr=Object.entries(sec).sort((a,b)=>b[1]-a[1]);
if(!arr.length) return '';
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>📊 섹터(업종) 비중</h3>`;
arr.forEach(([name,w])=>{
const over=w>30, c=over?'#FF8A80':'#40C4FF';
h+=`<div style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">
<span style="color:#CFD8DC">${esc(name)}${over?' ⚠️':''}</span>
<span style="color:${c};font-weight:700">${w.toFixed(1)}%</span></div>
<div style="height:8px;background:rgba(255,255,255,0.06);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${Math.min(100,w).toFixed(0)}%;background:${c};border-radius:4px"></div></div></div>`;
});
if(arr[0][1]>30) h+=`<div style="font-size:11.5px;color:#FF8A80;margin-top:4px">⚠️ <b>${esc(arr[0][0])}</b> 업종이 ${arr[0][1].toFixed(0)}%로 한쪽에 쏠렸습니다. 한 업종 30% 이내로 분산하면 위험이 줄어듭니다.</div>`;
return h+`</div>`;
}
function portfolioRebalanceHTML(holdings){
const acts=[];
holdings.forEach(s=>{
if(s.recommendation==='강력매도'||s.recommendation==='매도관심')
acts.push([s,'sell',`AI가 <b>${esc(s.recommendation)}</b> 신호를 냈습니다 — 매도를 검토하세요`]);
else if(s.cur>0&&s.pnlPct<=-10)
acts.push([s,'cut',`손실 ${s.pnlPct.toFixed(1)}% — 손절 기준을 점검하세요`]);
else if(s.aiScore!=null&&s.aiScore<=-30)
acts.push([s,'sell',`AI점수 ${s.aiScore.toFixed(0)}점으로 부진 — 매도를 검토하세요`]);
else if(s.aiScore!=null&&s.aiScore>=70&&s.weight<7)
acts.push([s,'add',`AI점수 ${s.aiScore.toFixed(0)}점 우량인데 비중이 작습니다 — 추가 매수 고려`]);
});
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>🔧 리밸런싱 진단</h3>`;
if(!acts.length){
h+=`<div style="color:#69F0AE;font-size:12.5px">✅ 보유 종목에 특별한 조치 신호가 없습니다. 그대로 유지해도 무난합니다.</div>`;
}else{
acts.forEach(([s,kind,msg])=>{
const c=kind==='add'?'#69F0AE':kind==='sell'?'#FF8A80':'#FFD740';
const ic=kind==='add'?'':kind==='sell'?'⚠️':'🔻';
h+=`<div data-stock="${s.code}" data-stock-name="${esc(s.name||'')}" style="display:flex;gap:10px;align-items:center;padding:9px 0;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer">
<span style="font-size:15px">${ic}</span>
<div style="flex:1;min-width:0"><div style="color:#CFD8DC;font-size:12.5px;font-weight:600">${esc(s.name||s.code)}</div>
<div style="color:${c};font-size:11px;line-height:1.5">${msg}</div></div>
<span style="color:${s.pnlPct>=0?'#69F0AE':'#FF8A80'};font-size:11px;font-family:'JetBrains Mono',monospace">${s.cur>0?(s.pnlPct>=0?'+':'')+s.pnlPct.toFixed(1)+'%':'-'}</span></div>`;
});
}
return h+`</div>`;
}
function recoPortfolioHTML(reco){
if(!reco||!reco.holdings||!reco.holdings.length)
return `<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오</h3><div style="color:#546E7A;font-size:12px">${esc((reco&&reco.msg)||'추천 데이터를 불러오지 못했습니다.')}</div></div>`;
let h=`<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오 (${esc(reco.as_of||'')})</h3>
<div style="font-size:11.5px;color:#90A4AE;margin-bottom:12px;line-height:1.6">오늘 <b style="color:#69F0AE">강력매수·매수관심</b> 종목을 변동성·점수로 비중을 매기고, 한 업종 30% 이내로 분산한 구성입니다. 투자금액을 넣으면 종목별 매수 주수를 계산합니다.</div>
<div style="display:flex;gap:8px;margin-bottom:14px;align-items:center;flex-wrap:wrap">
<input id="reco-amt" type="number" placeholder="투자금액 입력 (원)" value="${recoAmount||''}"
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:8px 12px;color:#E0E0E0;font-size:13px;width:180px;outline:none"
onkeypress="if(event.key==='Enter')applyRecoAmount()">
<button onclick="applyRecoAmount()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.25);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;font-weight:600;cursor:pointer">계산</button>
</div>`;
const sbArr=Object.entries(reco.sector_breakdown||{});
if(sbArr.length){
h+=`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px">`;
sbArr.forEach(([n,w])=>{ h+=`<span style="background:rgba(64,196,255,0.1);border:1px solid rgba(64,196,255,0.25);border-radius:6px;padding:3px 9px;font-size:10.5px;color:#CFD8DC">${esc(n)} ${w.toFixed(0)}%</span>`; });
h+=`</div>`;
}
const hasAmt=reco.input_amount>0;
h+=`<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:center;padding:8px 6px;font-weight:600">AI점수</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">비중</th>
${hasAmt?'<th style="text-align:right;padding:8px 6px;font-weight:600">매수금액</th><th style="text-align:right;padding:8px 6px;font-weight:600">주수</th>':''}
</tr></thead><tbody>`;
reco.holdings.forEach((s,i)=>{
const aic=s.total_score>=70?'#00E676':s.total_score>=40?'#69F0AE':'#90A4AE';
h+=`<tr data-stock="${s.stock_code}" data-stock-name="${esc(s.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="color:#CFD8DC;font-weight:600">${esc(s.stock_name)}</div>
<div style="font-size:10px;color:#546E7A">${esc(s.sector)} · ${esc(s.recommendation)}</div></td>
<td style="text-align:center;padding:9px 6px;color:${aic};font-weight:700;font-family:'JetBrains Mono',monospace">${s.total_score.toFixed(0)}</td>
<td style="text-align:right;padding:9px 6px"><span style="color:#40C4FF;font-weight:700">${s.weight_pct.toFixed(1)}%</span></td>
${hasAmt?`<td style="text-align:right;padding:9px 6px;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${fmt(s.invest_amount)}</td><td style="text-align:right;padding:9px 6px;color:#E0E0E0;font-weight:600">${s.shares.toLocaleString()}</td>`:''}
</tr>`;
});
h+=`</tbody></table></div>`;
if(hasAmt)
h+=`<div style="margin-top:10px;font-size:11.5px;color:#90A4AE">투자금 ${fmt(reco.input_amount)}원 중 <b style="color:#69F0AE">${fmt(reco.invested_amount)}원</b> 매수 · 현금 ${fmt(reco.cash_remaining)}원 남음</div>`;
return h+`</div>`;
}
function equityCurveSVG(curve){
if(!curve||curve.length<2) return '';
const W=640,H=180,pad=30;
const allv=curve.flatMap(p=>[p.strategy_pct,p.kospi_pct]).concat([0]);
let mn=Math.min(...allv), mx=Math.max(...allv);
if(mx===mn)mx=mn+1;
const X=i=>pad+(W-2*pad)*i/(curve.length-1);
const Y=v=>H-pad-(H-2*pad)*(v-mn)/(mx-mn);
const path=k=>curve.map((p,i)=>`${i?'L':'M'}${X(i).toFixed(1)},${Y(p[k]).toFixed(1)}`).join('');
const last=curve[curve.length-1];
const sC=last.strategy_pct>=0?'#00E676':'#FF5252';
return `<div class="panel" style="margin-top:14px"><h3>📈 자산곡선 — 추천을 따랐을 때 누적 수익률</h3>
<div style="font-size:11px;color:#90A4AE;margin-bottom:8px">매일 추천 종목을 등비중으로 샀다고 가정한 누적 수익률(거래비용 차감). <b style="color:${sC}">초록=전략</b> · <b style="color:#FFB74D">주황=KOSPI</b></div>
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">
<line x1="${pad}" y1="${Y(0).toFixed(1)}" x2="${W-pad}" y2="${Y(0).toFixed(1)}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3"/>
<path d="${path('kospi_pct')}" fill="none" stroke="#FFB74D" stroke-width="1.6"/>
<path d="${path('strategy_pct')}" fill="none" stroke="${sC}" stroke-width="2.2"/>
</svg>
<div style="display:flex;justify-content:space-between;font-size:11px;color:#78909C;margin-top:4px">
<span>${esc(curve[0].date)}</span>
<span style="color:${sC};font-weight:700">전략 ${last.strategy_pct>=0?'+':''}${last.strategy_pct}% vs KOSPI ${last.kospi_pct>=0?'+':''}${last.kospi_pct}%</span>
<span>${esc(last.date)}</span></div></div>`;
}
function renderSignals(signals,cands){
let h=`<div style="margin-bottom:20px">
<div style="font-size:12px;color:#546E7A;margin-bottom:16px">
MA/RSI/MACD/볼린저밴드 기반 기술적 분석 + AI 뉴스 감성 종합 추천
</div>`;
// 매수 후보 (통합)
h+=`<h3 style="font-size:14px;font-weight:700;color:#69F0AE;margin-bottom:12px">🎯 통합 매수 후보 (기술적+펀더멘탈)</h3>`;
if(cands&&cands.length){
h+=`<div class="candidates-grid">`;
cands.forEach(c=>{
const tg=c.targets||{};
const sigs=(c.signals||[]).slice(0,4);
const ts=c.tech_score||0;
const fs=c.fundamental_score||0;
const combined=((ts+fs)/2).toFixed(1);
h+=`<div class="cand-card" data-stock="${c.stock_code}" data-stock-name="${esc(c.stock_name||c.stock_code)}" style="cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">
<div>
<div style="font-size:16px;font-weight:700;color:#E0E0E0">${esc(c.stock_name||c.stock_code)}</div>
<div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${c.stock_code} · 현재가 <b style="color:#E0E0E0">${fmt(c.price)}원</b></div>
</div>
<div style="text-align:right">
<div style="font-size:12px;font-weight:700;color:#00E676">종합 ${combined}</div>
<div style="font-size:10px;color:#69F0AE">기술${ts.toFixed(0)} / 펀더${fs.toFixed(0)}</div>
</div>
</div>
<div class="price-row">
<div class="price-box"><div class="label">진입 추천가</div><div class="val" style="color:#40C4FF">${fmt(tg.entry_price)}</div></div>
<div class="price-box"><div class="label">목표1 (${pct(tg.t1_pct)})</div><div class="val" style="color:#69F0AE">${fmt(tg.t1)}</div></div>
<div class="price-box"><div class="label">목표2 (${pct(tg.t2_pct)})</div><div class="val" style="color:#00E676">${fmt(tg.t2)}</div></div>
<div class="price-box"><div class="label">손절가 (${pct(tg.stop_pct)})</div><div class="val" style="color:#FF8A80">${fmt(tg.stop_loss)}</div></div>
</div>
<div style="display:flex;gap:12px;font-size:11px;color:#78909C;margin-top:10px;flex-wrap:wrap">
<span>RSI <b style="color:${(c.rsi||50)<40?'#69F0AE':(c.rsi||50)>70?'#FF8A80':'#E0E0E0'}">${(c.rsi||0).toFixed(0)}</b></span>
<span>MACD히스 <b style="color:${(c.macd_hist||0)>0?'#69F0AE':'#FF8A80'}">${(c.macd_hist||0).toFixed(3)}</b></span>
<span>볼밴%B <b style="color:#FFD740">${((c.pct_b||0.5)*100).toFixed(0)}%</b></span>
<span>거래량비 <b style="color:#40C4FF">${(c.vol_ratio||1).toFixed(1)}x</b></span>
<span>리스크비 <b style="color:#E0E0E0">${tg.risk_reward||0}:1</b></span>
</div>
<div class="ta-row">${sigs.map(s=>{
const bull=s.includes('상승')||s.includes('골든')||s.includes('과매도')||s.includes('바닥')||s.includes('정배열')||s.includes('위')||s.includes('반등')||s.includes('매수');
const bear=s.includes('하락')||s.includes('데드')||s.includes('과매수')||s.includes('역배열')||s.includes('아래')||s.includes('매도');
return `<span class="ta-badge ${bull?'bull':bear?'bear':''}">${esc(s)}</span>`;
}).join('')}</div>
</div>`;
});
h+=`</div>`;
} else {
h+=`<div class="empty">기술적 분석 데이터 수집 중... (첫 실행 후 30분 이내 생성됩니다)</div>`;
}
// 매매 시그널 (KIS 기반)
h+=`<h3 style="font-size:14px;font-weight:700;color:#90A4AE;margin:24px 0 12px">📈 최근 매매 시그널 (3일)</h3>`;
const buySigs=(signals||[]).filter(s=>s.signal_type==='매수').slice(0,10);
const sellSigs=(signals||[]).filter(s=>s.signal_type==='매도').slice(0,5);
if(buySigs.length||sellSigs.length){
h+=`<div class="signal-grid">`;
[...buySigs,...sellSigs].forEach(s=>{
const isBuy=s.signal_type==='매수';
const sc=isBuy?'#00E676':'#FF5252';
const tg=s.targets||{};
const taSigs=(s.ta_signals||[]).slice(0,3);
h+=`<div class="signal-card" data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name||'')}" style="border:1px solid ${sc}20;cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<div>
<span style="font-size:15px;font-weight:700;color:${sc}">${s.signal_type}</span>
<span style="font-size:14px;color:#E0E0E0;margin-left:8px">${esc(s.stock_name)}</span>
<span style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace;margin-left:6px">${s.stock_code}</span>
</div>
<span style="font-size:12px;color:#78909C">신뢰도 <b style="color:#FFD740">${(s.confidence||0).toFixed(1)}%</b></span>
</div>
<div class="price-row">
<div class="price-box"><div class="label">현재가</div><div class="val" style="color:#E0E0E0">${fmt(s.current_price)}</div></div>
<div class="price-box"><div class="label">${isBuy?'목표가':'목표가'}</div><div class="val" style="color:${sc}">${fmt(s.target_price)}</div></div>
<div class="price-box"><div class="label">손절가</div><div class="val" style="color:#FF8A80">${fmt(s.stop_loss)}</div></div>
<div class="price-box"><div class="label">예상수익</div><div class="val" style="color:#FFD740">${pct(s.expected_return_pct)}</div></div>
</div>
${tg.t1?`<div style="font-size:11px;color:#78909C;margin-top:8px">
T1 <b style="color:#69F0AE">${fmt(tg.t1)}</b>(${pct(tg.t1_pct)}) &nbsp;
T2 <b style="color:#00E676">${fmt(tg.t2)}</b>(${pct(tg.t2_pct)}) &nbsp;
T3 <b style="color:#00BFA5">${fmt(tg.t3)}</b>(${pct(tg.t3_pct)})
</div>`:''}
${taSigs.length?`<div class="ta-row">${taSigs.map(sg=>`<span class="ta-badge">${esc(sg)}</span>`).join('')}</div>`:''}
${s.reason?`<div style="font-size:11px;color:#607D8B;margin-top:8px">${esc(s.reason.substring(0,120))}</div>`:''}
</div>`;
});
h+=`</div>`;
} else {
h+=`<div class="empty">최근 3일간 시그널이 없습니다. (장 중 5분마다 생성됩니다)</div>`;
}
h+=`</div>`;
document.getElementById('signals').innerHTML=h;
}
function getMarketTip(close, changePct){
if(!close) return null;
if(changePct >= 1.5) return {icon:'🚀', label:'강세장', color:'#00E676', text:'코스피가 강하게 상승 중입니다. 추세 추종 전략이 유효한 구간이지만, 과매수 지표(RSI 70↑)가 나타나면 신규 매수보다 기존 보유 종목 관리에 집중하세요.'};
if(changePct >= 0.5) return {icon:'📈', label:'완만한 상승', color:'#69F0AE', text:'시장이 우호적입니다. 실적 개선 종목과 외국인 순매수 종목을 중심으로 관심 가져볼 만한 구간입니다.'};
if(changePct <= -1.5) return {icon:'⚠️', label:'급락 주의', color:'#FF5252', text:'코스피가 급락 중입니다. 신규 매수보다 손절 라인 점검이 우선입니다. 시장 전반이 흔들릴 때는 현금 비중을 높이고 낙폭 과대 우량주 저점 매수 기회를 기다리세요.'};
if(changePct <= -0.5) return {icon:'📉', label:'약세 흐름', color:'#FF7043', text:'시장이 약세입니다. 실적 대비 낙폭이 과도한 종목을 발굴할 기회이기도 합니다. PBR 1 미만, ROE 10% 이상 종목을 관심 목록에 추가해 보세요.'};
if(close >= 2700) return {icon:'💹', label:'박스권 상단', color:'#FFC107', text:'코스피 2700 이상은 역사적 고평가 구간 진입 신호일 수 있습니다. 신규 진입보다 보유 종목 비중 조절과 이익 실현을 고려하세요.'};
if(close <= 2400) return {icon:'💎', label:'저점 구간', color:'#40C4FF', text:'코스피 2400 이하는 역사적으로 장기 투자자에게 좋은 진입 구간이었습니다. 우량 가치주를 분할 매수하는 전략이 유효합니다.'};
return {icon:'⚖️', label:'중립 구간', color:'#90A4AE', text:'코스피가 횡보 중입니다. 시장 전체보다 개별 종목의 실적과 모멘텀에 집중하는 보텀업 전략이 효과적인 구간입니다.'};
}
function renderOverview(s,ranking,recent,kospi,tip){
if(!s)return;
let h='';
// KOSPI 차트 + 시장 팁
if(kospi && kospi.data && kospi.data.length){
const cur=kospi.current||{};
const chg=parseFloat(cur.change_pct||0);
const chgColor=chg>=0?'#00E676':'#FF5252';
const chgSign=chg>=0?'+':'';
const mTip=getMarketTip(cur.close, chg);
h+=`<div class="kospi-panel">
<div class="kospi-header">
<div>
<div style="font-size:12px;color:#546E7A;font-weight:600;letter-spacing:1px;margin-bottom:6px">📊 KOSPI</div>
<div class="kospi-price" style="color:${chgColor}">${cur.close?Number(cur.close).toLocaleString('ko',{minimumFractionDigits:2}):'--'}</div>
<div class="kospi-change" style="color:${chgColor}">${chgSign}${chg.toFixed(2)}% &nbsp;${chg>=0?'▲':'▼'} ${Math.abs(cur.change||0).toFixed(2)}</div>
</div>
<div style="text-align:right;font-size:11px;color:#546E7A">${cur.date||''} 기준</div>
</div>
<canvas id="kospiChart" height="80"></canvas>
${mTip?`<div class="market-tip-box" style="border-color:${mTip.color}33;background:${mTip.color}0D">
<div class="mtip-label" style="color:${mTip.color}">${mTip.icon} 시장 상황: ${mTip.label}</div>
<div class="mtip-body">${mTip.text}</div>
</div>`:''}
</div>`;
}
// 오늘의 투자 팁
if(tip && tip.title){
h+=`<div class="kospi-tip">
<div class="tip-tag">💡 오늘의 투자 지식 &nbsp;·&nbsp; ${tip.tag||''}</div>
<div class="tip-title">${esc(tip.title)}</div>
<div class="tip-body">${esc(tip.body)}</div>
</div>`;
}
// 요약 카드
h+=`<div class="cards">
<div class="card"><div class="label">📰 분석 뉴스</div><div class="val">${s.total||0}</div><div class="sub">최근 7일</div></div>
<div class="card"><div class="label">▲ 호재</div><div class="val" style="color:#00E676">${s.positive||0}</div><div class="sub">${s.sentiment_ratio||50}%</div></div>
<div class="card"><div class="label">▼ 악재</div><div class="val" style="color:#FF5252">${s.negative||0}</div></div>
<div class="card"><div class="label">📋 DART 공시</div><div class="val">${s.dart||0}</div></div>
<div class="card"><div class="label">🏢 분석 종목</div><div class="val">${s.stocks_analyzed||0}</div></div>
</div><div class="grid2">`;
// 랭킹
h+=`<div class="panel"><h3>🏆 종목 랭킹</h3>`;
if(ranking&&ranking.length){
ranking.slice(0,15).forEach((item,i)=>{
const rc=RCOL[item.recommendation]||'#78909C';
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer"><div class="rank-num ${i<3?'top':'normal'}">${i+1}</div>
<div style="flex:1"><div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name)}</div><div style="font-size:10px;color:#546E7A">${item.stock_code}</div></div>
<div style="text-align:right"><div style="font-size:14px;font-weight:700;color:${rc}">${(item.total_score||0).toFixed(1)}</div><div style="font-size:10px;color:${rc}">${item.recommendation||'관망'}</div></div></div>`;
});
} else h+=`<div class="empty">데이터 수집 중...</div>`;
h+=`</div>`;
// 최근 뉴스
h+=`<div class="panel"><h3>📰 최근 분석</h3>`;
if(recent&&recent.length){
recent.slice(0,15).forEach(item=>{h+=newsItemHTML(item)});
} else h+=`<div class="empty">데이터 수집 중...</div>`;
h+=`</div></div>`;
document.getElementById('overview').innerHTML=h;
// KOSPI 차트 그리기
if(kospi && kospi.data && kospi.data.length && document.getElementById('kospiChart')){
const labels=kospi.data.map(d=>d.date.slice(5));
const closes=kospi.data.map(d=>d.close);
const last=closes[closes.length-1]||0;
const borderColor=last>=closes[0]?'#00E676':'#FF5252';
new Chart(document.getElementById('kospiChart'),{
type:'line',
data:{
labels,
datasets:[{
data:closes,
borderColor,
backgroundColor:borderColor+'18',
borderWidth:2,
fill:true,
tension:0.3,
pointRadius:0,
pointHoverRadius:4,
}]
},
options:{
responsive:true,
plugins:{legend:{display:false},tooltip:{
mode:'index',intersect:false,
callbacks:{label:ctx=>`KOSPI ${Number(ctx.parsed.y).toLocaleString('ko',{minimumFractionDigits:2})}`}
}},
scales:{
x:{grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},maxTicksLimit:10}},
y:{grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},callback:v=>v.toLocaleString()},position:'right'}
}
}
});
}
}
function newsItemHTML(item){
const sc=SCOL[item.sentiment]||'#78909C', si=SICON[item.sentiment]||'●';
const titleHtml=item.url
?`<a href="${esc(item.url)}" target="_blank" rel="noopener" style="color:#CFD8DC;text-decoration:none" onmouseover="this.style.color='#00E676'" onmouseout="this.style.color='#CFD8DC'">${esc(item.title||'')}</a>`
:esc(item.title||'');
// 종목 코드가 있으면 클릭 가능한 칩으로
const stockChip = item.primary_stock
? `<span data-stock="${item.primary_stock}" style="color:#40C4FF;cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px">${esc(item.primary_stock)}</span>`
: `<span style="color:#546E7A">-</span>`;
return `<div class="news-item"><div class="news-icon"><div class="arrow" style="color:${sc}">${si}</div><div class="score" style="color:${sc}">${item.intensity||0}</div></div>
<div class="news-body"><div class="news-title">${titleHtml}</div>
<div class="news-meta">${stockChip}<span style="color:#546E7A">${esc(item.source||'')}</span><span style="color:#455A64">${ftime(item.analyzed_at)}</span></div>
${item.reason?`<div class="news-reason">${esc((item.reason||'').substring(0,100))}</div>`:''}</div>
<div class="badge" style="background:${sc}12;border:1px solid ${sc}30;color:${sc}">${item.investment_action||'관망'}</div></div>`;
}
function recCardHTML(item){
const rc=RCOL[item.recommendation]||'#78909C';
const pctVal=Math.min(100,Math.max(0,((item.total_score||0)+100)/2));
const bc=item.total_score>=30?'#00E676':item.total_score>=-30?'#78909C':'#FF5252';
return `<div class="rec-card" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer"><div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px">
<div><div style="font-size:15px;font-weight:700;color:#E0E0E0">${esc(item.stock_name)}</div><div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code}</div></div>
<div class="badge" style="background:${rc}15;border:1px solid ${rc}30;color:${rc}">${item.recommendation}</div></div>
<div style="display:flex;gap:10px;font-size:11px;color:#78909C;flex-wrap:wrap">
<span>종합 <b style="color:#E0E0E0;font-size:14px">${(item.total_score||0).toFixed(1)}</b></span>
<span>뉴스 <b style="color:#69F0AE">${(item.news_score||0).toFixed(0)}</b></span>
<span>기술 <b style="color:#CE93D8">${(item.technical_score||0).toFixed(0)}</b></span>
<span>공시 <b style="color:#40C4FF">${(item.dart_score||0).toFixed(0)}</b></span>
<span>외국인 <b style="color:${(item.foreign_score||0)>=0?'#00E676':'#FF5252'}">${(item.foreign_score||0).toFixed(0)}</b></span>
<span>공매도 <b style="color:${(item.short_score||0)>=0?'#78909C':'#FF8A80'}">${(item.short_score||0).toFixed(0)}</b></span></div>
${(item.foreign_ratio||0)>0?`<div style="font-size:10px;color:#546E7A;margin-top:4px">외국인보유 ${(item.foreign_ratio||0).toFixed(2)}% · 공매도비중 ${(item.short_weight||0).toFixed(2)}%</div>`:''}
<div class="score-bar"><div class="score-fill" style="width:${pctVal}%;background:${bc}"></div></div>
${item.top_reasons?`<div style="font-size:11px;color:#607D8B;line-height:1.5;border-top:1px solid rgba(255,255,255,0.04);padding-top:8px;margin-top:10px">${esc((item.top_reasons||'').split('|')[0].trim().substring(0,120))}...</div>`:''}</div>`;
}
function renderRecs(recs,avoid){
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">뉴스 감성 + DART 공시 + 가격 모멘텀 + 기술적 분석 종합 점수 (종목별 중복 제거)</div>`;
h+=`<h3 style="font-size:14px;font-weight:700;color:#69F0AE;margin-bottom:12px">🟢 매수 추천 종목</h3><div class="rec-grid">`;
if(recs&&recs.length){
recs.forEach(item=>{h+=recCardHTML(item)});
} else h+=`<div class="empty" style="grid-column:1/-1">추천 데이터가 아직 없습니다.</div>`;
h+=`</div>`;
h+=`<h3 style="font-size:14px;font-weight:700;color:#FF8A80;margin:28px 0 12px">⚠️ 매도관심 / 회피 종목</h3>`;
h+=`<div style="font-size:12px;color:#546E7A;margin-bottom:12px">악재 뉴스·공시가 누적된 종목입니다. 신규 매수보다 관망을 권장합니다.</div>`;
h+=`<div class="rec-grid">`;
if(avoid&&avoid.length){
avoid.forEach(item=>{h+=recCardHTML(item)});
} else h+=`<div class="empty" style="grid-column:1/-1">현재 회피 종목 없음</div>`;
h+=`</div>`;
document.getElementById('recommend').innerHTML=h;
}
let _newsOnlyStock = (localStorage.getItem('news_only_stock') ?? '1') === '1';
async function renderNews(recent){
// 토글 상태에 따라 다시 fetch (loadAll의 첫 호출은 only_stock=true 기본값)
const list = recent || await api('/recent?limit=80&only_stock='+(_newsOnlyStock?'true':'false'));
let h=`<div class="panel" style="max-height:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">${_newsOnlyStock?'📰 종목 관련 뉴스':'📰 전체 뉴스'}</h3>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:#90A4AE;cursor:pointer">
<input type="checkbox" id="news-only-stock" ${_newsOnlyStock?'checked':''} onchange="toggleNewsFilter(this.checked)" style="cursor:pointer">
종목 관련만 보기
</label>
</div>`;
if(list&&list.length){list.forEach(item=>{h+=newsItemHTML(item)})} else h+=`<div class="empty">뉴스 데이터 수집 중...</div>`;
h+=`</div>`;
document.getElementById('news').innerHTML=h;
}
async function toggleNewsFilter(checked){
_newsOnlyStock = checked;
localStorage.setItem('news_only_stock', checked?'1':'0');
const list = await api('/recent?limit=80&only_stock='+(checked?'true':'false'));
renderNews(list);
}
function renderAlerts(alerts){
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">강도 3 이상 뉴스/공시 (최근 24시간) · 종목코드 클릭 시 차트 모달이 열립니다</div>`;
if(alerts&&alerts.length){
alerts.forEach(item=>{
const sc=SCOL[item.sentiment]||'#78909C', si=SICON[item.sentiment]||'●';
const stockChip = item.primary_stock
? `<span data-stock="${item.primary_stock}" style="color:#40C4FF;cursor:pointer;font-family:'JetBrains Mono',monospace;background:rgba(64,196,255,0.08);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:6px">${esc(item.primary_stock)}</span>`
: '';
h+=`<div class="alert-item" style="background:${sc}08;border:1px solid ${sc}20">
<div style="display:flex;justify-content:space-between;align-items:center">
<div style="font-size:13px;font-weight:600;color:${sc}">${si} ${item.sentiment} (강도 ${item.intensity})</div>
<div style="font-size:10px;color:#546E7A">${ftime(item.analyzed_at)}</div></div>
<div style="font-size:12px;color:#CFD8DC;margin-top:6px;line-height:1.4">${stockChip}${esc(item.title||'')}</div>
<div style="font-size:11px;color:#78909C;margin-top:4px">${esc((item.reason||'').substring(0,100))}</div></div>`;
});
} else h+=`<div class="panel"><div class="empty">현재 긴급 알림이 없습니다.</div></div>`;
document.getElementById('alerts').innerHTML=h;
}
async function renderSupplyDemand(){
const el=document.getElementById('supplydemand');
el.innerHTML='<div class="empty">로딩 중...</div>';
const [supplyData,shortData]=await Promise.all([api('/supply-demand?limit=20'),api('/short-ranking?limit=20')]);
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">키움증권 API 기반 외국인/기관 수급 + 공매도 현황 (장 종료 후 17시 갱신)</div>`;
h+=`<div class="grid2">`;
// 외국인 순매수 상위
h+=`<div class="panel"><h3>🌏 외국인 순매수 상위 (5일 누적)</h3>`;
if(supplyData&&supplyData.length){
supplyData.slice(0,15).forEach((item,i)=>{
const net=item.net_5d||0;
const col=net>=0?'#00E676':'#FF5252';
const fscore=(item.foreign_score||0).toFixed(1);
const fscoreCol=(item.foreign_score||0)>=0?'#69F0AE':'#FF8A80';
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer">
<div class="rank-num ${i<3?'top':'normal'}">${i+1}</div>
<div style="flex:1">
<div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name||item.stock_code)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code} · 보유 ${(item.hold_ratio||0).toFixed(2)}%</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;font-weight:700;color:${col};font-family:'JetBrains Mono',monospace">${net>=0?'+':''}${Number(net).toLocaleString()}주</div>
<div style="font-size:10px;color:${fscoreCol}">수급점수 ${fscore}</div>
</div>
</div>`;
});
} else h+=`<div class="empty">외국인 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>`;
h+=`</div>`;
// 공매도 비중 상위
h+=`<div class="panel"><h3>📉 공매도 비중 상위 (최근일 기준)</h3>`;
if(shortData&&shortData.length){
shortData.slice(0,15).forEach((item,i)=>{
const w=item.trade_weight||0;
const col=w>=5?'#FF5252':w>=2?'#FF8A80':'#78909C';
const sscore=(item.short_score||0).toFixed(1);
const sscoreCol=(item.short_score||0)>=0?'#78909C':'#FF8A80';
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer">
<div class="rank-num ${i<3?'top':'normal'}" style="${i<3?'background:rgba(255,82,82,0.12);color:#FF5252':''}">${i+1}</div>
<div style="flex:1">
<div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name||item.stock_code)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code} · 잔고 ${Number(item.balance||0).toLocaleString()}주</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;font-weight:700;color:${col}">${w.toFixed(2)}%</div>
<div style="font-size:10px;color:${sscoreCol}">공매도점수 ${sscore}</div>
</div>
</div>`;
});
} else h+=`<div class="empty">공매도 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>`;
h+=`</div>`;
h+=`</div>`;
// 설명 카드
h+=`<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:14px;padding:20px;margin-top:16px">
<div style="font-size:13px;font-weight:700;color:#90A4AE;margin-bottom:12px">📌 수급 점수 해석</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;font-size:12px;color:#78909C;line-height:1.8">
<div>
<div style="color:#69F0AE;font-weight:600;margin-bottom:4px">외국인 점수 (+)</div>
<div>순매수 연속 3일 이상 → +40</div>
<div>보유비율 5일 상승 추세 → +40</div>
<div>절대 보유비율 10%↑ → +20</div>
</div>
<div>
<div style="color:#FF8A80;font-weight:600;margin-bottom:4px">공매도 점수 (-)</div>
<div>공매도 비중 10%↑ → -60</div>
<div>공매도 비중 5~10% → -35</div>
<div>잔고 5일 20%↑ → -20</div>
</div>
</div>
</div>`;
el.innerHTML=h;
}
setInterval(()=>{const c=document.getElementById('clock');if(c)c.textContent=new Date().toLocaleTimeString('ko-KR')},1000);
// 인증 후에만 동작하는 자동 새로고침
let _refreshTimer=null;
function startAutoRefresh(){if(_refreshTimer)return;_refreshTimer=setInterval(()=>{if(isLoggedIn())loadAll();},30000);}
function stopAutoRefresh(){if(_refreshTimer){clearInterval(_refreshTimer);_refreshTimer=null;}}
// 페이지 로드: 토큰 검증 → 대시보드 또는 로그인
(async()=>{
if(!isLoggedIn()){showLoginPage();return;}
try{
const r=await fetch(API+'/auth/me',{headers:authHeaders()});
if(!r.ok){clearAuth();showLoginPage();return;}
const me=await r.json();
setAuth(_authToken,me); // role 포함 갱신
showDashboard();
await Promise.all([loadPortfolio(), loadAll()]);
startAutoRefresh();
setTimeout(maybeOfferImport,500);
}catch(e){clearAuth();showLoginPage();}
})();
// 로그인 직후 자동 새로고침 시작
const _origShowDashboard=showDashboard;
showDashboard=function(){_origShowDashboard();startAutoRefresh();};
const _origLogout=logoutUser;
logoutUser=async function(){stopAutoRefresh();await _origLogout();};
async function doSearch(){
const q=document.getElementById('searchInput').value.trim();
if(!q){document.getElementById('searchResults').style.display='none';return}
const code6=q.match(/^\d{6}$/)?q:'';
const [news,sigs,ta]=await Promise.all([
api('/search?q='+encodeURIComponent(q)),
fetch(API.replace('8989','8585')+'/signals/'+q).then(r=>r.json()).catch(()=>[]),
code6?api('/technical/'+code6):Promise.resolve(null)
]);
let h=`<div class="panel" style="max-height:none"><h3>🔍 "${esc(q)}" 검색 결과</h3>`;
// 기술적 분석 상세
if(ta&&ta.indicators){
const ind=ta.indicators, tg=ta.targets||{};
const sc=ta.signal==='매수'?'#00E676':ta.signal==='매도'?'#FF5252':'#78909C';
h+=`<div style="background:${sc}06;border:1px solid ${sc}20;border-radius:12px;padding:16px;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="font-size:14px;color:${sc}">📊 기술적 분석 (${ta.signal||'관망'})</h4>
<span style="font-size:12px;color:#78909C">기술점수 <b style="color:${sc};font-size:16px">${(ta.tech_score||0).toFixed(1)}</b></span>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;font-size:12px;margin-bottom:12px">
<div style="color:#78909C">현재가 <b style="color:#E0E0E0">${fmt(ind.price)}</b></div>
<div style="color:#78909C">MA5 <b style="color:${ind.price>ind.ma5?'#69F0AE':'#FF8A80'}">${fmt(ind.ma5)}</b></div>
<div style="color:#78909C">MA20 <b style="color:${ind.price>ind.ma20?'#69F0AE':'#FF8A80'}">${fmt(ind.ma20)}</b></div>
<div style="color:#78909C">MA60 <b style="color:${ind.price>ind.ma60?'#69F0AE':'#FF8A80'}">${fmt(ind.ma60)}</b></div>
<div style="color:#78909C">RSI <b style="color:${ind.rsi<40?'#69F0AE':ind.rsi>70?'#FF8A80':'#E0E0E0'}">${(ind.rsi||0).toFixed(1)}</b></div>
<div style="color:#78909C">MACD <b style="color:${(ind.macd_hist||0)>0?'#69F0AE':'#FF8A80'}">${(ind.macd_hist||0).toFixed(3)}</b></div>
<div style="color:#78909C">볼밴%B <b style="color:#FFD740">${((ind.pct_b||0.5)*100).toFixed(0)}%</b></div>
<div style="color:#78909C">거래량비 <b style="color:#40C4FF">${(ind.vol_ratio||1).toFixed(1)}x</b></div>
</div>
${tg.t1?`<div class="price-row">
<div class="price-box"><div class="label">진입 추천가</div><div class="val" style="color:#40C4FF">${fmt(tg.entry_price)}</div></div>
<div class="price-box"><div class="label">목표1 (${pct(tg.t1_pct)})</div><div class="val" style="color:#69F0AE">${fmt(tg.t1)}</div></div>
<div class="price-box"><div class="label">목표2 (${pct(tg.t2_pct)})</div><div class="val" style="color:#00E676">${fmt(tg.t2)}</div></div>
<div class="price-box"><div class="label">손절가 (${pct(tg.stop_pct)})</div><div class="val" style="color:#FF8A80">${fmt(tg.stop_loss)}</div></div>
</div>`:''}
<div class="ta-row" style="margin-top:10px">${(ta.signals||[]).map(s=>`<span class="ta-badge">${esc(s)}</span>`).join('')}</div>
</div>`;
}
if(sigs&&sigs.length){
h+=`<div style="margin-bottom:16px"><h4 style="font-size:13px;color:#69F0AE;margin-bottom:8px">💰 매매 시그널</h4>`;
sigs.forEach(s=>{
const sc=s.signal_type==='매수'?'#00E676':'#FF5252';
h+=`<div data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name||'')}" style="background:${sc}08;border:1px solid ${sc}20;border-radius:10px;padding:12px;margin-bottom:8px;cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:14px;font-weight:700;color:${sc}">${s.signal_type} ${esc(s.stock_name)} (${s.stock_code})</span>
<span style="font-size:12px;color:#78909C">신뢰도 ${(s.confidence||0).toFixed(1)}%</span></div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;font-size:12px">
<div style="color:#78909C">현재가 <b style="color:#E0E0E0">${fmt(s.current_price)}원</b></div>
<div style="color:#78909C">목표가 <b style="color:#69F0AE">${fmt(s.target_price)}원</b></div>
<div style="color:#78909C">손절가 <b style="color:#FF8A80">${fmt(s.stop_loss)}원</b></div>
<div style="color:#78909C">예상수익 <b style="color:#FFD740">${pct(s.expected_return_pct)}</b></div></div>
${s.reason?`<div style="font-size:11px;color:#607D8B;margin-top:8px">${esc(s.reason.substring(0,150))}</div>`:''}</div>`;
});
h+=`</div>`;
}
if(news&&news.length){
h+=`<h4 style="font-size:13px;color:#90A4AE;margin:12px 0 8px">📰 관련 뉴스/공시 (${news.length}건)</h4>`;
news.forEach(item=>{h+=newsItemHTML(item)});
} else if(!sigs||!sigs.length){
h+=`<div class="empty">검색 결과가 없습니다.</div>`;
}
h+=`</div>`;
const el=document.getElementById('searchResults');
el.innerHTML=h;el.style.display='block';
// 검색 결과가 종목코드면 차트 자동 열기 옵션
if(code6){
const ctaBtn = `<div style="margin-bottom:12px"><button onclick="openStockChart('${code6}')" style="background:linear-gradient(135deg,#00C853,#00897B);border:none;border-radius:8px;padding:8px 18px;color:#fff;font-size:12px;font-weight:700;cursor:pointer">📊 ${code6} 차트 모달 열기</button></div>`;
el.querySelector('.panel').insertAdjacentHTML('afterbegin', ctaBtn);
}
}
// ── 챗봇 ──────────────────────────────────────────────────
let chatHistory=[], chatOpen=false;
function toggleChat(){
chatOpen=!chatOpen;
const p=document.getElementById('chat-panel');
p.style.display=chatOpen?'flex':'none';
if(chatOpen) document.getElementById('chat-input').focus();
}
function chatKey(e){
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();}
}
function mdToHtml(t){
return t
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/`(.+?)`/g,'<code>$1</code>')
.replace(/^### (.+)$/gm,'<strong>$1</strong>')
.replace(/^## (.+)$/gm,'<strong>$1</strong>')
.replace(/^- (.+)$/gm,'• $1')
.replace(/\n/g,'<br>');
}
async function sendChat(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg) return;
input.value='';
const msgs=document.getElementById('chat-messages');
// 유저 메시지
msgs.innerHTML+=`<div class="msg user">${msg.replace(/</g,'&lt;')}</div>`;
// 타이핑 표시
const typingId='typing-'+Date.now();
msgs.innerHTML+=`<div class="typing" id="${typingId}"><span></span><span></span><span></span></div>`;
msgs.scrollTop=msgs.scrollHeight;
document.getElementById('chat-send').disabled=true;
chatHistory.push({role:'user',content:msg});
// AI 답변 버블 생성
const aiId='ai-'+Date.now();
let fullText='';
try{
const resp=await fetch('/api/chat',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:msg, history:chatHistory.slice(-10)})
});
const reader=resp.body.getReader();
const dec=new TextDecoder();
let buf='';
// 타이핑 제거, AI 버블 추가
document.getElementById(typingId)?.remove();
msgs.innerHTML+=`<div class="msg ai" id="${aiId}"></div>`;
while(true){
const{done,value}=await reader.read();
if(done) break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n');
buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: ')) continue;
const payload=line.slice(6);
if(payload==='[DONE]') break;
try{
const delta=JSON.parse(payload).content||'';
fullText+=delta;
document.getElementById(aiId).innerHTML=mdToHtml(fullText);
msgs.scrollTop=msgs.scrollHeight;
}catch{}
}
}
}catch(e){
document.getElementById(typingId)?.remove();
msgs.innerHTML+=`<div class="msg ai" style="color:#FF8A80">오류가 발생했습니다. 다시 시도해주세요.</div>`;
}
if(fullText) chatHistory.push({role:'assistant',content:fullText});
document.getElementById('chat-send').disabled=false;
msgs.scrollTop=msgs.scrollHeight;
}
// ─────────────────────────────────────────────────────────────
// 🧮 10공식 신호 매트릭스
// ─────────────────────────────────────────────────────────────
function _voteColor(v, pos){ // pos=true=매수신호, false=매도신호
if(v==null) return '#37474F';
return pos ? '#69F0AE' : '#FF8A80';
}
function _cell(label, val, hit, tooltip){
const c = hit ? (hit==='buy'?'#69F0AE':'#FF8A80') : '#546E7A';
const bg = hit ? (hit==='buy'?'rgba(0,230,118,0.10)':'rgba(255,82,82,0.10)') : 'rgba(255,255,255,0.03)';
return `<td title="${tooltip||''}" style="padding:6px 8px;text-align:center;font-family:'JetBrains Mono',monospace;font-size:11px;color:${c};background:${bg};border-radius:6px">${val}</td>`;
}
async function renderFormulas(){
const el=document.getElementById('formulas');
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">10공식(매직포뮬러·F-Score·Altman Z·PEG·12-1 모멘텀·Beneish·GP/A·G-Score·Amihud·BAB) 신호 매트릭스 — 오늘 점수 상위</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
try{
const [rows, weights] = await Promise.all([
api('/formulas/matrix?limit=50'),
api('/weights')
]);
if(!Array.isArray(rows)||!rows.length){el.innerHTML='<div class="empty">데이터가 없습니다</div>';return;}
const w = (weights && weights.weights) || {};
let wStr = Object.entries(w).map(([k,v])=>`<span style="margin-right:10px"><b style="color:#69F0AE">${k}</b> <span style="font-family:'JetBrains Mono',monospace">${(+v).toFixed(2)}</span></span>`).join('');
let h = `<div style="background:rgba(0,230,118,0.04);border:1px solid rgba(0,230,118,0.12);border-radius:10px;padding:10px 14px;margin-bottom:14px;font-size:11px;color:#90A4AE">학습 가중치: ${wStr||'<i>미학습</i>'}</div>`;
h += `<div style="overflow:auto;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:14px;padding:10px"><table style="width:100%;border-collapse:separate;border-spacing:2px;font-size:11px">
<thead><tr style="color:#90A4AE">
<th style="text-align:left;padding:8px 6px">종목</th>
<th style="text-align:right;padding:8px 6px">점수</th>
<th>등급</th>
<th title="매수 / 매도 보팅 합">B/S</th>
<th title="Greenblatt 매직포뮬러 (ROC+EY)">Magic</th>
<th title="Piotroski F-Score (0~7)">F</th>
<th title="Altman Z">Z</th>
<th title="Lynch PEG">PEG</th>
<th title="12-1개월 모멘텀">12-1%</th>
<th title="Beneish M-Score (분식의심도)">Bnsh</th>
<th title="Novy-Marx GP/A">GP/A%</th>
<th title="Mohanram G-Score (vs 섹터 중앙값)">G</th>
<th title="Amihud 비유동성">Amh</th>
<th title="저베타 알파 (Frazzini-Pedersen BAB)">β</th>
<th title="catalyst×감쇠×중복 가중 뉴스점수 (-100~100)">뉴스</th>
<th title="감정 모멘텀: 최근 3일 vs 이전 4일 변화율">M</th>
<th title="시장 평균 대비 sentiment alpha">α</th>
<th title="최근 7일 뉴스건수 log 스케일 관심도 (0~100)">관심</th>
<th title="평소 대비 뉴스 폭증 배율 (x)">×</th>
</tr></thead><tbody>`;
rows.forEach(r=>{
let sig = r.signals || {};
if(typeof sig === 'string'){ try{ sig = JSON.parse(sig); }catch{ sig = {}; } }
const hit = (k)=>{
const v = sig[k];
if(v==='buy' || v==='매수') return 'buy';
if(v==='sell' || v==='매도') return 'sell';
return null;
};
const recC = RCOL[r.recommendation] || '#90A4AE';
const code6 = r.stock_code;
h += `<tr style="background:rgba(255,255,255,0.02);cursor:pointer" onclick="openStockChart('${code6}')">
<td style="padding:8px 6px;color:#CFD8DC"><b>${esc(r.stock_name||code6)}</b> <span style="color:#546E7A;font-size:10px;font-family:'JetBrains Mono',monospace">${code6}</span></td>
<td style="padding:8px 6px;text-align:right;font-family:'JetBrains Mono',monospace;font-weight:700;color:${recC}">${(+r.total_score).toFixed(1)}</td>
<td style="text-align:center"><span class="badge" style="background:${recC}22;color:${recC};font-size:10px;padding:3px 7px">${r.recommendation||'-'}</span></td>
<td style="text-align:center;color:#90A4AE;font-family:'JetBrains Mono',monospace">${r.buy_votes||0}/${r.sell_votes||0}</td>
${_cell('Magic', r.magic_score!=null?(+r.magic_score).toFixed(0):'-', hit('magic'), 'ROC + EY 임계값 합')}
${_cell('F', r.f_score!=null?(+r.f_score).toFixed(0):'-', hit('fscore'), '7신호 합')}
${_cell('Z', r.altman_z!=null?(+r.altman_z).toFixed(2):'-', hit('altman'), 'Altman Z (≥2.6 안전)')}
${_cell('PEG', r.peg!=null?(+r.peg).toFixed(2):'-', hit('peg'), 'PER/성장률 (≤1.5 매수)')}
${_cell('12-1%', r.momentum_pct!=null?(+r.momentum_pct).toFixed(1):'-', hit('momentum'), '12-1개월 모멘텀')}
${_cell('Bnsh', r.beneish_score!=null?(+r.beneish_score).toFixed(0):'-', hit('beneish'), 'Beneish M (분식의심도)')}
${_cell('GP/A%', r.gpa_pct!=null?(+r.gpa_pct).toFixed(1):'-', hit('gpa'), '영업이익/총자산')}
${_cell('G', r.g_score!=null?(+r.g_score).toFixed(0):'-', hit('gscore'), 'Mohanram G (vs 섹터)')}
${_cell('Amh', r.amihud_illiq!=null?(+r.amihud_illiq).toFixed(0):'-', hit('amihud'), 'Amihud 비유동성')}
${_cell('β', r.market_beta!=null?(+r.market_beta).toFixed(2):'-', hit('beta'), '60일 KOSPI 회귀 베타')}
${(()=>{ // 뉴스점수
const v = r.news_score;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=10?'#69F0AE':v<=-10?'#FF8A80':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c};font-weight:700">${(+v).toFixed(0)}</td>`;
})()}
${(()=>{ // 모멘텀
const v = r.sentiment_momentum;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=5?'#69F0AE':v<=-5?'#FF8A80':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${(+v).toFixed(1)}</td>`;
})()}
${(()=>{ // alpha
const v = r.sentiment_alpha;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=5?'#69F0AE':v<=-5?'#FF8A80':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${v>0?'+':''}${(+v).toFixed(0)}</td>`;
})()}
${(()=>{ // attention
const v = r.attention_score;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=70?'#FFD740':v>=40?'#69F0AE':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${(+v).toFixed(0)}</td>`;
})()}
${(()=>{ // surge
const v = r.news_surge_ratio;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=3?'#FFD740':v>=1.5?'#69F0AE':'#546E7A';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${(+v).toFixed(1)}×</td>`;
})()}
</tr>`;
});
h += `</tbody></table></div>`;
h += `<div style="margin-top:14px;font-size:11px;color:#546E7A;line-height:1.6">초록=매수 신호, 빨강=매도 신호, 회색=중립/미산출. 종목 행 클릭 시 차트 모달. <br>📚 매트릭스 셀 위에 마우스를 올리면 공식 설명이 표시됩니다.</div>`;
el.innerHTML = h;
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
}
// ─────────────────────────────────────────────────────────────
// 📈 백테스트 성과
// ─────────────────────────────────────────────────────────────
function _btCard(label, v, suffix, danger){
if(v==null||isNaN(v)) return `<div class="card"><div class="label">${label}</div><div class="val" style="font-size:24px;color:#546E7A">-</div></div>`;
const c = danger ? (v<0?'#FF5252':'#00E676') : '#69F0AE';
return `<div class="card"><div class="label">${label}</div><div class="val" style="color:${c}">${(+v).toFixed(2)}${suffix||''}</div></div>`;
}
function _btTable(byRec){
const order = ['강력매수','매수관심','관망','매도관심','강력매도'];
let h = `<table style="width:100%;border-collapse:separate;border-spacing:2px;font-size:12px">
<thead><tr style="color:#90A4AE">
<th style="text-align:left;padding:8px 6px">등급</th>
<th>표본 N</th><th>평균 수익률</th><th>승률</th><th>샤프</th><th>MDD</th><th>알파</th>
</tr></thead><tbody>`;
order.forEach(rec=>{
const d = byRec[rec] || {n:0};
const recC = RCOL[rec] || '#90A4AE';
if(!d.n){
h += `<tr style="background:rgba(255,255,255,0.02)"><td style="padding:8px"><span class="badge" style="background:${recC}22;color:${recC}">${rec}</span></td><td colspan="6" style="text-align:center;color:#546E7A">표본 없음</td></tr>`;
}else{
const rC = d.avg_return_pct>=0?'#69F0AE':'#FF8A80';
const aC = (d.avg_alpha_pct||0)>=0?'#69F0AE':'#FF8A80';
h += `<tr style="background:rgba(255,255,255,0.02)">
<td style="padding:8px"><span class="badge" style="background:${recC}22;color:${recC}">${rec}</span></td>
<td style="text-align:center;color:#90A4AE">${d.n}</td>
<td style="text-align:center;color:${rC};font-family:'JetBrains Mono',monospace;font-weight:700">${(+d.avg_return_pct).toFixed(2)}%</td>
<td style="text-align:center;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${(+d.win_rate_pct).toFixed(1)}%</td>
<td style="text-align:center;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${(+d.sharpe).toFixed(2)}</td>
<td style="text-align:center;color:#FF8A80;font-family:'JetBrains Mono',monospace">${(+d.max_drawdown_pct).toFixed(2)}%</td>
<td style="text-align:center;color:${aC};font-family:'JetBrains Mono',monospace">${(+d.avg_alpha_pct).toFixed(2)}%</td>
</tr>`;
}
});
h += `</tbody></table>`;
return h;
}
async function renderBacktest(){
const el=document.getElementById('backtest');
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">추천 등급별 백테스트 성과 (기간/홀딩 슬라이드)</div>
<div style="margin-bottom:14px;display:flex;gap:8px;align-items:center">
<span style="color:#90A4AE;font-size:12px">기간:</span>
<select id="bt-days" onchange="renderBacktest()" style="background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#CFD8DC;padding:6px 10px;border-radius:6px;font-size:12px">
<option value="30">30일</option><option value="60">60일</option><option value="90">90일</option><option value="180" selected>180일</option><option value="360">360일</option>
</select>
</div>
<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
// re-read select if exists
const daysSel = document.getElementById('bt-days');
const days = daysSel ? +daysSel.value : 180;
try{
const d = await api(`/backtest?days=${days}`);
if(d.error){el.innerHTML=`<div class="empty">오류: ${esc(d.error)}</div>`;return;}
const oa = (d.overall && (d.overall['7d']||d.overall['30d'])) || {};
const oa30 = (d.overall && d.overall['30d']) || {};
let h = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">기간 ${d.period_days||days}일 · 표본 ${d.total_recommendations||0}건</div>
<div style="margin-bottom:14px;display:flex;gap:8px;align-items:center">
<span style="color:#90A4AE;font-size:12px">기간:</span>
<select id="bt-days" onchange="renderBacktest()" style="background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#CFD8DC;padding:6px 10px;border-radius:6px;font-size:12px">
${[30,60,90,180,360].map(x=>`<option value="${x}" ${x===days?'selected':''}>${x}일</option>`).join('')}
</select>
</div>
<h3 style="color:#90A4AE;font-size:13px;margin:8px 0 10px">7일 보유</h3>
<div class="cards">
${_btCard('N (표본)', oa.n, '')}
${_btCard('평균 수익률', oa.avg_return_pct, '%', true)}
${_btCard('승률', oa.win_rate_pct, '%')}
${_btCard('샤프', oa.sharpe, '', true)}
${_btCard('MDD', oa.max_drawdown_pct, '%', true)}
${_btCard('알파', oa.avg_alpha_pct, '%', true)}
</div>
${equityCurveSVG(d.equity_curve)}
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (7일)</h3>${_btTable(d.by_recommendation_7d||{})}</div>`;
if(oa30.n){
h += `<h3 style="color:#90A4AE;font-size:13px;margin:18px 0 10px">30일 보유</h3>
<div class="cards">
${_btCard('N', oa30.n, '')}
${_btCard('평균 수익률', oa30.avg_return_pct, '%', true)}
${_btCard('승률', oa30.win_rate_pct, '%')}
${_btCard('샤프', oa30.sharpe, '', true)}
${_btCard('MDD', oa30.max_drawdown_pct, '%', true)}
${_btCard('알파', oa30.avg_alpha_pct, '%', true)}
</div>
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (30일)</h3>${_btTable(d.by_recommendation_30d||{})}</div>`;
}
h += `<div style="margin-top:14px;font-size:11px;color:#546E7A">알파 = 추천 평균 수익률 − 같은 기간 KOSPI 수익률. 음수면 시장 대비 underperform.</div>`;
el.innerHTML = h;
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
}
// ─────────────────────────────────────────────────────────────
// 🌎 미증시 동조
// ─────────────────────────────────────────────────────────────
// 미국 주요 티커 → 한글 회사명/설명 매핑 (페어·ETF 표시용)
const US_TICKER_KR = {
// 반도체
NVDA:'엔비디아', AMD:'AMD', MU:'마이크론', INTC:'인텔', TSM:'TSMC(대만 반도체)',
AVGO:'브로드컴', QCOM:'퀄컴', TXN:'TI', AMAT:'어플라이드머티리얼', LRCX:'램리서치', KLAC:'KLA',
// 빅테크
AAPL:'애플', MSFT:'마이크로소프트', GOOGL:'구글', AMZN:'아마존', META:'메타(페북)',
TSLA:'테슬라', NFLX:'넷플릭스', ORCL:'오라클', ADBE:'어도비', CRM:'세일즈포스',
// 금융
JPM:'JP모건', BAC:'뱅크오브아메리카', GS:'골드만삭스', MS:'모건스탠리',
WFC:'웰스파고', C:'씨티그룹', BLK:'블랙록',
// 에너지/소재
XOM:'엑손모빌', CVX:'쉐브론', COP:'코노코필립스', NUE:'뉴코어(철강)', FCX:'프리포트(구리)',
// 헬스/바이오
JNJ:'존슨앤존슨', PFE:'화이자', MRK:'머크', ABBV:'애브비', LLY:'일라이릴리',
BMY:'BMS', GILD:'길리어드',
// 소비재/유통
WMT:'월마트', COST:'코스트코', HD:'홈디포', MCD:'맥도날드', NKE:'나이키',
SBUX:'스타벅스', DIS:'디즈니',
// 산업/방산
BA:'보잉', LMT:'록히드마틴', RTX:'레이시온', GD:'제너럴다이내믹스', CAT:'캐터필러', DE:'존디어',
// 자동차
F:'포드', GM:'GM', TM:'도요타',
// 2차전지/리튬
ALB:'알버말(리튬)', RIVN:'리비안',
// 화학
DOW:'다우', LYB:'라이언델바젤',
// 게임/엔터
NTES:'넷이즈(중국게임)',
// ETF
SOXX:'반도체 ETF', SMH:'반도체 ETF(VanEck)', XLK:'미국 기술주 ETF', QQQ:'나스닥100 ETF',
XBI:'바이오 ETF', IBB:'바이오 ETF(나스닥)', LIT:'리튬·전기차 ETF',
XLE:'에너지 ETF', XLF:'금융 ETF', XLV:'헬스케어 ETF', XLI:'산업재 ETF',
XLP:'필수소비재 ETF', XLY:'경기소비재 ETF', ITA:'항공·방산 ETF',
};
function usName(t){ return US_TICKER_KR[t] || ''; }
async function renderUsMarket(){
const el=document.getElementById('usmarket');
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
try{
const [etfs, pairs, signals, brief] = await Promise.all([
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals'), api('/usmarket/briefing')
]);
// ── 0. 탭 안내 (이 탭은 뭐 하는 곳?) ──
let h = `
<div style="background:linear-gradient(135deg,rgba(64,196,255,0.07),rgba(105,240,174,0.05));border:1px solid rgba(64,196,255,0.25);border-radius:12px;padding:14px 18px;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:18px">🌎</span>
<span style="font-weight:700;color:#40C4FF;font-size:14px">이 탭은 뭐예요?</span>
</div>
<div style="color:#CFD8DC;font-size:12.5px;line-height:1.7">
<b style="color:#69F0AE">미국 증시의 움직임</b>이 한국 종목에 어떤 영향을 줄지 미리 보여주는 곳입니다.
예) <span style="color:#FFD740">엔비디아가 어제 +5% 올랐다 → SK하이닉스도 오를 가능성 ↑</span>
<br>
매일 한국 시장 시작 전(KST 08:00)에 미국 마감을 보고 자동 계산됩니다.
</div>
<div style="background:rgba(255,255,255,0.04);border-radius:8px;padding:10px 12px;margin-top:10px;font-size:11.5px;color:#90A4AE;line-height:1.7">
<b style="color:#FFD740">📖 보는 법</b><br>
① <b style="color:#CFD8DC">최신 동조 시그널</b>: 한국 종목별로 "오늘 미국 영향을 +/- 몇 점 받았는지". <b style="color:#69F0AE">+5 이상이면 강한 호재</b>, <b style="color:#FF8A80">-5 이하면 강한 악재</b>. 종합점수에 반영됨.<br>
② <b style="color:#CFD8DC">섹터 ETF</b>: 미국 업종 ETF가 한국 어떤 업종에 영향 주는지. 예) 미국 반도체 ETF(SOXX) ↑ → 한국 반도체 ↑<br>
③ <b style="color:#CFD8DC">KR↔US 페어</b>: 개별 종목 간 동조 (엔비디아↔SK하이닉스 등). β(민감도)가 클수록 강하게 따라감.
</div>
</div>`;
// ── 0.5. 오늘 새벽 미국증시 브리핑 (핫/저조 + 관련 KOSPI) ──
if(brief && !brief.err && ((brief.hot&&brief.hot.length)||(brief.cold&&brief.cold.length))){
const aiC=(v)=> v==null?'#546E7A': v>=30?'#69F0AE': v<=-30?'#FF8A80':'#90A4AE';
const krLine=(k)=>{
const sc=(k.ai_score!=null)?`<span style="color:${aiC(k.ai_score)};font-family:'JetBrains Mono',monospace;font-size:11px">${k.ai_score>=0?'+':''}${k.ai_score}</span>`:'<span style="color:#546E7A;font-size:11px">점수없음</span>';
const rec=(k.recommendation&&k.recommendation!=='-')?`<span style="color:${RCOL[k.recommendation]||'#78909C'};font-size:10px;font-weight:600">${esc(k.recommendation)}</span>`:'';
return `<div data-stock="${esc(k.kr_code)}" data-stock-name="${esc(k.kr_name)}" style="display:flex;gap:6px;align-items:center;padding:3px 0;cursor:pointer">
<span style="color:#CFD8DC;font-size:11.5px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(k.kr_name)}</span>${sc}${rec}</div>`;
};
const stockCard=(s,isHot)=>{
const c=isHot?'#69F0AE':'#FF8A80';
const krs=(s.related_kr||[]).map(krLine).join('')||'<span style="color:#546E7A;font-size:11px">연결된 한국 종목 없음</span>';
return `<div style="background:rgba(255,255,255,0.03);border:1px solid ${c}33;border-radius:10px;padding:10px 12px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:8px">
<span style="color:#E0E0E0;font-weight:700;font-size:12.5px">${esc(s.label)}</span>
<span style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace;font-size:14px;white-space:nowrap">${s.change_pct>=0?'+':''}${s.change_pct}%</span>
</div>
<div style="border-top:1px solid rgba(255,255,255,0.05);padding-top:5px">
<div style="font-size:10px;color:#607D8B;margin-bottom:2px">관련 KOSPI 종목 (AI점수 높은 순)</div>${krs}
</div></div>`;
};
h += `<div class="panel"><h3>🌙 오늘 새벽 미국증시 (${esc(brief.trade_date||'')} 기준)</h3>
<div style="font-size:11.5px;color:#90A4AE;margin-bottom:14px;line-height:1.6">간밤 미국에서 <b style="color:#69F0AE">오른 종목</b>·<b style="color:#FF8A80">내린 종목</b>과, 같이 움직이는 한국(KOSPI) 관련주입니다. 관련주의 <b style="color:#FFD740">AI점수가 높으면(+30↑)</b> 오늘 주목해볼 만합니다.</div>
<div class="usbrief-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div><div style="color:#69F0AE;font-weight:700;font-size:12px;margin-bottom:8px">🔥 어젯밤 강했던 종목</div>
${(brief.hot||[]).map(s=>stockCard(s,true)).join('')||'<div style="color:#546E7A;font-size:11px">데이터 없음</div>'}</div>
<div><div style="color:#FF8A80;font-weight:700;font-size:12px;margin-bottom:8px">❄️ 어젯밤 약했던 종목</div>
${(brief.cold||[]).map(s=>stockCard(s,false)).join('')||'<div style="color:#546E7A;font-size:11px">데이터 없음</div>'}</div>
</div>`;
if(brief.sector_heat&&brief.sector_heat.length){
h += `<div style="margin-top:14px"><div style="font-size:11px;color:#607D8B;margin-bottom:6px">미국 업종 ETF 등락 — 한국 같은 업종에 영향</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">`;
brief.sector_heat.forEach(e=>{
const c=e.change_pct>=0?'#69F0AE':'#FF8A80';
h += `<span style="background:${c}1a;border:1px solid ${c}40;border-radius:6px;padding:3px 9px;font-size:10.5px;color:#CFD8DC">${esc(e.label)} <b style="color:${c}">${e.change_pct>=0?'+':''}${e.change_pct}%</b></span>`;
});
h += `</div></div>`;
}
h += `</div>`;
}
// ── 1. 최신 동조 시그널 (가장 중요 — 맨 위로) ──
h += `<div class="panel"><h3>📡 오늘의 미국→한국 영향 (${Array.isArray(signals)?signals.length:0}개 종목)</h3>`;
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:12px">
🟢 양수(+) = 미국 영향으로 <b style="color:#69F0AE">올라갈 압력</b> · 🔴 음수(-) = <b style="color:#FF8A80">내려갈 압력</b><br>
"섹터"는 미국 업종 ETF, "페어"는 짝지어진 미국 개별주의 영향. 둘을 합친 게 총점.
</div>`;
if(Array.isArray(signals) && signals.length){
const sorted = signals.slice().sort((a,b)=>Math.abs(+b.total_adj||0) - Math.abs(+a.total_adj||0));
// 강도 그룹별로 분류
const strong = sorted.filter(s=>Math.abs(+s.total_adj||0) >= 5);
const medium = sorted.filter(s=>{ const a=Math.abs(+s.total_adj||0); return a>=2 && a<5; });
const weak = sorted.filter(s=>Math.abs(+s.total_adj||0) < 2);
const renderCard = (s)=>{
const total = +s.total_adj || 0;
const sec = +s.sector_adj || 0;
const pair = +s.pair_adj || 0;
const c = total>0?'#69F0AE':total<0?'#FF8A80':'#90A4AE';
const arrow = total>=5?'🚀':total<=-5?'⚠️':total>0?'▲':total<0?'▼':'·';
const krName = esc(s.kr_name || s.kr_code || '-');
// contributing_pairs에서 어떤 미국 종목이 주된 원인인지 표시
let topReason = '';
try{
const cp = s.contributing_pairs;
const pairs = (cp && cp.pairs) ? cp.pairs : (Array.isArray(cp)?cp:[]);
if(pairs && pairs.length){
const top = pairs.slice().sort((a,b)=>Math.abs(+b.contribution||0)-Math.abs(+a.contribution||0))[0];
if(top){
const usK = usName(top.us) || '';
topReason = `${esc(top.us)}${usK?`(${esc(usK)})`:''} ${(+top.pct||0)>=0?'+':''}${(+top.pct||0).toFixed(1)}%`;
}
}
}catch(e){}
return `<div style="background:rgba(255,255,255,0.03);border:1px solid ${c}33;border-radius:10px;padding:12px 14px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<div style="flex:1;min-width:0">
<div style="color:#CFD8DC;font-weight:700;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${krName}</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;color:#546E7A">${esc(s.kr_code||'')}</div>
</div>
<div style="text-align:right">
<div style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace;font-size:18px">${arrow} ${total>0?'+':''}${total.toFixed(1)}</div>
</div>
</div>
<div style="font-size:10.5px;color:#78909C;margin-top:6px;display:flex;gap:10px;flex-wrap:wrap">
<span title="섹터 ETF 영향">섹터 <span style="color:${sec>=0?'#69F0AE':'#FF8A80'}">${sec>=0?'+':''}${sec.toFixed(1)}</span></span>
<span title="개별 페어 영향">페어 <span style="color:${pair>=0?'#69F0AE':'#FF8A80'}">${pair>=0?'+':''}${pair.toFixed(1)}</span></span>
</div>
${topReason?`<div style="font-size:10px;color:#90A4AE;margin-top:5px;border-top:1px solid rgba(255,255,255,0.05);padding-top:5px">📌 주원인: ${topReason}</div>`:''}
</div>`;
};
const renderGroup = (title, color, arr) => {
if(!arr.length) return '';
return `<div style="margin-bottom:14px">
<div style="font-size:12px;color:${color};font-weight:700;margin-bottom:8px">${title} <span style="color:#546E7A;font-weight:400">${arr.length}개</span></div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">
${arr.slice(0,40).map(renderCard).join('')}
</div>
</div>`;
};
h += renderGroup('🔥 강한 영향 (|점수| ≥ 5)', '#FFD740', strong);
h += renderGroup('➡️ 보통 영향 (2 ~ 5)', '#40C4FF', medium);
if(weak.length && (strong.length + medium.length) < 5){
h += renderGroup('약한 영향 (< 2)', '#78909C', weak.slice(0,20));
}
} else h += `<div class="empty">시그널 없음 — 매일 08:00에 자동 계산됨. 처음 배포 직후라면 아직 생성 전입니다.</div>`;
h += `</div>`;
// ── 2. 섹터 ETF (어떤 업종이 어떻게 매칭되는지) ──
h += `<div class="grid2" style="margin-top:14px">`;
h += `<div class="panel"><h3>🏭 미국 섹터 ETF → 한국 업종 매칭 (${Array.isArray(etfs)?etfs.length:0})</h3>`;
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:10px">미국 ETF가 오르내리면 매칭된 한국 업종도 같이 영향받습니다.</div>`;
if(Array.isArray(etfs) && etfs.length){
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
<thead><tr style="color:#90A4AE;font-size:10.5px">
<th style="text-align:left;padding:4px 6px">미국 ETF</th>
<th style="text-align:left">설명</th>
<th style="text-align:left">한국 업종</th>
</tr></thead><tbody>`;
etfs.slice(0,30).forEach(e=>{
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(' · '):'';
const krDesc = usName(e.etf_ticker) || esc(e.description||'-');
h += `<tr style="background:rgba(255,255,255,0.02)">
<td style="padding:7px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td>
<td style="color:#CFD8DC">${esc(krDesc)}</td>
<td style="color:#FFD740;font-size:11px">${esc(kw)}</td>
</tr>`;
});
h += `</tbody></table>`;
} else h += `<div class="empty">데이터 없음</div>`;
h += `</div>`;
// ── 3. 개별 페어 ──
h += `<div class="panel"><h3>🔗 개별 종목 짝짓기 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:10px">
<b>민감도(β)</b>: 미국 종목이 1% 오를 때 한국 종목이 몇% 따라가는지<br>
<b>상관계수(ρ)</b>: 같이 움직이는 정도. 0.7↑면 매우 강함, 0.5↑면 의미 있음, 0.3↓면 약함
</div>`;
if(Array.isArray(pairs) && pairs.length){
// 한국명 매칭되는 페어를 우선 정렬
const sorted = pairs.slice().sort((a,b)=>{
const corA = Math.abs(+a.correlation_60d||0);
const corB = Math.abs(+b.correlation_60d||0);
return corB - corA;
});
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
<thead><tr style="color:#90A4AE;font-size:10.5px">
<th style="text-align:left;padding:4px 6px">미국</th>
<th style="text-align:left">한국</th>
<th title="민감도: 미국 +1%일 때 한국 +β%" style="text-align:center">민감도(β)</th>
<th title="상관: 같이 움직이는 정도 (0~1)" style="text-align:center">상관(ρ)</th>
</tr></thead><tbody>`;
sorted.slice(0,40).forEach(p=>{
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
const corrN = +p.correlation_60d || 0;
const corrC = Math.abs(corrN)>=0.7?'#69F0AE':Math.abs(corrN)>=0.5?'#FFD740':Math.abs(corrN)>=0.3?'#90A4AE':'#546E7A';
const usK = usName(p.us_ticker) || '';
const krN = esc(p.kr_name || '');
h += `<tr style="background:rgba(255,255,255,0.02)">
<td style="padding:6px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}${usK?`<span style="color:#90A4AE;font-family:'Pretendard',sans-serif;font-size:10px;margin-left:4px">${esc(usK)}</span>`:''}</td>
<td style="color:#CFD8DC">${krN || `<span style="font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</span>`}</td>
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td>
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${corrC};font-weight:700">${corr}</td>
</tr>`;
});
h += `</tbody></table>`;
} else h += `<div class="empty">페어 없음</div>`;
h += `</div></div>`;
el.innerHTML = h;
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
}
// ─────────────────────────────────────────────────────────────
// 🌐 매크로 + 섹터 집중도
// ─────────────────────────────────────────────────────────────
async function renderMacro(){
const el=document.getElementById('macro');
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">한국 매크로 지표 (ECOS) + 추천 종목 섹터 집중도 + 학습 가중치</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
try{
const [m, conc, w] = await Promise.all([
api('/macro/ecos'), api('/sector-concentration'), api('/weights')
]);
let h = `<h3 style="color:#90A4AE;font-size:13px;margin-bottom:10px">한국 매크로</h3>`;
if(m && !m.error){
h += `<div class="cards">`;
const items = [
['KOSPI', m.kospi, ''],
['USD/KRW', m.usdkrw, '원'],
['국고채 3년', m.kor_3y, '%'],
['국고채 10년', m.kor_10y, '%'],
];
items.forEach(([label, obj, unit])=>{
if(!obj){h += `<div class="card"><div class="label">${label}</div><div class="val" style="font-size:24px;color:#546E7A">-</div></div>`;return;}
h += `<div class="card"><div class="label">${label}</div><div class="val" style="font-size:28px;color:#69F0AE">${(+obj.value).toLocaleString('ko-KR',{maximumFractionDigits:3})}<span style="font-size:14px;color:#90A4AE">${unit}</span></div><div class="sub">${obj.date||''}</div></div>`;
});
h += `</div>`;
} else h += `<div class="empty">매크로 데이터 없음 (aux-signal 미실행?)</div>`;
// 섹터 집중도
h += `<h3 style="color:#90A4AE;font-size:13px;margin:20px 0 10px">추천 종목 섹터 집중도</h3>`;
if(conc && !conc.error && Array.isArray(conc.sectors)){
if(Array.isArray(conc.warnings) && conc.warnings.length){
h += `<div style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.25);border-radius:10px;padding:12px 14px;margin-bottom:12px;color:#FF8A80;font-size:12px">⚠ ${conc.warnings.map(esc).join(' / ')}</div>`;
}
h += `<div class="panel"><table style="width:100%;font-size:12px;border-collapse:separate;border-spacing:1px">
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">섹터</th><th>종목 수</th><th>비중</th><th>평균 점수</th><th style="width:40%">분포</th></tr></thead><tbody>`;
conc.sectors.slice(0,20).forEach(s=>{
const pct = +s.share_pct;
const c = pct>=30?'#FF8A80':pct>=15?'#FFD740':'#69F0AE';
h += `<tr><td style="padding:6px 8px;color:#CFD8DC">${esc(s.sector||'-')}</td>
<td style="text-align:center;color:#90A4AE">${s.count}</td>
<td style="text-align:center;color:${c};font-family:'JetBrains Mono',monospace;font-weight:700">${pct.toFixed(1)}%</td>
<td style="text-align:center;color:#90A4AE;font-family:'JetBrains Mono',monospace">${(+s.avg_score).toFixed(1)}</td>
<td><div style="background:rgba(255,255,255,0.05);border-radius:3px;height:6px;overflow:hidden"><div style="background:${c};height:100%;width:${Math.min(pct,100)}%"></div></div></td>
</tr>`;
});
h += `</tbody></table></div>`;
} else h += `<div class="empty">섹터 집중도 데이터 없음</div>`;
// 가중치
h += `<h3 style="color:#90A4AE;font-size:13px;margin:20px 0 10px">10공식 학습 가중치</h3>`;
if(w && w.weights){
const ws = Object.entries(w.weights).sort((a,b)=>b[1]-a[1]);
const maxW = Math.max(...ws.map(x=>+x[1]),1);
h += `<div class="panel"><div style="font-size:11px;color:#546E7A;margin-bottom:10px">학습일 ${w.config_date||'-'} · 백테스트 ${w.period_days||90}일 · 표본 ${w.sample_size||0}</div>`;
ws.forEach(([k,v])=>{
const ratio = (+v / maxW * 100);
h += `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
<div style="width:80px;color:#CFD8DC;font-size:12px"><b>${k}</b></div>
<div style="flex:1;background:rgba(255,255,255,0.05);border-radius:3px;height:8px;overflow:hidden"><div style="background:#69F0AE;height:100%;width:${ratio}%"></div></div>
<div style="width:50px;text-align:right;font-family:'JetBrains Mono',monospace;color:#69F0AE;font-size:12px">${(+v).toFixed(2)}</div>
</div>`;
});
h += `</div>`;
} else h += `<div class="empty">학습된 가중치 없음 (POST /learn-weights 호출 필요)</div>`;
el.innerHTML = h;
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
}
</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>