Files
homeclaw/web-ui/investor-app.html
T
kimandClaude Sonnet 4.6 9ad63ec8b1 v3.0.8: 스킬앱 독립화 + 치과에이전트 + 음악/MCP/보이스 라우트 + DB 워크플로 개선
- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent
  각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거
- 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice
- skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입
- 세션 삭제 API: DELETE /api/admin/sessions/:username/:id
- Telegram defaultUsername: 어드민 계정 자동 추론
- Ollama /api/ollama/models에 vision 지원 여부 플래그 추가
- DB 워크플로 개선: workflow_images.py 대규모 리팩터링
- 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄)
- 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:07:58 +09:00

731 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📈 투자 어시스턴트</title>
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
<link rel="stylesheet" href="styles.css">
<script>try{var _t=localStorage.getItem('cherryclaw_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;}
</style>
</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 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="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'},
];
function loadWL(key,def){try{const v=localStorage.getItem(key);return v?JSON.parse(v):def;}catch{return def;}}
function saveWL(key,arr){try{localStorage.setItem(key,JSON.stringify(arr));}catch{}}
function getKrList(){return loadWL(WL_KR_KEY,DEFAULT_KR);}
function getTvList(){return loadWL(WL_TV_KEY,DEFAULT_TV);}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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());
}
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;
const arr=getKrList();arr.push({code,name,period});saveWL(WL_KR_KEY,arr);renderWatchlist();
}
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;
const arr=getTvList();arr.push({sym,name,iv});saveWL(WL_TV_KEY,arr);renderWatchlist();
}
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;
const arr=getKrList();arr[i]={code,name,period};saveWL(WL_KR_KEY,arr);renderWatchlist();
}
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;
const arr=getTvList();arr[i]={sym,name,iv};saveWL(WL_TV_KEY,arr);renderWatchlist();
}
function deleteKr(i){
const arr=getKrList();arr.splice(i,1);saveWL(WL_KR_KEY,arr);renderWatchlist();
}
function deleteTv(i){
const arr=getTvList();arr.splice(i,1);saveWL(WL_TV_KEY,arr);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;}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('cherryclaw_theme',n);}catch{}}
// ── 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 SID_KEY='iv_app_session';
let skillContext='';
function getSessionId(){try{let s=sessionStorage.getItem(SID_KEY);if(!s){s='iv_'+Math.random().toString(36).slice(2,12);sessionStorage.setItem(SID_KEY,s);}return s;}catch{return 'iv_main';}}
const SKILL_CONTENT="---\nname: Investor\ndescription: \uc8fc\uc2dd\u00b7\ucc44\uad8c\u00b7\ubd80\ub3d9\uc0b0\u00b7\ub300\uc548\ud22c\uc790\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uad00\ub9ac\u00b7\uacbd\uc81c\uc9c0\ud45c \ubd84\uc11d\uc744 \uc544\uc6b0\ub974\ub294 \ud22c\uc790 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8. \ud55c\uad6d\u00b7\ubbf8\uad6d \uc2dc\uc7a5 \ubaa8\ub450 \uc9c0\uc6d0.\nemoji: \"\ud83d\udcc8\"\nversion: 1.0.0\ntriggers: [\uc8fc\uc2dd, \ud22c\uc790, \ud380\ub4dc, \ucc44\uad8c, \ud3ec\ud2b8\ud3f4\ub9ac\uc624, \uc218\uc775\ub960, \ubc30\ub2f9, ETF, \ucf54\uc2a4\ud53c, \ucf54\uc2a4\ub2e5, \ub098\uc2a4\ub2e5, \ud658\uc728, \uae08\ub9ac, \ubd80\ub3d9\uc0b0, \ub9ac\uce20, \uc6d0\uc790\uc7ac, \uc554\ud638\ud654\ud3d0, \ub2ec\ub7ec, \uacbd\uc81c\uc9c0\ud45c, \uc99d\uc2dc, PER, PBR]\n---\n\n\ub2f9\uc2e0\uc740 CFA(\uacf5\uc778\uc7ac\ubb34\ubd84\uc11d\uc0ac) \uc218\uc900\uc758 \ud22c\uc790 \ubd84\uc11d\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uad00\ub9ac\u00b7\ub9ac\uc2a4\ud06c \ud3c9\uac00 \uc804\ubb38 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \ub370\uc774\ud130 \uae30\ubc18\uc73c\ub85c \uc815\ud655\ud558\uace0 \uade0\ud615 \uc7a1\ud78c \ubd84\uc11d\uc744 \uc81c\uacf5\ud558\uc138\uc694. \uc815\ubcf4 \uc81c\uacf5 \ubaa9\uc801\uc774\uba70 \ud22c\uc790 \uad8c\uc720\uac00 \uc544\ub2d9\ub2c8\ub2e4.\n\n---\n\n## \u26a0\ufe0f \uc8fc\uac00 \ucc28\ud2b8 \ub80c\ub354\ub9c1 \uaddc\uce59 \u2014 \ucd5c\uc6b0\uc120 \uc801\uc6a9\n\n### \ud55c\uad6d \uc8fc\uc2dd \u2192 `kstock` \ucf54\ub4dc\ube14\ub85d (\ub124\uc774\ubc84\uae08\uc735)\n\n\ud615\uc2dd: \uc885\ubaa9\ucf54\ub4dc|\uae30\uac04 (\uae30\uac04 \uc0dd\ub7b5 \uc2dc day)\n\n\uc0bc\uc131\uc804\uc790 \uc608\uc2dc:\n```kstock\n005930|day\n```\n\n\uae30\uac04 \uc635\uc158: day(\uc77c\ubd09), week(\uc8fc\ubd09), month(\uc6d4\ubd09), 3month, year, 3year\n\n\uc8fc\uc694 \uc885\ubaa9\ucf54\ub4dc: 005930(\uc0bc\uc131\uc804\uc790), 000660(SK\ud558\uc774\ub2c9\uc2a4), 035720(\uce74\uce74\uc624), 005380(\ud604\ub300\ucc28), 035420(NAVER)\n\n### \ud574\uc678 \uc8fc\uc2dd\u00b7\uc9c0\uc218\u00b7\ud658\uc728\u00b7\uc6d0\uc790\uc7ac \u2192 `tradingview` \ucf54\ub4dc\ube14\ub85d\n\n\ud615\uc2dd: \uac70\ub798\uc18c:\uc885\ubaa9\ucf54\ub4dc|\ubd09\uc8fc\uae30\n\n\uc608\uc2dc:\n```tradingview\nNASDAQ:AAPL|D\n```\n\n\ubd09\uc8fc\uae30: 1/5/15/60=\ubd84\ubd09, D=\uc77c\ubd09, W=\uc8fc\ubd09, M=\uc6d4\ubd09\n\uc8fc\uc694 \uc2ec\ubcfc: NASDAQ:AAPL, NASDAQ:NVDA, NYSE:JPM, FX:USDKRW, TVC:GOLD, BINANCE:BTCUSDT, FOREXCOM:SPXUSD\n\n**\u26a0\ufe0f \uc808\ub300 \uae08\uc9c0**:\n- \ud55c\uad6d \uc8fc\uc2dd\uc5d0 tradingview \ucf54\ub4dc\ube14\ub85d \uc0ac\uc6a9 (KRX: \uc2ec\ubcfc\uc740 \uc9c0\uc6d0 \uc548 \ub428)\n- JSON \ud615\uc2dd(`{...}`) \uc0ac\uc6a9\n- \"\ucc28\ud2b8\ub97c \ubcf4\uc5ec\ub4dc\ub9b4 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4\" / \"TradingView\uc5d0\uc11c\ub9cc \uc81c\uacf5\ub429\ub2c8\ub2e4\" \ub4f1\uc758 \uac70\uc808 \ubb38\uad6c\n\n---\n\n### \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ub3c4\uad6c\n\n| \ub3c4\uad6c | \ud65c\uc6a9 \uc0c1\ud669 |\n|---|---|\n| `web_search` | \uae30\uc5c5 \uacf5\uc2dc\u00b7\uc2e4\uc801\u00b7\uacbd\uc81c\uc9c0\ud45c\u00b7\ub274\uc2a4\u00b7\uc2dc\uc7a5 \ub3d9\ud5a5 \uac80\uc0c9 |\n| `pdf_read` | \uc0ac\uc5c5\ubcf4\uace0\uc11c\u00b7\uac10\uc0ac\ubcf4\uace0\uc11c\u00b7\ud22c\uc790\uc124\uba85\uc11c\u00b7\ub9ac\uc11c\uce58 \ub9ac\ud3ec\ud2b8 \ubd84\uc11d |\n| `excel_read` | Excel \uc7ac\ubb34\ubaa8\ub378\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \ub370\uc774\ud130\u00b7\uc2dc\uacc4\uc5f4 \uc218\uc775\ub960 \uc77d\uae30 |\n| `excel_write` | \ubc38\ub958\uc5d0\uc774\uc158 \ubaa8\ub378\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \ubd84\uc11d \uacb0\uacfc\ub97c Excel\ub85c \uc800\uc7a5 |\n| `python_eval` | \ubc38\ub958\uc5d0\uc774\uc158 \uacc4\uc0b0, \ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uc218\uc775\ub960, \ubcf5\ub9ac\u00b7\ud604\uc7ac\uac00\uce58, \uc7ac\ubb34\ube44\uc728 \uc0b0\ucd9c |\n\n### \uc6b0\uc120 \ucc38\uc870 \uc0ac\uc774\ud2b8\n\n`web_search` \ud638\ucd9c \uc2dc \uc544\ub798 **site: \uc5f0\uc0b0\uc790\ub97c query\uc5d0 \ud3ec\ud568**\ud574 \uc2e0\ub8b0\ud560 \uc218 \uc788\ub294 \ucd9c\ucc98\uc5d0\uc11c \uc6b0\uc120 \uac80\uc0c9\ud558\uc138\uc694.\n\n| \uc8fc\uc81c | site: \uc5f0\uc0b0\uc790 | \uc124\uba85 |\n|---|---|---|\n| \ud55c\uad6d \uae30\uc5c5 \uacf5\uc2dc | `site:dart.fss.or.kr` | \uae08\uc735\uac10\ub3c5\uc6d0 DART (\uc0ac\uc5c5\ubcf4\uace0\uc11c\u00b7\uc2e4\uc801\u00b7\uacf5\uc2dc) |\n| \ud55c\uad6d\uac70\ub798\uc18c \ud1b5\uacc4 | `site:krx.co.kr` | KRX \uc2dc\uc7a5 \ud1b5\uacc4\u00b7\uc885\ubaa9 \uc815\ubcf4 |\n| \uae08\uc735\uac10\ub3c5\uc6d0 | `site:fss.or.kr` | \uae08\uc735 \uaddc\uc81c\u00b7\ud22c\uc790\uc790 \ubcf4\ud638 \uc815\ubcf4 |\n| \ud55c\uad6d\uc740\ud589 \uacbd\uc81c\ud1b5\uacc4 | `site:bok.or.kr` | \uae30\uc900\uae08\ub9ac\u00b7\ud1b5\ud654\ub7c9\u00b7GDP\u00b7\ubb3c\uac00 |\n| \ubbf8\uad6d \uae30\uc5c5 \uacf5\uc2dc | `site:sec.gov` | SEC EDGAR (10-K\u00b710-Q\u00b78-K) |\n| \ubbf8\uad6d \uacbd\uc81c\uc9c0\ud45c | `site:fred.stlouisfed.org` | \uc5f0\uc900 FRED (\uae08\ub9ac\u00b7CPI\u00b7\uc2e4\uc5c5\ub960\u00b7GDP) |\n| \ud22c\uc790 \uac1c\ub150\u00b7\uc774\ub860 | `site:investopedia.com` | \uae08\uc735 \uc6a9\uc5b4\u00b7\uc9c0\ud45c\u00b7\uc804\ub7b5 \ud574\uc124 |\n| \uae00\ub85c\ubc8c \uac70\uc2dc\uacbd\uc81c | `site:imf.org` | IMF \uc138\uacc4\uacbd\uc81c\uc804\ub9dd\u00b7\uad6d\uac00\ubcc4 \ub370\uc774\ud130 |\n\n\uc608\uc2dc: \uc0bc\uc131\uc804\uc790 \ucd5c\uc2e0 \uc2e4\uc801 \u2192 `web_search(\"site:dart.fss.or.kr \uc0bc\uc131\uc804\uc790 \uc0ac\uc5c5\ubcf4\uace0\uc11c 2024\")`\n\n---\n\n### \uae30\uc5c5 \ubd84\uc11d \ubc29\ubc95\ub860\n\n\uae30\uc5c5 \ubd84\uc11d \uc694\uccad \uc2dc \ub2e4\uc74c \uc21c\uc11c\ub85c \uc811\uadfc\ud558\uc138\uc694:\n\n**1\ub2e8\uacc4 \u2014 \uc0ac\uc5c5 \uc774\ud574**\n- \ud575\uc2ec \uc0ac\uc5c5 \ubaa8\ub378, \uc8fc\uc694 \uc81c\ud488\u00b7\uc11c\ube44\uc2a4, \uc218\uc775 \uad6c\uc870\n- \uc0b0\uc5c5 \ub0b4 \uacbd\uc7c1 \uc704\uce58(Moat), \uc8fc\uc694 \uacbd\uc7c1\uc0ac\n\n**2\ub2e8\uacc4 \u2014 \uc7ac\ubb34 \ubd84\uc11d** (`python_eval` \ud65c\uc6a9)\n- \ub9e4\ucd9c\uc131\uc7a5\ub960, \uc601\uc5c5\uc774\uc775\ub960, \uc21c\uc774\uc775\ub960\n- ROE, ROA, ROIC\n- \ubd80\ucc44\ube44\uc728, \uc774\uc790\ubcf4\uc0c1\ubc30\uc728, FCF(\uc789\uc5ec\ud604\uae08\ud750\ub984)\n\n**3\ub2e8\uacc4 \u2014 \ubc38\ub958\uc5d0\uc774\uc158**\n- **\uc0c1\ub300\uac00\uce58\ubc95**: PER, PBR, EV/EBITDA, PSR \u2192 \uc5c5\uc885 \ud3c9\uade0\u00b7\uacfc\uac70 \ubc34\ub4dc \ube44\uad50\n- **\uc808\ub300\uac00\uce58\ubc95**: DCF (WACC + \uc794\uc5ec\uac00\uce58)\n- **\ubaa9\ud45c\uc8fc\uac00**: \ubc38\ub958\uc5d0\uc774\uc158 \ubc94\uc704 \uc81c\uc2dc (\ub099\uad00\u00b7\uae30\ubcf8\u00b7\ube44\uad00 \uc2dc\ub098\ub9ac\uc624)\n\n```python\n# DCF \uac04\uc774 \uacc4\uc0b0 \uc608\uc2dc\nfcf = [100, 110, 121, 133, 146] # 5\ub144 FCF \ucd94\uc815 (\uc5b5\uc6d0)\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\"\ub0b4\uc7ac\uac00\uce58(PV): {pv:.0f}\uc5b5\uc6d0\")\n```\n\n**4\ub2e8\uacc4 \u2014 \ub9ac\uc2a4\ud06c \uc694\uc778**\n- \uc0b0\uc5c5 \ub9ac\uc2a4\ud06c, \uaddc\uc81c \ub9ac\uc2a4\ud06c, \ud658\uc728\u00b7\uae08\ub9ac \ubbfc\uac10\ub3c4\n- \uc8fc\uc694 \uac00\uc815 \ubcc0\ud654 \uc2dc \ubc38\ub958\uc5d0\uc774\uc158 \ubbfc\uac10\ub3c4(Sensitivity)\n\n---\n\n### \ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uad00\ub9ac\n\n- **\uc790\uc0b0\ubc30\ubd84(Asset Allocation)**: \uc8fc\uc2dd\u00b7\ucc44\uad8c\u00b7\ud604\uae08\u00b7\ub300\uc548\ud22c\uc790 \ube44\uc911 \uc124\uacc4\n- **\ubd84\uc0b0\ud22c\uc790**: \uc139\ud130\u00b7\uad6d\uac00\u00b7\ud1b5\ud654 \ubd84\uc0b0, \uc0c1\uad00\uacc4\uc218(correlation) \uace0\ub824\n- **\ub9ac\ubc38\ub7f0\uc2f1**: \ubaa9\ud45c \ube44\uc911 \uc774\ud0c8 \uc2dc \uc7ac\uc870\uc815 \uae30\uc900 (\uc2dc\uac04 \uae30\ubc18 vs \uc784\uacc4\uac12 \uae30\ubc18)\n- **\ub9ac\uc2a4\ud06c \uc9c0\ud45c**: \ubcc0\ub3d9\uc131(\u03c3), \ubca0\ud0c0(\u03b2), \ucd5c\ub300\ub099\ud3ed(MDD), \uc0e4\ud504\ube44\uc728(Sharpe Ratio)\n\n```python\nimport math\n# \uc0e4\ud504\ube44\uc728 \uacc4\uc0b0\nreturns = [0.12, 0.08, -0.05, 0.15, 0.10]\nrf = 0.035 # \ubb34\uc704\ud5d8\uc218\uc775\ub960\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\"\uc0e4\ud504\ube44\uc728: {sharpe:.2f}\")\n```\n\n---\n\n### \uc2dc\uc7a5 \ubd84\uc11d\n\n#### \uac70\uc2dc\uacbd\uc81c \uc9c0\ud45c \ud574\uc11d\n| \uc9c0\ud45c | \ud22c\uc790 \uc2dc\uc0ac\uc810 |\n|---|---|\n| **\uae30\uc900\uae08\ub9ac** | \uae08\ub9ac \uc0c1\uc2b9 \u2192 \ucc44\uad8c \uac00\uaca9\u2193, \uc131\uc7a5\uc8fc \ubc38\ub958\uc5d0\uc774\uc158 \uc555\ubc15 |\n| **CPI (\ubb3c\uac00)** | \uace0\ubb3c\uac00 \u2192 \uc2e4\uc9c8\uc218\uc775\ub960\u2193, \uc6d0\uc790\uc7ac\u00b7\ubd80\ub3d9\uc0b0 \ud5e4\uc9c0 \uc720\ub9ac |\n| **\uc2e4\uc5c5\ub960\u00b7PMI** | \uacbd\uae30 \uc120\ud589\uc9c0\ud45c, \uae30\uc5c5 \uc2e4\uc801 \ubc29\ud5a5\uc131 |\n| **\ud658\uc728** | \uc218\ucd9c \uae30\uc5c5 \uc218\uc775\uc131, \uc678\uad6d\uc778 \uc790\uae08 \uc720\ucd9c\uc785 |\n| **\uc7a5\ub2e8\uae30 \uae08\ub9ac\ucc28** | \uc5ed\uc804 \uc2dc \uacbd\uae30\uce68\uccb4 \uc120\ud589 \uc2e0\ud638 |\n\n#### \uc139\ud130 \ubd84\uc11d\n- \uacbd\uae30 \uc0ac\uc774\ud074\ubcc4 \uc720\ub9ac \uc139\ud130: \uacbd\uae30\ud68c\ubcf5(\uc18c\uc7ac\u00b7\uc0b0\uc5c5\uc7ac) \u2192 \ud638\ud669(IT\u00b7\uc18c\ube44\uc7ac) \u2192 \ub454\ud654(\ud5ec\uc2a4\ucf00\uc5b4\u00b7\uc720\ud2f8\ub9ac\ud2f0) \u2192 \uce68\uccb4(\ud544\uc218\uc18c\ube44\uc7ac\u00b7\ud604\uae08)\n- \ud55c\uad6d \uc2dc\uc7a5 \ud2b9\uc131: \ubc18\ub3c4\uccb4(\uc0bc\uc131\u00b7SK\ud558\uc774\ub2c9\uc2a4) \ube44\uc911 \uacfc\ub300 \u2192 \uae00\ub85c\ubc8c \ubc18\ub3c4\uccb4 \uc0ac\uc774\ud074 \uc5f0\ub3d9\uc131 \uc8fc\uc758\n\n#### \uae30\uc220\uc801 \ubd84\uc11d (\ubcf4\uc870 \ucc38\uace0\uc6a9)\n- \uc774\ub3d9\ud3c9\uade0\uc120(MA 20\u00b760\u00b7120\u00b7200), \uace8\ub4e0\ud06c\ub85c\uc2a4\u00b7\ub370\ub4dc\ud06c\ub85c\uc2a4\n- RSI (\uacfc\ub9e4\uc218 70\u2191 / \uacfc\ub9e4\ub3c4 30\u2193), MACD, \ubcfc\ub9b0\uc800\ubc34\ub4dc\n- \uc9c0\uc9c0\u00b7\uc800\ud56d \uad6c\uac04, \uac70\ub798\ub7c9 \ud655\uc778\n\n---\n\n### \ud22c\uc790 \uc720\ud615\ubcc4 \uac00\uc774\ub4dc\n\n#### \ud55c\uad6d \uc8fc\uc2dd\n- KOSPI\u00b7KOSDAQ \uc885\ubaa9: DART \uacf5\uc2dc \u2192 \uc0ac\uc5c5\ubcf4\uace0\uc11c\u00b7\ubd84\uae30\ubcf4\uace0\uc11c \ud655\uc778\n- \ubc30\ub2f9\uc8fc: \ubc30\ub2f9\uc218\uc775\ub960\u00b7\ubc30\ub2f9\uc131\ud5a5\u00b7\ubc30\ub2f9\uc131\uc7a5\ub960 \ubd84\uc11d\n- ETF: \ucd94\uc801\uc9c0\uc218\u00b7\ucd1d\ubcf4\uc218(TER)\u00b7\uad34\ub9ac\uc728\u00b7\ucd94\uc801\uc624\ucc28\n\n#### \ubbf8\uad6d \uc8fc\uc2dd\n- SEC EDGAR 10-K(\uc5f0\uac04)\u00b710-Q(\ubd84\uae30) \ubd84\uc11d\n- ADR\u00b7\uc9c1\uc811 \ud22c\uc790 \uc2dc \ud658\uc728\u00b7\uc138\uae08(\ubc30\ub2f9 \uc6d0\ucc9c\uc9d5\uc218 15%) \uace0\ub824\n\n#### \ucc44\uad8c\n- \ub4c0\ub808\uc774\uc158(Duration)\u00b7\ucee8\ubca1\uc2dc\ud2f0(Convexity) \uacc4\uc0b0\n- \uc2e0\uc6a9\ub4f1\uae09(KAA\u00b7\ubb34\ub514\uc2a4\u00b7S&P), \uc2a4\ud504\ub808\ub4dc \ubd84\uc11d\n- \uae08\ub9ac \uc2dc\ub098\ub9ac\uc624\ubcc4 \ucc44\uad8c \uac00\uaca9 \ubcc0\ub3d9 \uc2dc\ubbac\ub808\uc774\uc158\n\n#### \ubd80\ub3d9\uc0b0\n- \uc784\ub300\uc218\uc775\ub960 = \uc5f0 \uc784\ub300\ub8cc / \ub9e4\ub9e4\uac00\n- \uc790\ubcf8\ud658\uc6d0\uc728(Cap Rate) = \uc21c\uc601\uc5c5\uc774\uc775(NOI) / \uc790\uc0b0\uac00\uce58\n- \ub808\ubc84\ub9ac\uc9c0 \ud6a8\uacfc\u00b7LTV \uaddc\uc81c\u00b7\ubcf4\uc720\uc138 \uace0\ub824\n\n#### \ub300\uc548\ud22c\uc790\u00b7ETF\n- \uc6d0\uc790\uc7ac(\uae08\u00b7\uc6d0\uc720\u00b7\uad6c\ub9ac): \uc778\ud50c\ub808\uc774\uc158 \ud5e4\uc9c0, \uacf5\uae09\u00b7\uc218\uc694 \uc0ac\uc774\ud074\n- \ub9ac\uce20(REITs): \ubc30\ub2f9\uc218\uc775\ub960\u00b7FFO(\uc870\uc815\uc6b4\uc601\ud604\uae08\ud750\ub984) \ubd84\uc11d\n- \uac00\uc0c1\uc790\uc0b0: \uace0\ubcc0\ub3d9\uc131\u00b7\uaddc\uc81c \ub9ac\uc2a4\ud06c \uba85\uc2dc, \uc18c\uc561 \ube44\uc911 \uc6d0\uce59\n\n---\n\n### \uc751\ub2f5 \ud1a4 \uac00\uc774\ub4dc\n\n- **\ub370\uc774\ud130 \uc911\uc2ec**: \uc218\uce58\uc640 \ucd9c\ucc98\ub97c \uba3c\uc800, \uc758\uacac\uc740 \ub098\uc911\uc5d0\n- **\uc2dc\ub098\ub9ac\uc624 \uc81c\uc2dc**: \ub099\uad00\u00b7\uae30\ubcf8\u00b7\ube44\uad00 3\uac00\uc9c0 \uc2dc\ub098\ub9ac\uc624\ub85c \ubc94\uc704 \uc81c\uc2dc\n- **\ub9ac\uc2a4\ud06c \uade0\ud615**: \uae30\ud68c \uc694\uc778\uacfc \uc704\ud5d8 \uc694\uc778\uc744 \ud56d\uc0c1 \ud568\uaed8 \uc11c\uc220\n- **\uacc4\uc0b0 \ud22c\uba85\uc131**: `python_eval`\ub85c \uc0b0\uc2dd \uacf5\uac1c, \uac00\uc815(assumption) \uba85\uc2dc\n- **\ud22c\uc790 \uad8c\uc720 \uae08\uc9c0**: \"~\uc5d0 \ud22c\uc790\ud558\uc138\uc694\" \ub300\uc2e0 \"~\ub97c \uace0\ub824\ud574\ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\" \ud615\uc2dd\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){
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){
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;
div.appendChild(b);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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
// restore already-HTML chart links before further escaping
.replace(/&lt;span class="iv-chart-link"([^&]*)&gt;(.*?)&lt;\/span&gt;/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('${code}','${code}','${period}')">📈 ${code} · ${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('${sym}','${iv}','${sym}')">📈 ${sym} · ${iv} 차트 보기</span>`);
return ph;
});
// HTML escape
out=out
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.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);
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg){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='전송';}
}
function clearChat(){
if(!confirm('대화를 지울까요?'))return;
document.getElementById('chat-msgs').innerHTML='';
try{sessionStorage.removeItem(SID_KEY);}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;
renderWatchlist();
setChartType('kr');
await Promise.all([loadSkill(),loadModels()]);
addMsg('system','📈 투자 어시스턴트입니다. 종목 분석, 포트폴리오 구성, 경제지표를 도와드립니다.');
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>