법률앱에서 `$\rightarrow$`가 날것으로 보인다는 신고. 통화 `$` 문제(f06955f)를
먼저 의심했지만 원인은 더 단순했다 — **앱 페이지들은 KaTeX를 아예 로드하지
않고 있었다.**
index.html katex O
lawyer / detective / doctor / investor / accountant / mind / weather 전부 X
메인 채팅에만 chat-render.js의 renderKatexIn이 있었고, 앱 7개는 각자 자기
renderMd를 갖고 있어 수식이 통과할 길이 없었다. 공용 js/app/katex-inline.js로
빼서 전부에 붙였다(통화 `$` 보호 로직 포함).
addMsg에서 DOM 삽입 직후 호출한다. innerHTML은 <script>를 실행하지 않으므로
삽입 시점마다 명시적으로 불러야 한다. TTS 버튼이 innerText를 읽어가므로
그보다 먼저 렌더링되게 배치했다 — 안 그러면 음성이 "달러 백슬래시 rightarrow
달러"를 읽는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
859 lines
57 KiB
HTML
859 lines
57 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>📈 투자 어시스턴트</title>
|
||
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
||
<link rel="stylesheet" href="../css/styles.css">
|
||
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
|
||
<style>
|
||
html,body{height:100%;margin:0;padding:0;}
|
||
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
||
|
||
/* ── Header ── */
|
||
.iv-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.iv-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
||
.iv-hdr-sp{flex:1;}
|
||
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
|
||
.hdr-btn:hover{border-color:#22c55e;color:#22c55e;}
|
||
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.25);color:#4ade80;font-weight:700;}
|
||
|
||
/* ── Layout ── */
|
||
.iv-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
|
||
|
||
/* ── Sidebar ── */
|
||
.iv-sb{width:190px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;overflow-y:auto;}
|
||
.iv-sb::-webkit-scrollbar{width:3px;}
|
||
.iv-sb::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.iv-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;flex-shrink:0;display:flex;align-items:center;}
|
||
.iv-sb-sec span{flex:1;}
|
||
.iv-add-btn{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:0 4px;transition:.12s;line-height:1;}
|
||
.iv-add-btn:hover{color:#22c55e;}
|
||
.iv-group{padding:0 6px 4px;}
|
||
.iv-ticker{display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:6px;cursor:pointer;transition:.12s;border:1px solid transparent;}
|
||
.iv-ticker:hover{background:rgba(34,197,94,.05);border-color:rgba(34,197,94,.15);}
|
||
.iv-ticker:hover .iv-t-actions{opacity:1;}
|
||
.iv-ticker.active{background:rgba(34,197,94,.08);border-color:rgba(34,197,94,.25);}
|
||
.iv-t-info{flex:1;min-width:0;overflow:hidden;}
|
||
.iv-t-code{font-size:11px;font-weight:700;color:var(--text);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.iv-t-name{font-size:10px;color:var(--muted);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.iv-ticker.active .iv-t-name{color:#4ade80;}
|
||
.iv-t-actions{display:flex;gap:1px;opacity:0;transition:.12s;flex-shrink:0;}
|
||
.iv-t-act{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:2px 3px;border-radius:3px;transition:.12s;line-height:1;}
|
||
.iv-t-act:hover{color:#22c55e;background:rgba(34,197,94,.1);}
|
||
.iv-t-act.del:hover{color:#ef4444;background:rgba(239,68,68,.1);}
|
||
|
||
/* ── Inline add/edit form ── */
|
||
.iv-ticker-form{padding:5px 6px;border:1px solid rgba(34,197,94,.3);border-radius:7px;background:rgba(34,197,94,.04);margin:2px 6px;}
|
||
.iv-tf-row{display:flex;gap:4px;margin-bottom:4px;align-items:center;}
|
||
.iv-tf-input{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;outline:none;min-width:0;}
|
||
.iv-tf-input:focus{border-color:#22c55e;}
|
||
.iv-tf-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:10px;color:var(--text);font-family:inherit;}
|
||
.iv-tf-btns{display:flex;gap:4px;}
|
||
.iv-tf-save{background:#14532d;border:none;border-radius:5px;padding:3px 9px;font-size:11px;font-weight:700;color:#fff;cursor:pointer;font-family:inherit;}
|
||
.iv-tf-cancel{background:none;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
|
||
|
||
.iv-divider{height:1px;background:var(--line);margin:4px 6px;}
|
||
.iv-quick-section{padding:0 6px 6px;}
|
||
.iv-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;width:100%;margin-bottom:3px;line-height:1.4;}
|
||
.iv-qbtn:hover{border-color:#22c55e;color:#22c55e;background:rgba(34,197,94,.04);}
|
||
.iv-disclaimer{margin:6px;padding:7px 9px;background:rgba(234,179,8,.05);border:1px solid rgba(234,179,8,.15);border-radius:7px;font-size:10px;color:var(--muted);line-height:1.6;flex-shrink:0;}
|
||
|
||
/* ── Center ── */
|
||
.iv-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;}
|
||
|
||
/* Chart search bar */
|
||
.iv-chart-bar{padding:7px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
|
||
.iv-chart-type{display:flex;gap:0;border:1px solid var(--line);border-radius:7px;overflow:hidden;flex-shrink:0;}
|
||
.iv-ct-btn{padding:4px 11px;font-size:11px;font-weight:700;cursor:pointer;background:none;border:none;color:var(--muted);font-family:inherit;transition:.12s;}
|
||
.iv-ct-btn.active{background:#052e16;color:#4ade80;}
|
||
[data-theme="light"] .iv-ct-btn.active{background:#dcfce7;color:#15803d;}
|
||
.iv-sym-input{background:var(--panel-2);border:1.5px solid var(--line);border-radius:7px;padding:5px 10px;font-size:12px;color:var(--text);font-family:inherit;outline:none;width:120px;transition:.15s;}
|
||
.iv-sym-input:focus{border-color:#22c55e;}
|
||
.iv-period-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 7px;font-size:11px;color:var(--text);font-family:inherit;}
|
||
.iv-load-btn{background:#14532d;border:none;border-radius:7px;padding:5px 13px;font-size:12px;font-weight:700;color:#fff;cursor:pointer;font-family:inherit;transition:.15s;white-space:nowrap;}
|
||
.iv-load-btn:hover{background:#166534;}
|
||
.iv-chart-title{font-size:11px;color:var(--muted);flex:1;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
|
||
/* Chart area */
|
||
.iv-chart-area{flex:1;position:relative;display:flex;flex-direction:column;overflow:hidden;min-height:0;}
|
||
.iv-chart-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;text-align:center;padding:40px;}
|
||
.iv-chart-empty .icon{font-size:52px;opacity:.28;}
|
||
.iv-chart-empty p{font-size:12px;line-height:1.7;max-width:280px;}
|
||
#tv-container{flex:1;width:100%;min-height:0;display:none;}
|
||
#ks-container{flex:1;width:100%;min-height:0;overflow:auto;display:none;align-items:center;justify-content:center;background:var(--panel);}
|
||
#ks-container img{max-width:100%;max-height:100%;object-fit:contain;border-radius:4px;}
|
||
#ks-container.visible,#tv-container.visible{display:flex;}
|
||
|
||
/* ── Resize ── */
|
||
.iv-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
|
||
.iv-resize:hover,.iv-resize.dragging{background:#22c55e;}
|
||
|
||
/* ── Chat ── */
|
||
.iv-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
|
||
.iv-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
|
||
.iv-chat-label{font-size:10px;font-weight:700;color:#4ade80;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
|
||
.iv-model-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;min-width:0;}
|
||
.iv-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
|
||
.iv-chat-clear:hover{color:#ef4444;}
|
||
.iv-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
|
||
.iv-msgs::-webkit-scrollbar{width:3px;}
|
||
.iv-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.iv-msg{display:flex;flex-direction:column;}
|
||
.iv-msg.user{align-items:flex-end;}
|
||
.iv-msg.assistant{align-items:flex-start;}
|
||
.iv-msg.system{align-items:center;}
|
||
.iv-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
|
||
.iv-msg.user .iv-bubble{background:#052e16;color:#dcfce7;border-radius:10px 10px 3px 10px;}
|
||
.iv-msg.assistant .iv-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
|
||
.iv-msg.system .iv-bubble{background:transparent;color:var(--muted);font-size:11px;}
|
||
.iv-bubble p{margin:3px 0;}
|
||
.iv-bubble h3{font-size:13px;margin:6px 0 3px;}
|
||
.iv-bubble ul,.iv-bubble ol{padding-left:16px;margin:3px 0;}
|
||
.iv-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
|
||
.iv-bubble strong{font-weight:700;}
|
||
.iv-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
|
||
.iv-bubble th,.iv-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
|
||
.iv-bubble th{background:var(--panel);font-weight:700;}
|
||
/* chart link in chat */
|
||
.iv-chart-link{display:inline-block;margin:4px 0;padding:4px 10px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.25);border-radius:6px;font-size:11px;color:#4ade80;cursor:pointer;}
|
||
.iv-chart-link:hover{background:rgba(34,197,94,.18);}
|
||
.iv-thinking{padding:4px 10px;border-radius:6px;font-size:10px;color:var(--muted);background:var(--bg);border:1px solid var(--line);font-style:italic;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;cursor:pointer;margin-bottom:4px;}
|
||
.iv-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
|
||
.iv-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
|
||
.iv-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:ivDot 1.2s ease-in-out infinite;}
|
||
.iv-dot:nth-child(2){animation-delay:.2s;}.iv-dot:nth-child(3){animation-delay:.4s;}
|
||
@keyframes ivDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
|
||
.iv-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
|
||
.iv-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
|
||
.iv-textarea:focus{border-color:#22c55e;}
|
||
.iv-send{background:#14532d;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
|
||
.iv-send:hover:not(:disabled){background:#166534;}
|
||
.iv-send:disabled{opacity:.5;cursor:not-allowed;}
|
||
|
||
/* ── Mobile ── */
|
||
@media(max-width:1024px){
|
||
.iv-resize{display:none;}
|
||
.iv-sb{
|
||
position:fixed;top:0;left:0;bottom:52px;z-index:500;
|
||
width:80vw;max-width:300px;
|
||
transform:translateX(-100%);transition:transform .25s ease;
|
||
box-shadow:4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.iv-sb.mob-open{transform:translateX(0);}
|
||
.iv-chat{
|
||
position:fixed;top:0;right:0;bottom:52px;z-index:500;
|
||
width:92vw;max-width:380px;
|
||
transform:translateX(100%);transition:transform .25s ease;
|
||
box-shadow:-4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.iv-chat.mob-open{transform:translateX(0);}
|
||
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.5);z-index:499;}
|
||
#mob-backdrop.show{display:block;}
|
||
.iv-center{width:100%;}
|
||
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid var(--line);height:52px;}
|
||
.iv-textarea,input,textarea{font-size:16px !important;}
|
||
}
|
||
@media(min-width:1025px){
|
||
#mob-tabbar{display:none !important;}
|
||
#mob-backdrop{display:none !important;}
|
||
}
|
||
</style>
|
||
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
|
||
<script src="/vendor/katex/katex.min.js"></script>
|
||
<script src="/vendor/katex/auto-render.min.js"></script>
|
||
<script src="/vendor/katex/mhchem.min.js"></script>
|
||
<script src="../js/app/katex-inline.js"></script>
|
||
<script src="../js/voice/voice-utils.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="iv-hdr">
|
||
<a href="/" class="hdr-btn">← 홈</a>
|
||
<h1>📈 투자 어시스턴트</h1>
|
||
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
|
||
<div class="iv-hdr-sp"></div>
|
||
<button class="hdr-btn" onclick="toggleTheme()">🌙 테마</button>
|
||
</header>
|
||
|
||
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
|
||
|
||
<div id="mob-tabbar" style="display:none">
|
||
<button class="mob-tab" id="mobtab-stocks" onclick="mobToggle('stocks')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>
|
||
<span>종목</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#22c55e);border-top:2px solid var(--brand,#22c55e)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><rect x="2" y="3" width="20" height="14" rx="2"/><polyline points="8 21 12 17 16 21"/></svg>
|
||
<span>차트</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-chat" onclick="mobToggle('chat')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
<span>AI</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="iv-layout">
|
||
|
||
<!-- ── Sidebar ── -->
|
||
<aside class="iv-sb">
|
||
<div class="iv-sb-sec"><span>🇰🇷 코스피/코스닥</span><button class="iv-add-btn" onclick="showAddForm('kr')" title="종목 추가">+</button></div>
|
||
<div id="kr-group"></div>
|
||
<div class="iv-divider"></div>
|
||
<div class="iv-sb-sec"><span>🌍 글로벌</span><button class="iv-add-btn" onclick="showAddForm('tv')" title="종목 추가">+</button></div>
|
||
<div id="tv-group"></div>
|
||
<div class="iv-divider"></div>
|
||
<div class="iv-sb-sec"><span>빠른 질문</span></div>
|
||
<div class="iv-quick-section">
|
||
<button class="iv-qbtn" onclick="sendQuick('현재 글로벌 경제 상황과 주요 투자 리스크를 분석해주세요.')">🌐 거시경제 분석</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('삼성전자(005930) 기업 분석과 밸류에이션을 해주세요.')">🔬 기업 분석</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('안정적인 장기 투자 포트폴리오 구성 방법을 알려주세요.')">💼 포트폴리오 구성</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('ETF 투자 시 핵심 체크포인트와 추천 유형을 알려주세요.')">📊 ETF 투자</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('DCF 밸류에이션 계산 방법을 예시와 함께 알려주세요.')">🧮 DCF 계산</button>
|
||
</div>
|
||
<div class="iv-disclaimer">⚠️ 정보 제공 목적이며 <strong>투자 권유가 아닙니다.</strong> 투자 결정은 본인 책임이며 손실 위험이 있습니다.</div>
|
||
</aside>
|
||
|
||
<!-- ── Center chart ── -->
|
||
<section class="iv-center">
|
||
<!-- Chart bar -->
|
||
<div class="iv-chart-bar">
|
||
<div class="iv-chart-type">
|
||
<button class="iv-ct-btn active" id="ct-kr" onclick="setChartType('kr')">🇰🇷 한국</button>
|
||
<button class="iv-ct-btn" id="ct-tv" onclick="setChartType('tv')">🌍 글로벌</button>
|
||
</div>
|
||
<input class="iv-sym-input" id="sym-input" type="text" placeholder="005930 또는 NASDAQ:AAPL" onkeydown="if(event.key==='Enter')loadChartFromInput()">
|
||
<select class="iv-period-sel" id="period-sel">
|
||
<option value="day">일봉</option>
|
||
<option value="week">주봉</option>
|
||
<option value="month">월봉</option>
|
||
<option value="3month">3개월</option>
|
||
<option value="year">1년</option>
|
||
<option value="3year">3년</option>
|
||
</select>
|
||
<button class="iv-load-btn" onclick="loadChartFromInput()">차트 보기</button>
|
||
<span class="iv-chart-title" id="chart-title"></span>
|
||
</div>
|
||
|
||
<!-- Chart display -->
|
||
<div class="iv-chart-area">
|
||
<div class="iv-chart-empty" id="chart-empty">
|
||
<div class="icon">📈</div>
|
||
<p>왼쪽 종목을 클릭하거나<br>종목코드를 입력해 차트를 불러오세요.<br><br>AI에게 종목 분석을 요청하면<br>차트가 자동으로 열립니다.</p>
|
||
</div>
|
||
<div id="tv-container"></div>
|
||
<div id="ks-container"><img id="ks-img" src="" alt=""></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Resize ── -->
|
||
<div class="iv-resize" id="resize-handle"></div>
|
||
|
||
<!-- ── Chat ── -->
|
||
<div class="iv-chat" id="chat-panel">
|
||
<div class="iv-chat-hdr">
|
||
<span class="iv-chat-label">📈 투자 AI</span>
|
||
<select class="iv-model-sel" id="model-sel" onchange="saveModel()">
|
||
<option value="">기본 모델</option>
|
||
</select>
|
||
<button class="iv-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
||
</div>
|
||
<div class="iv-msgs" id="chat-msgs"></div>
|
||
<div class="iv-input-bar">
|
||
<textarea class="iv-textarea" id="chat-input" rows="1" placeholder="종목 분석, 포트폴리오, 경제지표 등을 질문하세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
|
||
<button class="iv-send" id="send-btn" onclick="sendMessage()">전송</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ── Watchlist ─────────────────────────────────────────────────────────────────
|
||
const WL_KR_KEY='iv_wl_kr';
|
||
const WL_TV_KEY='iv_wl_tv';
|
||
const DEFAULT_KR=[
|
||
{code:'005930',name:'삼성전자',period:'day'},
|
||
{code:'000660',name:'SK하이닉스',period:'day'},
|
||
{code:'005380',name:'현대자동차',period:'day'},
|
||
{code:'035420',name:'NAVER',period:'day'},
|
||
{code:'373220',name:'LG에너지솔루션',period:'day'},
|
||
{code:'035720',name:'카카오',period:'day'},
|
||
];
|
||
const DEFAULT_TV=[
|
||
{sym:'NASDAQ:AAPL',name:'Apple',iv:'D'},
|
||
{sym:'NASDAQ:NVDA',name:'NVIDIA',iv:'D'},
|
||
{sym:'NASDAQ:TSLA',name:'Tesla',iv:'D'},
|
||
{sym:'FOREXCOM:SPXUSD',name:'S&P 500',iv:'D'},
|
||
{sym:'FX:USDKRW',name:'달러 환율',iv:'D'},
|
||
{sym:'TVC:GOLD',name:'금 선물',iv:'D'},
|
||
{sym:'BINANCE:BTCUSDT',name:'비트코인',iv:'D'},
|
||
];
|
||
|
||
let _wlKr=null,_wlTv=null;
|
||
function getKrList(){return _wlKr??DEFAULT_KR;}
|
||
function getTvList(){return _wlTv??DEFAULT_TV;}
|
||
async function loadWatchlist(){
|
||
try{
|
||
const r=await fetch('/api/investor/watchlist',{headers:authH()});
|
||
if(r.ok){const d=await r.json();if(Array.isArray(d.kr))_wlKr=d.kr;if(Array.isArray(d.tv))_wlTv=d.tv;}
|
||
}catch{}
|
||
}
|
||
async function saveWL(){
|
||
try{await fetch('/api/investor/watchlist',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({kr:getKrList(),tv:getTvList()})});}catch{}
|
||
}
|
||
|
||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
|
||
function renderWatchlist(){
|
||
// KR
|
||
const kr=getKrList();
|
||
document.getElementById('kr-group').innerHTML=`<div class="iv-group">${
|
||
kr.map((t,i)=>`
|
||
<div class="iv-ticker" id="kr-${i}" onclick="loadKStock('${escAttr(t.code)}','${escAttr(t.name)}','${escAttr(t.period||'day')}')">
|
||
<div class="iv-t-info">
|
||
<span class="iv-t-code">${escHtml(t.code)}</span>
|
||
<span class="iv-t-name">${escHtml(t.name)}</span>
|
||
</div>
|
||
<div class="iv-t-actions">
|
||
<button class="iv-t-act" onclick="event.stopPropagation();editKr(${i})" title="편집">✏</button>
|
||
<button class="iv-t-act del" onclick="event.stopPropagation();deleteKr(${i})" title="삭제">✕</button>
|
||
</div>
|
||
</div>`).join('')
|
||
}</div>`;
|
||
// TV
|
||
const tv=getTvList();
|
||
document.getElementById('tv-group').innerHTML=`<div class="iv-group">${
|
||
tv.map((t,i)=>`
|
||
<div class="iv-ticker" id="tv-${i}" onclick="loadTV('${escAttr(t.sym)}','${escAttr(t.iv||'D')}','${escAttr(t.name)}')">
|
||
<div class="iv-t-info">
|
||
<span class="iv-t-code">${escHtml(t.sym.split(':').pop())}</span>
|
||
<span class="iv-t-name">${escHtml(t.name)}</span>
|
||
</div>
|
||
<div class="iv-t-actions">
|
||
<button class="iv-t-act" onclick="event.stopPropagation();editTv(${i})" title="편집">✏</button>
|
||
<button class="iv-t-act del" onclick="event.stopPropagation();deleteTv(${i})" title="삭제">✕</button>
|
||
</div>
|
||
</div>`).join('')
|
||
}</div>`;
|
||
}
|
||
|
||
// ── Add / Edit forms ──────────────────────────────────────────────────────────
|
||
function showAddForm(type){
|
||
closeAllForms();
|
||
const container=document.getElementById(type==='kr'?'kr-group':'tv-group');
|
||
const formId='add-form-'+type;
|
||
const formHtml=type==='kr'
|
||
?`<div class="iv-ticker-form" id="${formId}">
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-code" placeholder="종목코드 (예: 005930)" maxlength="10"></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-name" placeholder="종목명 (예: 삼성전자)"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="tf-period">
|
||
<option value="day">일봉</option><option value="week">주봉</option>
|
||
<option value="month">월봉</option><option value="3month">3개월</option>
|
||
<option value="year">1년</option><option value="3year">3년</option>
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveAddKr()">추가</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
:`<div class="iv-ticker-form" id="${formId}">
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-sym" placeholder="심볼 (예: NASDAQ:AAPL)" ></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-name" placeholder="이름 (예: Apple)"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="tf-iv">
|
||
<option value="D">일봉</option><option value="W">주봉</option>
|
||
<option value="M">월봉</option><option value="60">60분</option>
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveAddTv()">추가</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
// append form below the group div
|
||
const existing=document.getElementById(formId);
|
||
if(existing){existing.remove();return;}
|
||
const wrap=document.createElement('div');wrap.innerHTML=formHtml;
|
||
container.appendChild(wrap.firstElementChild);
|
||
container.querySelector('#tf-code,#tf-sym')?.focus();
|
||
}
|
||
|
||
function editKr(i){
|
||
closeAllForms();
|
||
const arr=getKrList();const t=arr[i];
|
||
const row=document.getElementById('kr-'+i);if(!row)return;
|
||
const formId='edit-kr-'+i;
|
||
const form=document.createElement('div');form.className='iv-ticker-form';form.id=formId;
|
||
form.innerHTML=`
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-code-${i}" value="${escAttr(t.code)}" placeholder="종목코드"></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-name-${i}" value="${escAttr(t.name)}" placeholder="종목명"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="ef-period-${i}">
|
||
${['day','week','month','3month','year','3year'].map(p=>`<option value="${p}"${t.period===p?' selected':''}>${{day:'일봉',week:'주봉',month:'월봉','3month':'3개월',year:'1년','3year':'3년'}[p]||p}</option>`).join('')}
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveEditKr(${i})">저장</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>`;
|
||
row.after(form);
|
||
document.getElementById('ef-code-'+i)?.focus();
|
||
}
|
||
|
||
function editTv(i){
|
||
closeAllForms();
|
||
const arr=getTvList();const t=arr[i];
|
||
const row=document.getElementById('tv-'+i);if(!row)return;
|
||
const formId='edit-tv-'+i;
|
||
const form=document.createElement('div');form.className='iv-ticker-form';form.id=formId;
|
||
form.innerHTML=`
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-sym-${i}" value="${escAttr(t.sym)}" placeholder="심볼"></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-name-${i}" value="${escAttr(t.name)}" placeholder="이름"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="ef-iv-${i}">
|
||
${['D','W','M','60'].map(v=>`<option value="${v}"${t.iv===v?' selected':''}>${{D:'일봉',W:'주봉',M:'월봉','60':'60분'}[v]||v}</option>`).join('')}
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveEditTv(${i})">저장</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>`;
|
||
row.after(form);
|
||
document.getElementById('ef-sym-'+i)?.focus();
|
||
}
|
||
|
||
function closeAllForms(){
|
||
document.querySelectorAll('.iv-ticker-form').forEach(f=>f.remove());
|
||
}
|
||
|
||
async function saveAddKr(){
|
||
const code=(document.getElementById('tf-code')?.value||'').trim().replace(/\D/g,'');
|
||
const name=(document.getElementById('tf-name')?.value||'').trim()||code;
|
||
const period=document.getElementById('tf-period')?.value||'day';
|
||
if(!code)return;
|
||
if(!_wlKr)_wlKr=[...DEFAULT_KR];
|
||
_wlKr.push({code,name,period});await saveWL();renderWatchlist();
|
||
}
|
||
async function saveAddTv(){
|
||
const sym=(document.getElementById('tf-sym')?.value||'').trim().toUpperCase();
|
||
const name=(document.getElementById('tf-name')?.value||'').trim()||sym;
|
||
const iv=document.getElementById('tf-iv')?.value||'D';
|
||
if(!sym)return;
|
||
if(!_wlTv)_wlTv=[...DEFAULT_TV];
|
||
_wlTv.push({sym,name,iv});await saveWL();renderWatchlist();
|
||
}
|
||
async function saveEditKr(i){
|
||
const code=(document.getElementById('ef-code-'+i)?.value||'').trim().replace(/\D/g,'');
|
||
const name=(document.getElementById('ef-name-'+i)?.value||'').trim()||code;
|
||
const period=document.getElementById('ef-period-'+i)?.value||'day';
|
||
if(!code)return;
|
||
if(!_wlKr)_wlKr=[...DEFAULT_KR];
|
||
_wlKr[i]={code,name,period};await saveWL();renderWatchlist();
|
||
}
|
||
async function saveEditTv(i){
|
||
const sym=(document.getElementById('ef-sym-'+i)?.value||'').trim().toUpperCase();
|
||
const name=(document.getElementById('ef-name-'+i)?.value||'').trim()||sym;
|
||
const iv=document.getElementById('ef-iv-'+i)?.value||'D';
|
||
if(!sym)return;
|
||
if(!_wlTv)_wlTv=[...DEFAULT_TV];
|
||
_wlTv[i]={sym,name,iv};await saveWL();renderWatchlist();
|
||
}
|
||
async function deleteKr(i){
|
||
if(!_wlKr)_wlKr=[...DEFAULT_KR];
|
||
_wlKr.splice(i,1);await saveWL();renderWatchlist();
|
||
}
|
||
async function deleteTv(i){
|
||
if(!_wlTv)_wlTv=[...DEFAULT_TV];
|
||
_wlTv.splice(i,1);await saveWL();renderWatchlist();
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
const TOKEN_KEY='smallclaw_token';
|
||
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
||
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
|
||
async function checkAuth(){
|
||
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
|
||
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||
}
|
||
|
||
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
|
||
|
||
// ── Mobile overlay ────────────────────────────────────────────────────────────
|
||
function mobCloseAll(){
|
||
document.querySelector('.iv-sb').classList.remove('mob-open');
|
||
document.getElementById('chat-panel').classList.remove('mob-open');
|
||
document.getElementById('mob-backdrop').classList.remove('show');
|
||
['stocks','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
|
||
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#22c55e);border-top:2px solid var(--brand,#22c55e)';
|
||
}
|
||
function mobToggle(which){
|
||
const sb=document.querySelector('.iv-sb');
|
||
const cp=document.getElementById('chat-panel');
|
||
const bd=document.getElementById('mob-backdrop');
|
||
if(which==='home'){mobCloseAll();return;}
|
||
const sbOpen=sb.classList.contains('mob-open');
|
||
const cpOpen=cp.classList.contains('mob-open');
|
||
mobCloseAll();
|
||
if(which==='stocks'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-stocks').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
if(which==='chat'&&!cpOpen){cp.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-chat').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
}
|
||
|
||
// ── Resize ────────────────────────────────────────────────────────────────────
|
||
(function(){
|
||
const handle=document.getElementById('resize-handle');
|
||
const panel=document.getElementById('chat-panel');
|
||
let dragging=false,startX=0,startW=0;
|
||
panel.style.width=(parseInt(localStorage.getItem('iv_chat_w')||'340',10))+'px';
|
||
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
|
||
document.addEventListener('mousemove',e=>{if(!dragging)return;panel.style.width=Math.max(260,Math.min(600,startW+(startX-e.clientX)))+'px';});
|
||
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem('iv_chat_w',panel.offsetWidth);}catch{};});
|
||
})();
|
||
|
||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||
let selectedModel=localStorage.getItem('iv_model')||'';
|
||
async function loadModels(){
|
||
const sel=document.getElementById('model-sel');
|
||
try{
|
||
const[sr,or]=await Promise.all([fetch('/api/settings/model',{headers:authH()}),fetch('/api/ollama/models',{headers:authH()})]);
|
||
const setting=sr.ok?await sr.json():{};const od=or.ok?await or.json():{};
|
||
const primary=setting.primary||'기본';const models=(od.models||[]).map(m=>m.name);
|
||
sel.innerHTML=`<option value="">기본 (${primary})</option>`;
|
||
if(selectedModel&&!models.includes(selectedModel))models.unshift(selectedModel);
|
||
models.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===selectedModel)o.selected=true;sel.appendChild(o);});
|
||
}catch{}
|
||
}
|
||
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem('iv_model',selectedModel);}catch{}}
|
||
|
||
// ── Skill ─────────────────────────────────────────────────────────────────────
|
||
const APP_KEY='iv';
|
||
let skillContext='';
|
||
let _sessionId=null;
|
||
function getSessionId(){return _sessionId||'iv_main';}
|
||
async function initSessionId(){
|
||
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
|
||
_sessionId='iv_'+Math.random().toString(36).slice(2,12);
|
||
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
||
}
|
||
|
||
async function loadHistory(){
|
||
const sid=getSessionId();
|
||
if(!sid||sid.endsWith('_main'))return;
|
||
try{
|
||
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
||
if(!r.ok)return;
|
||
const d=await r.json();
|
||
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
|
||
if(!msgs.length)return;
|
||
const recent=msgs.slice(-30);
|
||
const el=document.getElementById('chat-msgs');
|
||
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
|
||
for(const m of recent){
|
||
if(m.role==='user')addMsg('user',escHtml(m.content||''));
|
||
else addMsg('assistant',renderMd(m.content||''));
|
||
}
|
||
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
|
||
el.scrollTop=el.scrollHeight;
|
||
}catch{}
|
||
}
|
||
|
||
|
||
const SKILL_CONTENT="---\nname: Investor\ndescription: 주식·채권·부동산·대안투자·포트폴리오 관리·경제지표 분석을 아우르는 투자 전문가 어시스턴트. 한국·미국 시장 모두 지원.\nemoji: \"📈\"\nversion: 1.0.0\ntriggers: [주식, 투자, 펀드, 채권, 포트폴리오, 수익률, 배당, ETF, 코스피, 코스닥, 나스닥, 환율, 금리, 부동산, 리츠, 원자재, 암호화폐, 달러, 경제지표, 증시, PER, PBR]\n---\n\n당신은 CFA(공인재무분석사) 수준의 투자 분석·포트폴리오 관리·리스크 평가 전문 어시스턴트입니다. 데이터 기반으로 정확하고 균형 잡힌 분석을 제공하세요. 정보 제공 목적이며 투자 권유가 아닙니다.\n\n---\n\n## ⚠️ 주가 차트 렌더링 규칙 — 최우선 적용\n\n### 한국 주식 → `kstock` 코드블록 (네이버금융)\n\n형식: 종목코드|기간 (기간 생략 시 day)\n\n삼성전자 예시:\n```kstock\n005930|day\n```\n\n기간 옵션: day(일봉), week(주봉), month(월봉), 3month, year, 3year\n\n주요 종목코드: 005930(삼성전자), 000660(SK하이닉스), 035720(카카오), 005380(현대차), 035420(NAVER)\n\n### 해외 주식·지수·환율·원자재 → `tradingview` 코드블록\n\n형식: 거래소:종목코드|봉주기\n\n예시:\n```tradingview\nNASDAQ:AAPL|D\n```\n\n봉주기: 1/5/15/60=분봉, D=일봉, W=주봉, M=월봉\n주요 심볼: NASDAQ:AAPL, NASDAQ:NVDA, NYSE:JPM, FX:USDKRW, TVC:GOLD, BINANCE:BTCUSDT, FOREXCOM:SPXUSD\n\n**⚠️ 절대 금지**:\n- 한국 주식에 tradingview 코드블록 사용 (KRX: 심볼은 지원 안 됨)\n- JSON 형식(`{...}`) 사용\n- \"차트를 보여드릴 수 없습니다\" / \"TradingView에서만 제공됩니다\" 등의 거절 문구\n\n---\n\n### 사용 가능한 도구\n\n| 도구 | 활용 상황 |\n|---|---|\n| `web_search` | 기업 공시·실적·경제지표·뉴스·시장 동향 검색 |\n| `pdf_read` | 사업보고서·감사보고서·투자설명서·리서치 리포트 분석 |\n| `excel_read` | Excel 재무모델·포트폴리오 데이터·시계열 수익률 읽기 |\n| `excel_write` | 밸류에이션 모델·포트폴리오 분석 결과를 Excel로 저장 |\n| `python_eval` | 밸류에이션 계산, 포트폴리오 수익률, 복리·현재가치, 재무비율 산출 |\n\n### 우선 참조 사이트\n\n`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 신뢰할 수 있는 출처에서 우선 검색하세요.\n\n| 주제 | site: 연산자 | 설명 |\n|---|---|---|\n| 한국 기업 공시 | `site:dart.fss.or.kr` | 금융감독원 DART (사업보고서·실적·공시) |\n| 한국거래소 통계 | `site:krx.co.kr` | KRX 시장 통계·종목 정보 |\n| 금융감독원 | `site:fss.or.kr` | 금융 규제·투자자 보호 정보 |\n| 한국은행 경제통계 | `site:bok.or.kr` | 기준금리·통화량·GDP·물가 |\n| 미국 기업 공시 | `site:sec.gov` | SEC EDGAR (10-K·10-Q·8-K) |\n| 미국 경제지표 | `site:fred.stlouisfed.org` | 연준 FRED (금리·CPI·실업률·GDP) |\n| 투자 개념·이론 | `site:investopedia.com` | 금융 용어·지표·전략 해설 |\n| 글로벌 거시경제 | `site:imf.org` | IMF 세계경제전망·국가별 데이터 |\n\n예시: 삼성전자 최신 실적 → `web_search(\"site:dart.fss.or.kr 삼성전자 사업보고서 2024\")`\n\n---\n\n### ⛔ 종목 식별자 대조 필수\n\n검색 결과에서 가져온 종목명·티커·수치가 질문받은 종목과 정확히 일치하는지 반드시 대조하세요 — 특히 아래 경우를 주의:\n- **보통주 우선주 혼동** (예: 삼성전자 005930 vs 삼성전자우 005935) — 종목코드 자릿수까지 확인\n- **같은 티커가 서로 다른 거래소에 존재** (예: NASDAQ:META vs 다른 거래소의 유사티커) — tradingview 코드블록에 거래소를 명시적으로 확인\n- **상호명이 겹치는 서로 다른 법인** (예: 삼성전자 vs 삼성SDI vs 삼성물산 — 각자 다른 종목코드·실적)\n- **검색 결과가 오래된 기사일 가능성** — 기사 날짜와 분기(예: \"2024년 3분기 실적\")가 질문받은 시점과 맞는지 확인, 오래된 결과를 이 분기/오늘 수치로 단정하지 말 것\n\n종목명과 종목코드(혹은 거래소:티커)를 답변에 함께 명시해 사용자가 직접 대조할 수 있게 하세요.\n\n---\n\n### 기업 분석 방법론\n\n기업 분석 요청 시 다음 순서로 접근하세요:\n\n**1단계 — 사업 이해**\n- 핵심 사업 모델, 주요 제품·서비스, 수익 구조\n- 산업 내 경쟁 위치(Moat), 주요 경쟁사\n\n**2단계 — 재무 분석** (`python_eval` 활용)\n- 매출성장률, 영업이익률, 순이익률\n- ROE, ROA, ROIC\n- 부채비율, 이자보상배율, FCF(잉여현금흐름)\n\n**3단계 — 밸류에이션**\n- **상대가치법**: PER, PBR, EV/EBITDA, PSR → 업종 평균·과거 밴드 비교\n- **절대가치법**: DCF (WACC + 잔여가치)\n- **목표주가**: 밸류에이션 범위 제시 (낙관·기본·비관 시나리오)\n\n```python\n# DCF 간이 계산 예시\nfcf = [100, 110, 121, 133, 146] # 5년 FCF 추정 (억원)\nwacc = 0.09\nterminal_growth = 0.03\nterminal_value = fcf[-1] * (1 + terminal_growth) / (wacc - terminal_growth)\npv = sum(f / (1 + wacc)**i for i, f in enumerate(fcf, 1))\npv += terminal_value / (1 + wacc)**5\nprint(f\"내재가치(PV): {pv:.0f}억원\")\n```\n\n**4단계 — 리스크 요인**\n- 산업 리스크, 규제 리스크, 환율·금리 민감도\n- 주요 가정 변화 시 밸류에이션 민감도(Sensitivity)\n\n---\n\n### 포트폴리오 관리\n\n- **자산배분(Asset Allocation)**: 주식·채권·현금·대안투자 비중 설계\n- **분산투자**: 섹터·국가·통화 분산, 상관계수(correlation) 고려\n- **리밸런싱**: 목표 비중 이탈 시 재조정 기준 (시간 기반 vs 임계값 기반)\n- **리스크 지표**: 변동성(σ), 베타(β), 최대낙폭(MDD), 샤프비율(Sharpe Ratio)\n\n```python\nimport math\n# 샤프비율 계산\nreturns = [0.12, 0.08, -0.05, 0.15, 0.10]\nrf = 0.035 # 무위험수익률\navg_r = sum(returns) / len(returns)\nstd_r = math.sqrt(sum((r - avg_r)**2 for r in returns) / len(returns))\nsharpe = (avg_r - rf) / std_r\nprint(f\"샤프비율: {sharpe:.2f}\")\n```\n\n---\n\n### 시장 분석\n\n#### 거시경제 지표 해석\n| 지표 | 투자 시사점 |\n|---|---|\n| **기준금리** | 금리 상승 → 채권 가격↓, 성장주 밸류에이션 압박 |\n| **CPI (물가)** | 고물가 → 실질수익률↓, 원자재·부동산 헤지 유리 |\n| **실업률·PMI** | 경기 선행지표, 기업 실적 방향성 |\n| **환율** | 수출 기업 수익성, 외국인 자금 유출입 |\n| **장단기 금리차** | 역전 시 경기침체 선행 신호 |\n\n#### 섹터 분석\n- 경기 사이클별 유리 섹터: 경기회복(소재·산업재) → 호황(IT·소비재) → 둔화(헬스케어·유틸리티) → 침체(필수소비재·현금)\n- 한국 시장 특성: 반도체(삼성·SK하이닉스) 비중 과대 → 글로벌 반도체 사이클 연동성 주의\n\n#### 기술적 분석 (보조 참고용)\n- 이동평균선(MA 20·60·120·200), 골든크로스·데드크로스\n- RSI (과매수 70↑ / 과매도 30↓), MACD, 볼린저밴드\n- 지지·저항 구간, 거래량 확인\n\n---\n\n### 투자 유형별 가이드\n\n#### 한국 주식\n- KOSPI·KOSDAQ 종목: DART 공시 → 사업보고서·분기보고서 확인\n- 배당주: 배당수익률·배당성향·배당성장률 분석\n- ETF: 추적지수·총보수(TER)·괴리율·추적오차\n\n#### 미국 주식\n- SEC EDGAR 10-K(연간)·10-Q(분기) 분석\n- ADR·직접 투자 시 환율·세금(배당 원천징수 15%) 고려\n\n#### 채권\n- 듀레이션(Duration)·컨벡시티(Convexity) 계산\n- 신용등급(KAA·무디스·S&P), 스프레드 분석\n- 금리 시나리오별 채권 가격 변동 시뮬레이션\n\n#### 부동산\n- 임대수익률 = 연 임대료 / 매매가\n- 자본환원율(Cap Rate) = 순영업이익(NOI) / 자산가치\n- 레버리지 효과·LTV 규제·보유세 고려\n\n#### 대안투자·ETF\n- 원자재(금·원유·구리): 인플레이션 헤지, 공급·수요 사이클\n- 리츠(REITs): 배당수익률·FFO(조정운영현금흐름) 분석\n- 가상자산: 고변동성·규제 리스크 명시, 소액 비중 원칙\n\n---\n\n### 응답 톤 가이드\n\n- **데이터 중심**: 수치와 출처를 먼저, 의견은 나중에\n- **시나리오 제시**: 낙관·기본·비관 3가지 시나리오로 범위 제시\n- **리스크 균형**: 기회 요인과 위험 요인을 항상 함께 서술\n- **계산 투명성**: `python_eval`로 산식 공개, 가정(assumption) 명시\n- **투자 권유 금지**: \"~에 투자하세요\" 대신 \"~를 고려해볼 수 있습니다\" 형식\n";
|
||
function loadSkill(){
|
||
skillContext=SKILL_CONTENT;
|
||
document.getElementById('skill-badge').textContent='📈 투자 전문가 활성';
|
||
}
|
||
|
||
// ── Chart ─────────────────────────────────────────────────────────────────────
|
||
let chartType='kr'; // 'kr' or 'tv'
|
||
let tvReady=false;
|
||
|
||
function setChartType(type){
|
||
chartType=type;
|
||
document.getElementById('ct-kr').classList.toggle('active',type==='kr');
|
||
document.getElementById('ct-tv').classList.toggle('active',type==='tv');
|
||
const ph=type==='kr'?'005930 (종목코드)':'NASDAQ:AAPL';
|
||
document.getElementById('sym-input').placeholder=ph;
|
||
document.getElementById('period-sel').style.display=type==='kr'?'':'none';
|
||
}
|
||
|
||
function showEmpty(){
|
||
document.getElementById('chart-empty').style.display='';
|
||
document.getElementById('tv-container').classList.remove('visible');
|
||
document.getElementById('ks-container').classList.remove('visible');
|
||
document.getElementById('chart-title').textContent='';
|
||
}
|
||
|
||
// Korean stock via Naver Finance image
|
||
function loadKStock(code,name,period){
|
||
if(window.innerWidth<=1024) mobCloseAll();
|
||
period=period||document.getElementById('period-sel').value||'day';
|
||
setChartType('kr');
|
||
document.getElementById('sym-input').value=code;
|
||
document.getElementById('period-sel').value=period;
|
||
// highlight sidebar
|
||
document.querySelectorAll('.iv-ticker').forEach(el=>el.classList.remove('active'));
|
||
const periodMap={day:'day',week:'week',month:'month','3month':'3month',year:'year','3year':'3year'};
|
||
const navPeriod=periodMap[period]||'day';
|
||
const imgUrl=`https://ssl.pstatic.net/imgfinance/chart/item/area/${navPeriod}/${code}.png`;
|
||
const navUrl=`https://finance.naver.com/item/main.naver?code=${code}`;
|
||
document.getElementById('chart-empty').style.display='none';
|
||
document.getElementById('tv-container').classList.remove('visible');
|
||
const ksC=document.getElementById('ks-container');
|
||
ksC.classList.add('visible');
|
||
const img=document.getElementById('ks-img');
|
||
img.src='';
|
||
img.onerror=()=>{ksC.innerHTML=`<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px">차트를 불러올 수 없습니다.<br><a href="${navUrl}" target="_blank" style="color:#4ade80">네이버금융에서 보기 ↗</a></div>`;};
|
||
img.onload=()=>{};
|
||
img.src=imgUrl;img.alt=name||code;
|
||
const periodLabel={day:'일봉',week:'주봉',month:'월봉','3month':'3개월',year:'1년','3year':'3년'}[navPeriod]||navPeriod;
|
||
document.getElementById('chart-title').innerHTML=`<a href="${navUrl}" target="_blank" style="color:#4ade80;text-decoration:none">${name||code} · ${periodLabel} (네이버금융) ↗</a>`;
|
||
}
|
||
|
||
// TradingView widget
|
||
function loadTV(symbol,interval,name){
|
||
if(window.innerWidth<=1024) mobCloseAll();
|
||
interval=interval||'D';
|
||
setChartType('tv');
|
||
document.getElementById('sym-input').value=symbol;
|
||
document.getElementById('chart-empty').style.display='none';
|
||
document.getElementById('ks-container').classList.remove('visible');
|
||
const tvC=document.getElementById('tv-container');
|
||
tvC.classList.add('visible');
|
||
tvC.innerHTML='<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px">차트 로딩 중...</div>';
|
||
document.getElementById('chart-title').textContent=name||symbol;
|
||
const isDark=document.documentElement.getAttribute('data-theme')!=='light';
|
||
const containerId='tv-chart-'+Date.now();
|
||
const inner=document.createElement('div');inner.id=containerId;inner.style.cssText='width:100%;height:100%;';
|
||
tvC.innerHTML='';tvC.appendChild(inner);
|
||
function doInit(){
|
||
try{
|
||
new TradingView.widget({
|
||
container_id:containerId,autosize:true,symbol,interval,
|
||
timezone:'Asia/Seoul',theme:isDark?'dark':'light',
|
||
style:'1',locale:'kr',enable_publishing:false,
|
||
allow_symbol_change:true,save_image:false,
|
||
withdateranges:true,hide_side_toolbar:false,
|
||
studies:['RSI@tv-basicstudies','MACD@tv-basicstudies'],
|
||
});
|
||
}catch(e){tvC.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center">TradingView 차트 로드 실패: ${e.message}</div>`;}
|
||
}
|
||
if(window.TradingView&&window.TradingView.widget){doInit();}
|
||
else{
|
||
const s=document.createElement('script');s.src='https://s3.tradingview.com/tv.js';
|
||
s.onload=doInit;s.onerror=()=>{tvC.innerHTML='<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center">TradingView 스크립트를 로드할 수 없습니다.</div>';};
|
||
document.head.appendChild(s);
|
||
}
|
||
}
|
||
|
||
function loadChartFromInput(){
|
||
const raw=document.getElementById('sym-input').value.trim();
|
||
if(!raw)return;
|
||
const period=document.getElementById('period-sel').value||'day';
|
||
if(chartType==='kr'||/^\d{6}$/.test(raw)){loadKStock(raw.replace(/\D/g,''),raw,period);}
|
||
else{
|
||
// Parse interval from period select: day→D, week→W, month→M
|
||
const ivMap={day:'D',week:'W',month:'M','3month':'3M',year:'12M','3year':'60M'};
|
||
loadTV(raw.toUpperCase(),ivMap[period]||'D',raw);
|
||
}
|
||
}
|
||
|
||
// Intercept kstock/tradingview blocks from AI response and show in center
|
||
function processChartBlocks(text){
|
||
// kstock block: ```kstock\n005930|day\n```
|
||
const ksMatch=text.match(/```kstock\s*\n([^`]+)\n```/);
|
||
if(ksMatch){
|
||
const parts=ksMatch[1].trim().split('|');
|
||
const code=parts[0].trim().replace(/\D/g,'');
|
||
const period=(parts[1]||'day').trim().toLowerCase();
|
||
if(code){
|
||
setTimeout(()=>loadKStock(code,'',period),100);
|
||
return true;
|
||
}
|
||
}
|
||
// tradingview block: ```tradingview\nNASDAQ:AAPL|D\n```
|
||
const tvMatch=text.match(/```tradingview\s*\n([^`]+)\n```/);
|
||
if(tvMatch){
|
||
const parts=tvMatch[1].trim().split('|');
|
||
const symbol=parts[0].trim().toUpperCase();
|
||
const interval=(parts[1]||'D').trim().toUpperCase();
|
||
if(symbol){
|
||
setTimeout(()=>loadTV(symbol,interval,symbol),100);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||
|
||
function addMsg(role,html){
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const div=document.createElement('div');div.className='iv-msg '+role;
|
||
const b=document.createElement('div');b.className='iv-bubble';b.innerHTML=html;
|
||
// 수식은 DOM 삽입 후에 렌더링해야 한다(innerHTML은 <script>를 실행하지 않는다).
|
||
if(role!=='user'&&window.renderKatexIn)window.renderKatexIn(b);
|
||
div.appendChild(b);
|
||
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
|
||
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
|
||
return b;
|
||
}
|
||
|
||
function renderMd(text){
|
||
// First extract chart blocks to replace with links
|
||
let out=text;
|
||
// Replace kstock blocks with a clickable chip
|
||
out=out.replace(/```kstock\s*\n([^`]+)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const code=parts[0].trim().replace(/\D/g,'');const period=(parts[1]||'day').trim();
|
||
if(!code)return m;
|
||
return `<span class="iv-chart-link" onclick="loadKStock('${code}','${code}','${period}')">📈 ${code} ${period} 차트 보기</span>`;
|
||
});
|
||
// Replace tradingview blocks with a clickable chip
|
||
out=out.replace(/```tradingview\s*\n([^`]+)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const sym=parts[0].trim().toUpperCase();const iv=(parts[1]||'D').trim();
|
||
if(!sym)return m;
|
||
return `<span class="iv-chart-link" onclick="loadTV('${sym}','${iv}','${sym}')">📈 ${sym} 차트 보기</span>`;
|
||
});
|
||
return out
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
// restore already-HTML chart links before further escaping
|
||
.replace(/<span class="iv-chart-link"([^&]*)>(.*?)<\/span>/g,(m)=>{
|
||
// this won't work — need to do escaping AFTER substitution
|
||
return m;
|
||
})
|
||
// Actually process differently below
|
||
;
|
||
}
|
||
|
||
function renderMdSafe(text){
|
||
// Do chart block substitution BEFORE HTML escaping
|
||
let charts=[];
|
||
let out=text
|
||
.replace(/```kstock\s*\n([^`]+?)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const code=parts[0].trim().replace(/\D/g,'');const period=(parts[1]||'day').trim().toLowerCase();
|
||
if(!code)return m;
|
||
const ph=`\x00CHART${charts.length}\x00`;
|
||
charts.push(`<span class="iv-chart-link" onclick="loadKStock('${escAttr(code)}','${escAttr(code)}','${escAttr(period)}')">📈 ${escHtml(code)} · ${escHtml(period)} 차트 보기</span>`);
|
||
return ph;
|
||
})
|
||
.replace(/```tradingview\s*\n([^`]+?)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const sym=parts[0].trim().toUpperCase();const iv=(parts[1]||'D').trim().toUpperCase();
|
||
if(!sym)return m;
|
||
const ph=`\x00CHART${charts.length}\x00`;
|
||
charts.push(`<span class="iv-chart-link" onclick="loadTV('${escAttr(sym)}','${escAttr(iv)}','${escAttr(sym)}')">📈 ${escHtml(sym)} · ${escHtml(iv)} 차트 보기</span>`);
|
||
return ph;
|
||
});
|
||
// HTML escape
|
||
out=out
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
|
||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
|
||
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
|
||
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
|
||
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
|
||
.replace(/<p><\/p>/g,'').replace(/<p>(<h3>)/g,'$1').replace(/(<\/h3>)<\/p>/g,'$1')
|
||
.replace(/<p>(<pre>)/g,'$1').replace(/(<\/pre>)<\/p>/g,'$1')
|
||
.replace(/<p>(<ul>)/g,'$1').replace(/(<\/ul>)<\/p>/g,'$1');
|
||
// Restore chart placeholders
|
||
charts.forEach((html,i)=>{out=out.replace(new RegExp(`\x00CHART${i}\x00`,'g'),html);});
|
||
return out;
|
||
}
|
||
|
||
async function sendMessage(){
|
||
const input=document.getElementById('chat-input');
|
||
const msg=input.value.trim();
|
||
if(!msg||document.getElementById('send-btn').disabled)return;
|
||
input.value='';autoResize(input);
|
||
if(window.innerWidth<=1024) mobToggle('chat');
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}
|
||
|
||
function sendQuick(msg){
|
||
if(window.innerWidth<=1024){mobCloseAll();}
|
||
addMsg('user',escHtml(msg));callChat(msg);
|
||
}
|
||
|
||
async function callChat(message){
|
||
const btn=document.getElementById('send-btn');
|
||
btn.disabled=true;btn.textContent='...';
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const typingDiv=document.createElement('div');typingDiv.className='iv-msg assistant';
|
||
typingDiv.innerHTML='<div class="iv-typing"><div class="iv-dot"></div><div class="iv-dot"></div><div class="iv-dot"></div></div>';
|
||
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
||
let bubble=null,textBuf='',thinkBuf='',chartLoaded=false;
|
||
try{
|
||
const body={message,sessionId:getSessionId(),useTools:true,
|
||
...(selectedModel?{model:selectedModel}:{}),
|
||
...(skillContext?{skillContext}:{})};
|
||
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
||
if(!r.ok)throw new Error('서버 오류 '+r.status);
|
||
typingDiv.remove();
|
||
const msgDiv=document.createElement('div');msgDiv.className='iv-msg assistant';
|
||
bubble=document.createElement('div');bubble.className='iv-bubble';
|
||
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
|
||
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
|
||
while(true){
|
||
const{done,value}=await reader.read();if(done)break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
const parts=buf.split('\n\n');buf=parts.pop()||'';
|
||
for(const part of parts){
|
||
if(!part.startsWith('data: '))continue;
|
||
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
|
||
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
|
||
else if(ev.type==='token'||ev.type==='text_delta'){
|
||
textBuf+=(ev.text||'');
|
||
if(bubble){
|
||
bubble.innerHTML=(thinkBuf?`<div class="iv-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMdSafe(textBuf);
|
||
msgs.scrollTop=msgs.scrollHeight;
|
||
}
|
||
// Auto-load first chart block found in stream (once)
|
||
if(!chartLoaded&&textBuf.includes('```')){
|
||
if(processChartBlocks(textBuf)){chartLoaded=true;}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if(!chartLoaded&&textBuf)processChartBlocks(textBuf);
|
||
}catch(e){
|
||
typingDiv.remove();
|
||
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
|
||
}finally{btn.disabled=false;btn.textContent='전송';}
|
||
}
|
||
|
||
async function clearChat(){
|
||
if(!confirm('대화를 지울까요?'))return;
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
_sessionId='iv_'+Math.random().toString(36).slice(2,12);
|
||
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
||
addMsg('system','새 대화가 시작되었습니다. 투자 질문을 입력해주세요.');
|
||
}
|
||
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
||
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
(async()=>{
|
||
const ok=await checkAuth();if(!ok)return;
|
||
await initSessionId();
|
||
await loadHistory();
|
||
await loadWatchlist();
|
||
renderWatchlist();
|
||
loadTV('AMEX:XLK','D','XLK (기술주 섹터 ETF)');
|
||
await Promise.all([loadSkill(),loadModels()]);
|
||
addMsg('system','📈 투자 어시스턴트입니다. 종목 분석, 포트폴리오 구성, 경제지표를 도와드립니다.');
|
||
initAppVoice(sendMessage);
|
||
document.getElementById('chat-input').focus();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|