8b200e6cc6
- 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>
2532 lines
165 KiB
HTML
2532 lines
165 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Trading AI Dashboard</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap');
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0}
|
||
::-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개 투자 공식이 각자 매수/매도를 투표한 결과.'],
|
||
['추천등급','강력매수 > 매수관심 > 관망 > 매도관심 > 강력매도 순. 점수와 공식 투표를 함께 본다.'],
|
||
]},
|
||
{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&§or.length){
|
||
h+=`<div class="panel" style="max-height:none;margin-bottom:16px">
|
||
<h3 style="margin-bottom:16px">🏭 섹터별 AI 점수 순위</h3>
|
||
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
|
||
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
|
||
<th style="text-align:left;padding:8px 6px">섹터</th>
|
||
<th style="text-align:right;padding:8px 6px">종목수</th>
|
||
<th style="text-align:right;padding:8px 6px">평균점수</th>
|
||
<th style="text-align:right;padding:8px 6px">뉴스</th>
|
||
<th style="text-align:right;padding:8px 6px">기술</th>
|
||
<th style="text-align:right;padding:8px 6px">매수종목</th>
|
||
<th style="text-align:right;padding:8px 6px">매도종목</th>
|
||
</tr></thead><tbody>`;
|
||
sector.forEach((sec,i)=>{
|
||
const sc=sec.avg_score>=30?'#00E676':sec.avg_score<=-30?'#FF5252':'#78909C';
|
||
h+=`<tr style="border-bottom:1px solid rgba(255,255,255,0.04);${i%2?'background:rgba(255,255,255,0.01)':''}">
|
||
<td style="padding:9px 6px;font-weight:600;color:#CFD8DC">${esc(sec.sector)}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:#78909C">${sec.stock_count}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:${sc};font-weight:700">${sec.avg_score}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:#69F0AE">${sec.avg_news}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:#CE93D8">${sec.avg_tech}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:#00E676">${sec.buy_count}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:#FF8A80">${sec.sell_count}</td>
|
||
</tr>`;
|
||
});
|
||
h+=`</tbody></table></div></div>`;
|
||
}
|
||
|
||
// 최근 성과 이력
|
||
const recent=perf.recent||[];
|
||
if(recent.length){
|
||
h+=`<div class="panel" style="max-height:none">
|
||
<h3 style="margin-bottom:16px">📜 추천 성과 이력</h3>
|
||
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
|
||
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
|
||
<th style="text-align:left;padding:8px 6px">종목</th>
|
||
<th style="text-align:center;padding:8px 6px">추천등급</th>
|
||
<th style="text-align:right;padding:8px 6px">진입가</th>
|
||
<th style="text-align:right;padding:8px 6px">7일수익률</th>
|
||
<th style="text-align:right;padding:8px 6px">30일수익률</th>
|
||
<th style="text-align:right;padding:8px 6px">추천일</th>
|
||
</tr></thead><tbody>`;
|
||
recent.forEach((r,i)=>{
|
||
const rc=r.recommendation==='강력매수'?'#00E676':r.recommendation==='매수관심'?'#69F0AE':r.recommendation==='매도관심'?'#FF8A80':'#FF5252';
|
||
const r7c=(r.return_7d||0)>=0?'#00E676':'#FF5252';
|
||
const r30c=(r.return_30d||0)>=0?'#00E676':'#FF5252';
|
||
h+=`<tr data-stock="${r.stock_code}" data-stock-name="${esc(r.stock_name||'')}" style="border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer;${i%2?'background:rgba(255,255,255,0.01)':''}">
|
||
<td style="padding:9px 6px"><div style="font-weight:600;color:#CFD8DC">${esc(r.stock_name)}</div>
|
||
<div style="font-size:10px;color:#546E7A">${r.stock_code}</div></td>
|
||
<td style="text-align:center;padding:9px 6px"><span style="color:${rc};font-weight:700;font-size:11px">${esc(r.recommendation)}</span></td>
|
||
<td style="text-align:right;padding:9px 6px;color:#78909C;font-family:'JetBrains Mono',monospace">${fmt(r.entry_price)}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:${r7c};font-weight:700">
|
||
${r.return_7d!=null?(r.return_7d>=0?'+':'')+r.return_7d.toFixed(2)+'%':'-'}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:${r30c};font-weight:700">
|
||
${r.return_30d!=null?(r.return_30d>=0?'+':'')+r.return_30d.toFixed(2)+'%':'-'}</td>
|
||
<td style="text-align:right;padding:9px 6px;color:#546E7A;font-size:11px">${r.rec_date}</td>
|
||
</tr>`;
|
||
});
|
||
h+=`</tbody></table></div></div>`;
|
||
} else {
|
||
h+=`<div class="panel"><div class="empty">오늘부터 추천 성과가 기록됩니다. 7일 후 수익률이 표시됩니다.</div></div>`;
|
||
}
|
||
el.innerHTML=h;
|
||
}
|
||
|
||
async function renderPortfolio(){
|
||
const el=document.getElementById('portfolio');
|
||
const list=getPortfolio();
|
||
if(!list.length){
|
||
el.innerHTML='<div 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)})
|
||
T2 <b style="color:#00E676">${fmt(tg.t2)}</b>(${pct(tg.t2_pct)})
|
||
T3 <b style="color:#00BFA5">${fmt(tg.t3)}</b>(${pct(tg.t3_pct)})
|
||
</div>`:''}
|
||
${taSigs.length?`<div class="ta-row">${taSigs.map(sg=>`<span class="ta-badge">${esc(sg)}</span>`).join('')}</div>`:''}
|
||
${s.reason?`<div style="font-size:11px;color:#607D8B;margin-top:8px">${esc(s.reason.substring(0,120))}</div>`:''}
|
||
</div>`;
|
||
});
|
||
h+=`</div>`;
|
||
} else {
|
||
h+=`<div class="empty">최근 3일간 시그널이 없습니다. (장 중 5분마다 생성됩니다)</div>`;
|
||
}
|
||
h+=`</div>`;
|
||
document.getElementById('signals').innerHTML=h;
|
||
}
|
||
|
||
function getMarketTip(close, changePct){
|
||
if(!close) return null;
|
||
if(changePct >= 1.5) return {icon:'🚀', label:'강세장', color:'#00E676', text:'코스피가 강하게 상승 중입니다. 추세 추종 전략이 유효한 구간이지만, 과매수 지표(RSI 70↑)가 나타나면 신규 매수보다 기존 보유 종목 관리에 집중하세요.'};
|
||
if(changePct >= 0.5) return {icon:'📈', label:'완만한 상승', color:'#69F0AE', text:'시장이 우호적입니다. 실적 개선 종목과 외국인 순매수 종목을 중심으로 관심 가져볼 만한 구간입니다.'};
|
||
if(changePct <= -1.5) return {icon:'⚠️', label:'급락 주의', color:'#FF5252', text:'코스피가 급락 중입니다. 신규 매수보다 손절 라인 점검이 우선입니다. 시장 전반이 흔들릴 때는 현금 비중을 높이고 낙폭 과대 우량주 저점 매수 기회를 기다리세요.'};
|
||
if(changePct <= -0.5) return {icon:'📉', label:'약세 흐름', color:'#FF7043', text:'시장이 약세입니다. 실적 대비 낙폭이 과도한 종목을 발굴할 기회이기도 합니다. PBR 1 미만, ROE 10% 이상 종목을 관심 목록에 추가해 보세요.'};
|
||
if(close >= 2700) return {icon:'💹', label:'박스권 상단', color:'#FFC107', text:'코스피 2700 이상은 역사적 고평가 구간 진입 신호일 수 있습니다. 신규 진입보다 보유 종목 비중 조절과 이익 실현을 고려하세요.'};
|
||
if(close <= 2400) return {icon:'💎', label:'저점 구간', color:'#40C4FF', text:'코스피 2400 이하는 역사적으로 장기 투자자에게 좋은 진입 구간이었습니다. 우량 가치주를 분할 매수하는 전략이 유효합니다.'};
|
||
return {icon:'⚖️', label:'중립 구간', color:'#90A4AE', text:'코스피가 횡보 중입니다. 시장 전체보다 개별 종목의 실적과 모멘텀에 집중하는 보텀업 전략이 효과적인 구간입니다.'};
|
||
}
|
||
|
||
function renderOverview(s,ranking,recent,kospi,tip){
|
||
if(!s)return;
|
||
let h='';
|
||
|
||
// KOSPI 차트 + 시장 팁
|
||
if(kospi && kospi.data && kospi.data.length){
|
||
const cur=kospi.current||{};
|
||
const chg=parseFloat(cur.change_pct||0);
|
||
const chgColor=chg>=0?'#00E676':'#FF5252';
|
||
const chgSign=chg>=0?'+':'';
|
||
const mTip=getMarketTip(cur.close, chg);
|
||
h+=`<div class="kospi-panel">
|
||
<div class="kospi-header">
|
||
<div>
|
||
<div style="font-size:12px;color:#546E7A;font-weight:600;letter-spacing:1px;margin-bottom:6px">📊 KOSPI</div>
|
||
<div class="kospi-price" style="color:${chgColor}">${cur.close?Number(cur.close).toLocaleString('ko',{minimumFractionDigits:2}):'--'}</div>
|
||
<div class="kospi-change" style="color:${chgColor}">${chgSign}${chg.toFixed(2)}% ${chg>=0?'▲':'▼'} ${Math.abs(cur.change||0).toFixed(2)}</div>
|
||
</div>
|
||
<div style="text-align:right;font-size:11px;color:#546E7A">${cur.date||''} 기준</div>
|
||
</div>
|
||
<canvas id="kospiChart" height="80"></canvas>
|
||
${mTip?`<div class="market-tip-box" style="border-color:${mTip.color}33;background:${mTip.color}0D">
|
||
<div class="mtip-label" style="color:${mTip.color}">${mTip.icon} 시장 상황: ${mTip.label}</div>
|
||
<div class="mtip-body">${mTip.text}</div>
|
||
</div>`:''}
|
||
</div>`;
|
||
}
|
||
|
||
// 오늘의 투자 팁
|
||
if(tip && tip.title){
|
||
h+=`<div class="kospi-tip">
|
||
<div class="tip-tag">💡 오늘의 투자 지식 · ${tip.tag||''}</div>
|
||
<div class="tip-title">${esc(tip.title)}</div>
|
||
<div class="tip-body">${esc(tip.body)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 요약 카드
|
||
h+=`<div class="cards">
|
||
<div class="card"><div class="label">📰 분석 뉴스</div><div class="val">${s.total||0}</div><div class="sub">최근 7일</div></div>
|
||
<div class="card"><div class="label">▲ 호재</div><div class="val" style="color:#00E676">${s.positive||0}</div><div class="sub">${s.sentiment_ratio||50}%</div></div>
|
||
<div class="card"><div class="label">▼ 악재</div><div class="val" style="color:#FF5252">${s.negative||0}</div></div>
|
||
<div class="card"><div class="label">📋 DART 공시</div><div class="val">${s.dart||0}</div></div>
|
||
<div class="card"><div class="label">🏢 분석 종목</div><div class="val">${s.stocks_analyzed||0}</div></div>
|
||
</div><div class="grid2">`;
|
||
|
||
// 랭킹
|
||
h+=`<div class="panel"><h3>🏆 종목 랭킹</h3>`;
|
||
if(ranking&&ranking.length){
|
||
ranking.slice(0,15).forEach((item,i)=>{
|
||
const rc=RCOL[item.recommendation]||'#78909C';
|
||
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer"><div class="rank-num ${i<3?'top':'normal'}">${i+1}</div>
|
||
<div style="flex:1"><div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name)}</div><div style="font-size:10px;color:#546E7A">${item.stock_code}</div></div>
|
||
<div style="text-align:right"><div style="font-size:14px;font-weight:700;color:${rc}">${(item.total_score||0).toFixed(1)}</div><div style="font-size:10px;color:${rc}">${item.recommendation||'관망'}</div></div></div>`;
|
||
});
|
||
} else h+=`<div class="empty">데이터 수집 중...</div>`;
|
||
h+=`</div>`;
|
||
|
||
// 최근 뉴스
|
||
h+=`<div class="panel"><h3>📰 최근 분석</h3>`;
|
||
if(recent&&recent.length){
|
||
recent.slice(0,15).forEach(item=>{h+=newsItemHTML(item)});
|
||
} else h+=`<div class="empty">데이터 수집 중...</div>`;
|
||
h+=`</div></div>`;
|
||
|
||
document.getElementById('overview').innerHTML=h;
|
||
|
||
// KOSPI 차트 그리기
|
||
if(kospi && kospi.data && kospi.data.length && document.getElementById('kospiChart')){
|
||
const labels=kospi.data.map(d=>d.date.slice(5));
|
||
const closes=kospi.data.map(d=>d.close);
|
||
const last=closes[closes.length-1]||0;
|
||
const borderColor=last>=closes[0]?'#00E676':'#FF5252';
|
||
new Chart(document.getElementById('kospiChart'),{
|
||
type:'line',
|
||
data:{
|
||
labels,
|
||
datasets:[{
|
||
data:closes,
|
||
borderColor,
|
||
backgroundColor:borderColor+'18',
|
||
borderWidth:2,
|
||
fill:true,
|
||
tension:0.3,
|
||
pointRadius:0,
|
||
pointHoverRadius:4,
|
||
}]
|
||
},
|
||
options:{
|
||
responsive:true,
|
||
plugins:{legend:{display:false},tooltip:{
|
||
mode:'index',intersect:false,
|
||
callbacks:{label:ctx=>`KOSPI ${Number(ctx.parsed.y).toLocaleString('ko',{minimumFractionDigits:2})}`}
|
||
}},
|
||
scales:{
|
||
x:{grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},maxTicksLimit:10}},
|
||
y:{grid:{color:'rgba(255,255,255,0.04)'},ticks:{color:'#546E7A',font:{size:10},callback:v=>v.toLocaleString()},position:'right'}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function newsItemHTML(item){
|
||
const sc=SCOL[item.sentiment]||'#78909C', si=SICON[item.sentiment]||'●';
|
||
const titleHtml=item.url
|
||
?`<a href="${esc(item.url)}" target="_blank" rel="noopener" style="color:#CFD8DC;text-decoration:none" onmouseover="this.style.color='#00E676'" onmouseout="this.style.color='#CFD8DC'">${esc(item.title||'')}</a>`
|
||
:esc(item.title||'');
|
||
// 종목 코드가 있으면 클릭 가능한 칩으로
|
||
const stockChip = item.primary_stock
|
||
? `<span data-stock="${item.primary_stock}" style="color:#40C4FF;cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px">${esc(item.primary_stock)}</span>`
|
||
: `<span style="color:#546E7A">-</span>`;
|
||
return `<div class="news-item"><div class="news-icon"><div class="arrow" style="color:${sc}">${si}</div><div class="score" style="color:${sc}">${item.intensity||0}</div></div>
|
||
<div class="news-body"><div class="news-title">${titleHtml}</div>
|
||
<div class="news-meta">${stockChip}<span style="color:#546E7A">${esc(item.source||'')}</span><span style="color:#455A64">${ftime(item.analyzed_at)}</span></div>
|
||
${item.reason?`<div class="news-reason">${esc((item.reason||'').substring(0,100))}</div>`:''}</div>
|
||
<div class="badge" style="background:${sc}12;border:1px solid ${sc}30;color:${sc}">${item.investment_action||'관망'}</div></div>`;
|
||
}
|
||
|
||
function recCardHTML(item){
|
||
const rc=RCOL[item.recommendation]||'#78909C';
|
||
const pctVal=Math.min(100,Math.max(0,((item.total_score||0)+100)/2));
|
||
const bc=item.total_score>=30?'#00E676':item.total_score>=-30?'#78909C':'#FF5252';
|
||
return `<div class="rec-card" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer"><div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px">
|
||
<div><div style="font-size:15px;font-weight:700;color:#E0E0E0">${esc(item.stock_name)}</div><div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code}</div></div>
|
||
<div class="badge" style="background:${rc}15;border:1px solid ${rc}30;color:${rc}">${item.recommendation}</div></div>
|
||
<div style="display:flex;gap:10px;font-size:11px;color:#78909C;flex-wrap:wrap">
|
||
<span>종합 <b style="color:#E0E0E0;font-size:14px">${(item.total_score||0).toFixed(1)}</b></span>
|
||
<span>뉴스 <b style="color:#69F0AE">${(item.news_score||0).toFixed(0)}</b></span>
|
||
<span>기술 <b style="color:#CE93D8">${(item.technical_score||0).toFixed(0)}</b></span>
|
||
<span>공시 <b style="color:#40C4FF">${(item.dart_score||0).toFixed(0)}</b></span>
|
||
<span>외국인 <b style="color:${(item.foreign_score||0)>=0?'#00E676':'#FF5252'}">${(item.foreign_score||0).toFixed(0)}</b></span>
|
||
<span>공매도 <b style="color:${(item.short_score||0)>=0?'#78909C':'#FF8A80'}">${(item.short_score||0).toFixed(0)}</b></span></div>
|
||
${(item.foreign_ratio||0)>0?`<div style="font-size:10px;color:#546E7A;margin-top:4px">외국인보유 ${(item.foreign_ratio||0).toFixed(2)}% · 공매도비중 ${(item.short_weight||0).toFixed(2)}%</div>`:''}
|
||
|
||
<div class="score-bar"><div class="score-fill" style="width:${pctVal}%;background:${bc}"></div></div>
|
||
${item.top_reasons?`<div style="font-size:11px;color:#607D8B;line-height:1.5;border-top:1px solid rgba(255,255,255,0.04);padding-top:8px;margin-top:10px">${esc((item.top_reasons||'').split('|')[0].trim().substring(0,120))}...</div>`:''}</div>`;
|
||
}
|
||
|
||
function renderRecs(recs,avoid){
|
||
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">뉴스 감성 + DART 공시 + 가격 모멘텀 + 기술적 분석 종합 점수 (종목별 중복 제거)</div>`;
|
||
h+=`<h3 style="font-size:14px;font-weight:700;color:#69F0AE;margin-bottom:12px">🟢 매수 추천 종목</h3><div class="rec-grid">`;
|
||
if(recs&&recs.length){
|
||
recs.forEach(item=>{h+=recCardHTML(item)});
|
||
} else h+=`<div class="empty" style="grid-column:1/-1">추천 데이터가 아직 없습니다.</div>`;
|
||
h+=`</div>`;
|
||
|
||
h+=`<h3 style="font-size:14px;font-weight:700;color:#FF8A80;margin:28px 0 12px">⚠️ 매도관심 / 회피 종목</h3>`;
|
||
h+=`<div style="font-size:12px;color:#546E7A;margin-bottom:12px">악재 뉴스·공시가 누적된 종목입니다. 신규 매수보다 관망을 권장합니다.</div>`;
|
||
h+=`<div class="rec-grid">`;
|
||
if(avoid&&avoid.length){
|
||
avoid.forEach(item=>{h+=recCardHTML(item)});
|
||
} else h+=`<div class="empty" style="grid-column:1/-1">현재 회피 종목 없음</div>`;
|
||
h+=`</div>`;
|
||
|
||
document.getElementById('recommend').innerHTML=h;
|
||
}
|
||
|
||
let _newsOnlyStock = (localStorage.getItem('news_only_stock') ?? '1') === '1';
|
||
|
||
async function renderNews(recent){
|
||
// 토글 상태에 따라 다시 fetch (loadAll의 첫 호출은 only_stock=true 기본값)
|
||
const list = recent || await api('/recent?limit=80&only_stock='+(_newsOnlyStock?'true':'false'));
|
||
let h=`<div class="panel" style="max-height:none">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<h3 style="margin:0">${_newsOnlyStock?'📰 종목 관련 뉴스':'📰 전체 뉴스'}</h3>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:#90A4AE;cursor:pointer">
|
||
<input type="checkbox" id="news-only-stock" ${_newsOnlyStock?'checked':''} onchange="toggleNewsFilter(this.checked)" style="cursor:pointer">
|
||
종목 관련만 보기
|
||
</label>
|
||
</div>`;
|
||
if(list&&list.length){list.forEach(item=>{h+=newsItemHTML(item)})} else h+=`<div class="empty">뉴스 데이터 수집 중...</div>`;
|
||
h+=`</div>`;
|
||
document.getElementById('news').innerHTML=h;
|
||
}
|
||
|
||
async function toggleNewsFilter(checked){
|
||
_newsOnlyStock = checked;
|
||
localStorage.setItem('news_only_stock', checked?'1':'0');
|
||
const list = await api('/recent?limit=80&only_stock='+(checked?'true':'false'));
|
||
renderNews(list);
|
||
}
|
||
|
||
function renderAlerts(alerts){
|
||
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">강도 3 이상 뉴스/공시 (최근 24시간) · 종목코드 클릭 시 차트 모달이 열립니다</div>`;
|
||
if(alerts&&alerts.length){
|
||
alerts.forEach(item=>{
|
||
const sc=SCOL[item.sentiment]||'#78909C', si=SICON[item.sentiment]||'●';
|
||
const stockChip = item.primary_stock
|
||
? `<span data-stock="${item.primary_stock}" style="color:#40C4FF;cursor:pointer;font-family:'JetBrains Mono',monospace;background:rgba(64,196,255,0.08);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:6px">${esc(item.primary_stock)}</span>`
|
||
: '';
|
||
h+=`<div class="alert-item" style="background:${sc}08;border:1px solid ${sc}20">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<div style="font-size:13px;font-weight:600;color:${sc}">${si} ${item.sentiment} (강도 ${item.intensity})</div>
|
||
<div style="font-size:10px;color:#546E7A">${ftime(item.analyzed_at)}</div></div>
|
||
<div style="font-size:12px;color:#CFD8DC;margin-top:6px;line-height:1.4">${stockChip}${esc(item.title||'')}</div>
|
||
<div style="font-size:11px;color:#78909C;margin-top:4px">${esc((item.reason||'').substring(0,100))}</div></div>`;
|
||
});
|
||
} else h+=`<div class="panel"><div class="empty">현재 긴급 알림이 없습니다.</div></div>`;
|
||
document.getElementById('alerts').innerHTML=h;
|
||
}
|
||
|
||
async function renderSupplyDemand(){
|
||
const el=document.getElementById('supplydemand');
|
||
el.innerHTML='<div class="empty">로딩 중...</div>';
|
||
const [supplyData,shortData]=await Promise.all([api('/supply-demand?limit=20'),api('/short-ranking?limit=20')]);
|
||
let h=`<div style="font-size:12px;color:#546E7A;margin-bottom:16px">키움증권 API 기반 외국인/기관 수급 + 공매도 현황 (장 종료 후 17시 갱신)</div>`;
|
||
h+=`<div class="grid2">`;
|
||
|
||
// 외국인 순매수 상위
|
||
h+=`<div class="panel"><h3>🌏 외국인 순매수 상위 (5일 누적)</h3>`;
|
||
if(supplyData&&supplyData.length){
|
||
supplyData.slice(0,15).forEach((item,i)=>{
|
||
const net=item.net_5d||0;
|
||
const col=net>=0?'#00E676':'#FF5252';
|
||
const fscore=(item.foreign_score||0).toFixed(1);
|
||
const fscoreCol=(item.foreign_score||0)>=0?'#69F0AE':'#FF8A80';
|
||
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer">
|
||
<div class="rank-num ${i<3?'top':'normal'}">${i+1}</div>
|
||
<div style="flex:1">
|
||
<div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name||item.stock_code)}</div>
|
||
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code} · 보유 ${(item.hold_ratio||0).toFixed(2)}%</div>
|
||
</div>
|
||
<div style="text-align:right">
|
||
<div style="font-size:13px;font-weight:700;color:${col};font-family:'JetBrains Mono',monospace">${net>=0?'+':''}${Number(net).toLocaleString()}주</div>
|
||
<div style="font-size:10px;color:${fscoreCol}">수급점수 ${fscore}</div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
} else h+=`<div class="empty">외국인 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>`;
|
||
h+=`</div>`;
|
||
|
||
// 공매도 비중 상위
|
||
h+=`<div class="panel"><h3>📉 공매도 비중 상위 (최근일 기준)</h3>`;
|
||
if(shortData&&shortData.length){
|
||
shortData.slice(0,15).forEach((item,i)=>{
|
||
const w=item.trade_weight||0;
|
||
const col=w>=5?'#FF5252':w>=2?'#FF8A80':'#78909C';
|
||
const sscore=(item.short_score||0).toFixed(1);
|
||
const sscoreCol=(item.short_score||0)>=0?'#78909C':'#FF8A80';
|
||
h+=`<div class="rank-item" data-stock="${item.stock_code}" data-stock-name="${esc(item.stock_name||'')}" style="cursor:pointer">
|
||
<div class="rank-num ${i<3?'top':'normal'}" style="${i<3?'background:rgba(255,82,82,0.12);color:#FF5252':''}">${i+1}</div>
|
||
<div style="flex:1">
|
||
<div style="font-size:13px;font-weight:600;color:#CFD8DC">${esc(item.stock_name||item.stock_code)}</div>
|
||
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${item.stock_code} · 잔고 ${Number(item.balance||0).toLocaleString()}주</div>
|
||
</div>
|
||
<div style="text-align:right">
|
||
<div style="font-size:13px;font-weight:700;color:${col}">${w.toFixed(2)}%</div>
|
||
<div style="font-size:10px;color:${sscoreCol}">공매도점수 ${sscore}</div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
} else h+=`<div class="empty">공매도 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>`;
|
||
h+=`</div>`;
|
||
|
||
h+=`</div>`;
|
||
|
||
// 설명 카드
|
||
h+=`<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:14px;padding:20px;margin-top:16px">
|
||
<div style="font-size:13px;font-weight:700;color:#90A4AE;margin-bottom:12px">📌 수급 점수 해석</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;font-size:12px;color:#78909C;line-height:1.8">
|
||
<div>
|
||
<div style="color:#69F0AE;font-weight:600;margin-bottom:4px">외국인 점수 (+)</div>
|
||
<div>순매수 연속 3일 이상 → +40</div>
|
||
<div>보유비율 5일 상승 추세 → +40</div>
|
||
<div>절대 보유비율 10%↑ → +20</div>
|
||
</div>
|
||
<div>
|
||
<div style="color:#FF8A80;font-weight:600;margin-bottom:4px">공매도 점수 (-)</div>
|
||
<div>공매도 비중 10%↑ → -60</div>
|
||
<div>공매도 비중 5~10% → -35</div>
|
||
<div>잔고 5일 20%↑ → -20</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
el.innerHTML=h;
|
||
}
|
||
|
||
setInterval(()=>{const c=document.getElementById('clock');if(c)c.textContent=new Date().toLocaleTimeString('ko-KR')},1000);
|
||
|
||
// 인증 후에만 동작하는 자동 새로고침
|
||
let _refreshTimer=null;
|
||
function startAutoRefresh(){if(_refreshTimer)return;_refreshTimer=setInterval(()=>{if(isLoggedIn())loadAll();},30000);}
|
||
function stopAutoRefresh(){if(_refreshTimer){clearInterval(_refreshTimer);_refreshTimer=null;}}
|
||
|
||
// 페이지 로드: 토큰 검증 → 대시보드 또는 로그인
|
||
(async()=>{
|
||
if(!isLoggedIn()){showLoginPage();return;}
|
||
try{
|
||
const r=await fetch(API+'/auth/me',{headers:authHeaders()});
|
||
if(!r.ok){clearAuth();showLoginPage();return;}
|
||
const me=await r.json();
|
||
setAuth(_authToken,me); // role 포함 갱신
|
||
showDashboard();
|
||
await Promise.all([loadPortfolio(), loadAll()]);
|
||
startAutoRefresh();
|
||
setTimeout(maybeOfferImport,500);
|
||
}catch(e){clearAuth();showLoginPage();}
|
||
})();
|
||
|
||
// 로그인 직후 자동 새로고침 시작
|
||
const _origShowDashboard=showDashboard;
|
||
showDashboard=function(){_origShowDashboard();startAutoRefresh();};
|
||
const _origLogout=logoutUser;
|
||
logoutUser=async function(){stopAutoRefresh();await _origLogout();};
|
||
async function doSearch(){
|
||
const q=document.getElementById('searchInput').value.trim();
|
||
if(!q){document.getElementById('searchResults').style.display='none';return}
|
||
const code6=q.match(/^\d{6}$/)?q:'';
|
||
const [news,sigs,ta]=await Promise.all([
|
||
api('/search?q='+encodeURIComponent(q)),
|
||
fetch(API.replace('8989','8585')+'/signals/'+q).then(r=>r.json()).catch(()=>[]),
|
||
code6?api('/technical/'+code6):Promise.resolve(null)
|
||
]);
|
||
let h=`<div class="panel" style="max-height:none"><h3>🔍 "${esc(q)}" 검색 결과</h3>`;
|
||
|
||
// 기술적 분석 상세
|
||
if(ta&&ta.indicators){
|
||
const ind=ta.indicators, tg=ta.targets||{};
|
||
const sc=ta.signal==='매수'?'#00E676':ta.signal==='매도'?'#FF5252':'#78909C';
|
||
h+=`<div style="background:${sc}06;border:1px solid ${sc}20;border-radius:12px;padding:16px;margin-bottom:16px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<h4 style="font-size:14px;color:${sc}">📊 기술적 분석 (${ta.signal||'관망'})</h4>
|
||
<span style="font-size:12px;color:#78909C">기술점수 <b style="color:${sc};font-size:16px">${(ta.tech_score||0).toFixed(1)}</b></span>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;font-size:12px;margin-bottom:12px">
|
||
<div style="color:#78909C">현재가 <b style="color:#E0E0E0">${fmt(ind.price)}</b></div>
|
||
<div style="color:#78909C">MA5 <b style="color:${ind.price>ind.ma5?'#69F0AE':'#FF8A80'}">${fmt(ind.ma5)}</b></div>
|
||
<div style="color:#78909C">MA20 <b style="color:${ind.price>ind.ma20?'#69F0AE':'#FF8A80'}">${fmt(ind.ma20)}</b></div>
|
||
<div style="color:#78909C">MA60 <b style="color:${ind.price>ind.ma60?'#69F0AE':'#FF8A80'}">${fmt(ind.ma60)}</b></div>
|
||
<div style="color:#78909C">RSI <b style="color:${ind.rsi<40?'#69F0AE':ind.rsi>70?'#FF8A80':'#E0E0E0'}">${(ind.rsi||0).toFixed(1)}</b></div>
|
||
<div style="color:#78909C">MACD <b style="color:${(ind.macd_hist||0)>0?'#69F0AE':'#FF8A80'}">${(ind.macd_hist||0).toFixed(3)}</b></div>
|
||
<div style="color:#78909C">볼밴%B <b style="color:#FFD740">${((ind.pct_b||0.5)*100).toFixed(0)}%</b></div>
|
||
<div style="color:#78909C">거래량비 <b style="color:#40C4FF">${(ind.vol_ratio||1).toFixed(1)}x</b></div>
|
||
</div>
|
||
${tg.t1?`<div class="price-row">
|
||
<div class="price-box"><div class="label">진입 추천가</div><div class="val" style="color:#40C4FF">${fmt(tg.entry_price)}</div></div>
|
||
<div class="price-box"><div class="label">목표1 (${pct(tg.t1_pct)})</div><div class="val" style="color:#69F0AE">${fmt(tg.t1)}</div></div>
|
||
<div class="price-box"><div class="label">목표2 (${pct(tg.t2_pct)})</div><div class="val" style="color:#00E676">${fmt(tg.t2)}</div></div>
|
||
<div class="price-box"><div class="label">손절가 (${pct(tg.stop_pct)})</div><div class="val" style="color:#FF8A80">${fmt(tg.stop_loss)}</div></div>
|
||
</div>`:''}
|
||
<div class="ta-row" style="margin-top:10px">${(ta.signals||[]).map(s=>`<span class="ta-badge">${esc(s)}</span>`).join('')}</div>
|
||
</div>`;
|
||
}
|
||
|
||
if(sigs&&sigs.length){
|
||
h+=`<div style="margin-bottom:16px"><h4 style="font-size:13px;color:#69F0AE;margin-bottom:8px">💰 매매 시그널</h4>`;
|
||
sigs.forEach(s=>{
|
||
const sc=s.signal_type==='매수'?'#00E676':'#FF5252';
|
||
h+=`<div data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name||'')}" style="background:${sc}08;border:1px solid ${sc}20;border-radius:10px;padding:12px;margin-bottom:8px;cursor:pointer">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:14px;font-weight:700;color:${sc}">${s.signal_type} ${esc(s.stock_name)} (${s.stock_code})</span>
|
||
<span style="font-size:12px;color:#78909C">신뢰도 ${(s.confidence||0).toFixed(1)}%</span></div>
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;font-size:12px">
|
||
<div style="color:#78909C">현재가 <b style="color:#E0E0E0">${fmt(s.current_price)}원</b></div>
|
||
<div style="color:#78909C">목표가 <b style="color:#69F0AE">${fmt(s.target_price)}원</b></div>
|
||
<div style="color:#78909C">손절가 <b style="color:#FF8A80">${fmt(s.stop_loss)}원</b></div>
|
||
<div style="color:#78909C">예상수익 <b style="color:#FFD740">${pct(s.expected_return_pct)}</b></div></div>
|
||
${s.reason?`<div style="font-size:11px;color:#607D8B;margin-top:8px">${esc(s.reason.substring(0,150))}</div>`:''}</div>`;
|
||
});
|
||
h+=`</div>`;
|
||
}
|
||
|
||
if(news&&news.length){
|
||
h+=`<h4 style="font-size:13px;color:#90A4AE;margin:12px 0 8px">📰 관련 뉴스/공시 (${news.length}건)</h4>`;
|
||
news.forEach(item=>{h+=newsItemHTML(item)});
|
||
} else if(!sigs||!sigs.length){
|
||
h+=`<div class="empty">검색 결과가 없습니다.</div>`;
|
||
}
|
||
h+=`</div>`;
|
||
const el=document.getElementById('searchResults');
|
||
el.innerHTML=h;el.style.display='block';
|
||
// 검색 결과가 종목코드면 차트 자동 열기 옵션
|
||
if(code6){
|
||
const ctaBtn = `<div style="margin-bottom:12px"><button onclick="openStockChart('${code6}')" style="background:linear-gradient(135deg,#00C853,#00897B);border:none;border-radius:8px;padding:8px 18px;color:#fff;font-size:12px;font-weight:700;cursor:pointer">📊 ${code6} 차트 모달 열기</button></div>`;
|
||
el.querySelector('.panel').insertAdjacentHTML('afterbegin', ctaBtn);
|
||
}
|
||
}
|
||
|
||
// ── 챗봇 ──────────────────────────────────────────────────
|
||
let chatHistory=[], chatOpen=false;
|
||
|
||
function toggleChat(){
|
||
chatOpen=!chatOpen;
|
||
const p=document.getElementById('chat-panel');
|
||
p.style.display=chatOpen?'flex':'none';
|
||
if(chatOpen) document.getElementById('chat-input').focus();
|
||
}
|
||
|
||
function chatKey(e){
|
||
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();}
|
||
}
|
||
|
||
function mdToHtml(t){
|
||
return t
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/`(.+?)`/g,'<code>$1</code>')
|
||
.replace(/^### (.+)$/gm,'<strong>$1</strong>')
|
||
.replace(/^## (.+)$/gm,'<strong>$1</strong>')
|
||
.replace(/^- (.+)$/gm,'• $1')
|
||
.replace(/\n/g,'<br>');
|
||
}
|
||
|
||
async function sendChat(){
|
||
const input=document.getElementById('chat-input');
|
||
const msg=input.value.trim();
|
||
if(!msg) return;
|
||
input.value='';
|
||
|
||
const msgs=document.getElementById('chat-messages');
|
||
|
||
// 유저 메시지
|
||
msgs.innerHTML+=`<div class="msg user">${msg.replace(/</g,'<')}</div>`;
|
||
|
||
// 타이핑 표시
|
||
const typingId='typing-'+Date.now();
|
||
msgs.innerHTML+=`<div class="typing" id="${typingId}"><span></span><span></span><span></span></div>`;
|
||
msgs.scrollTop=msgs.scrollHeight;
|
||
|
||
document.getElementById('chat-send').disabled=true;
|
||
|
||
chatHistory.push({role:'user',content:msg});
|
||
|
||
// AI 답변 버블 생성
|
||
const aiId='ai-'+Date.now();
|
||
let fullText='';
|
||
|
||
try{
|
||
const resp=await fetch('/api/chat',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({message:msg, history:chatHistory.slice(-10)})
|
||
});
|
||
|
||
const reader=resp.body.getReader();
|
||
const dec=new TextDecoder();
|
||
let buf='';
|
||
|
||
// 타이핑 제거, AI 버블 추가
|
||
document.getElementById(typingId)?.remove();
|
||
msgs.innerHTML+=`<div class="msg ai" id="${aiId}"></div>`;
|
||
|
||
while(true){
|
||
const{done,value}=await reader.read();
|
||
if(done) break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
const lines=buf.split('\n');
|
||
buf=lines.pop();
|
||
for(const line of lines){
|
||
if(!line.startsWith('data: ')) continue;
|
||
const payload=line.slice(6);
|
||
if(payload==='[DONE]') break;
|
||
try{
|
||
const delta=JSON.parse(payload).content||'';
|
||
fullText+=delta;
|
||
document.getElementById(aiId).innerHTML=mdToHtml(fullText);
|
||
msgs.scrollTop=msgs.scrollHeight;
|
||
}catch{}
|
||
}
|
||
}
|
||
}catch(e){
|
||
document.getElementById(typingId)?.remove();
|
||
msgs.innerHTML+=`<div class="msg ai" style="color:#FF8A80">오류가 발생했습니다. 다시 시도해주세요.</div>`;
|
||
}
|
||
|
||
if(fullText) chatHistory.push({role:'assistant',content:fullText});
|
||
document.getElementById('chat-send').disabled=false;
|
||
msgs.scrollTop=msgs.scrollHeight;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// 🧮 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>
|