Files
stockautomtion/app/templates/index.html
gerd 182c2b38e6 feat: config.yaml 기반 설정 마이그레이션 및 대시보드/종목조회 기능 추가
- config.yaml에서 직접 KIS 설정 관리 (BaseSettings 제거)
- 대시보드 KIS 계좌 잔고 실시간 연동, 로컬 DB 폴백
- 종목 조회 페이지: 시세/호가/일봉차트 (canvas) 구현
- 호가 REST API 엔드포인트 (/api/stocks/{code}/orderbook) 추가
- 토큰 캐시(.auth_cache.json) 저장/복원, 1분 재시도 제한
- KIS WebSocket 자동 연결 + 재연결 로직
- httpx 타임아웃 10초→30초, 라우터 타임아웃 핸들링 (504)
- rate limit: requests_per_second 2.0 (모의투자 초당 2건)
- 사이드바 3메뉴: 실거래 / 과거기록 / 종목조회
2026-07-17 15:25:34 +09:00

556 lines
30 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Automation Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; display: flex; min-height: 100vh; }
.sidebar { width: 200px; background: #161b22; border-right: 1px solid #30363d; display: flex; flex-direction: column; flex-shrink: 0; }
.sidebar-header { padding: 16px; border-bottom: 1px solid #30363d; }
.sidebar-header h1 { font-size: 16px; color: #58a6ff; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a { display: flex; align-items: center; gap: 10px; padding: 10px 16px; color: #8b949e; text-decoration: none; font-size: 14px; transition: background 0.15s; }
.sidebar-nav a:hover { background: #1c2128; color: #c9d1d9; }
.sidebar-nav a.active { background: #1c2128; color: #58a6ff; border-right: 2px solid #58a6ff; }
.sidebar-nav a .icon { width: 18px; text-align: center; font-size: 15px; }
.sidebar-footer { padding: 12px 16px; border-top: 1px solid #30363d; font-size: 12px; color: #484f58; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { background: #161b22; padding: 12px 24px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.topbar .status { font-size: 13px; color: #8b949e; display: flex; align-items: center; gap: 6px; }
.content { flex: 1; padding: 24px; overflow-y: auto; }
.page { display: none; max-width: 1200px; margin: 0 auto; }
.page.active { display: block; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
.card h3 { font-size: 13px; color: #8b949e; margin-bottom: 8px; text-transform: uppercase; }
.card .value { font-size: 28px; font-weight: 700; }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; }
.section h2 { font-size: 16px; margin-bottom: 12px; color: #c9d1d9; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 12px; color: #8b949e; border-bottom: 1px solid #30363d; }
td { padding: 8px 12px; border-bottom: 1px solid #21262d; }
tr:hover { background: #1c2128; }
.badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.badge.buy { background: #1a3a2a; color: #3fb950; }
.badge.sell { background: #3a1a1a; color: #f85149; }
.badge.pending { background: #3a3a1a; color: #d29922; }
.badge.filled { background: #1a2a3a; color: #58a6ff; }
.btn { padding: 8px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; font-size: 13px; }
.btn:hover { background: #30363d; }
.btn.primary { background: #238636; border-color: #2ea043; color: #fff; }
.btn.primary:hover { background: #2ea043; }
.form-row { display: flex; gap: 12px; align-items: end; margin-bottom: 12px; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 12px; color: #8b949e; }
.form-group input, .form-group select { padding: 6px 10px; border-radius: 4px; border: 1px solid #30363d; background: #0d1117; color: #c9d1d9; font-size: 13px; }
#ws-status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
#ws-status.connected { background: #3fb950; }
#ws-status.disconnected { background: #f85149; }
.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; display: none; }
.alert.warning { background: #3a2a00; border: 1px solid #d29922; color: #e3b341; }
.alert.warning.show { display: block; }
.source-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
.source-tag.kis { background: #1a2a3a; color: #58a6ff; }
.source-tag.local { background: #2a2a1a; color: #d29922; }
.filter-row { display: flex; gap: 12px; align-items: end; margin-bottom: 16px; flex-wrap: wrap; }
.positive { color: #3fb950; }
.negative { color: #f85149; }
.stock-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.stock-header .name { font-size: 22px; font-weight: 700; color: #e1e4e8; }
.stock-header .code { font-size: 14px; color: #8b949e; }
.stock-price-main { font-size: 32px; font-weight: 700; margin-bottom: 4px; }
.stock-change { font-size: 15px; margin-bottom: 20px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.orderbook-table { width: 100%; }
.orderbook-table td { text-align: right; padding: 5px 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
.orderbook-table td:first-child { text-align: center; color: #8b949e; width: 40px; }
.orderbook-table .ask-row td { color: #f85149; }
.orderbook-table .bid-row td { color: #3fb950; }
.orderbook-table .mid-row td { color: #8b949e; font-weight: 600; border-top: 1px solid #30363d; border-bottom: 1px solid #30363d; }
.chart-canvas { width: 100%; height: 300px; background: #0d1117; border-radius: 6px; margin-top: 12px; }
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<h1>Stock Automation</h1>
</div>
<nav class="sidebar-nav">
<a href="#" class="active" data-page="trading">
<span class="icon">&#9881;</span> 실거래
</a>
<a href="#" data-page="history">
<span class="icon">&#128203;</span> 과거 기록 조회
</a>
<a href="#" data-page="stock-info">
<span class="icon">&#128200;</span> 종목 조회
</a>
</nav>
<div class="sidebar-footer">v0.1.0</div>
</aside>
<div class="main">
<div class="topbar">
<div style="font-size: 14px; color: #8b949e;" id="page-title">실거래</div>
<div class="status">
<span id="ws-status" class="disconnected"></span>
<span id="status-text">연결 중...</span>
</div>
</div>
<div class="content">
<!-- 실거래 페이지 -->
<div id="page-trading" class="page active">
<div id="auth-alert" class="alert warning">
API 키가 설정되지 않았거나 유효하지 않습니다. config.yaml 파일에 KIS 설정을 확인하세요.
현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다.
</div>
<div class="grid">
<div class="card">
<h3>총 투자금</h3>
<div class="value" id="total-invested">-</div>
</div>
<div class="card">
<h3>평가금액</h3>
<div class="value" id="total-evaluated">-</div>
</div>
<div class="card">
<h3>총 수익</h3>
<div class="value" id="total-profit">-</div>
</div>
<div class="card">
<h3>수익률</h3>
<div class="value" id="profit-rate">-</div>
</div>
</div>
<div class="section">
<h2>보유 종목</h2>
<table>
<thead>
<tr>
<th>종목코드</th><th>종목명</th><th>수량</th>
<th>평균단가</th><th>현재가</th><th>수익</th><th>수익률</th>
</tr>
</thead>
<tbody id="holdings-table"></tbody>
</table>
</div>
<div class="section">
<h2>최근 매매 내역</h2>
<table>
<thead>
<tr>
<th>시간</th><th>종목코드</th><th>구분</th>
<th>수량</th><th>가격</th><th>상태</th>
</tr>
</thead>
<tbody id="trades-table"></tbody>
</table>
</div>
<div class="section">
<h2>매매 주문</h2>
<div class="form-row">
<div class="form-group">
<label>종목코드</label>
<input id="order-code" type="text" placeholder="005930" />
</div>
<div class="form-group">
<label>구분</label>
<select id="order-side">
<option value="buy">매수</option>
<option value="sell">매도</option>
</select>
</div>
<div class="form-group">
<label>수량</label>
<input id="order-qty" type="number" value="1" min="1" />
</div>
<div class="form-group">
<label>가격 (0=시장가)</label>
<input id="order-price" type="number" value="0" />
</div>
<button class="btn primary" onclick="placeOrder()">주문</button>
</div>
</div>
</div>
<!-- 과거 기록 조회 페이지 -->
<div id="page-history" class="page">
<div class="section">
<h2>과거 매매 기록 조회 <span id="history-source" class="source-tag"></span></h2>
<div class="filter-row">
<div class="form-group">
<label>시작일</label>
<input id="hist-start" type="date" />
</div>
<div class="form-group">
<label>종료일</label>
<input id="hist-end" type="date" />
</div>
<div class="form-group">
<label>종목코드 (선택)</label>
<input id="hist-code" type="text" placeholder="005930" />
</div>
<button class="btn primary" onclick="loadTradeHistory()">조회</button>
</div>
<table>
<thead>
<tr>
<th>시간</th><th>종목코드</th><th>종목명</th><th>구분</th>
<th>수량</th><th>체결가</th><th>주문금액</th><th>상태</th>
</tr>
</thead>
<tbody id="history-table"></tbody>
</table>
<div id="history-empty" style="display:none; text-align:center; padding:24px; color:#8b949e;">
조회된 매매 기록이 없습니다.
</div>
</div>
</div>
<!-- 종목 조회 페이지 -->
<div id="page-stock-info" class="page">
<div class="section">
<div class="filter-row">
<div class="form-group">
<label>종목코드</label>
<input id="stock-search-code" type="text" placeholder="005930" />
</div>
<button class="btn primary" onclick="loadStockInfo()">조회</button>
</div>
</div>
<div id="stock-info-content" style="display:none;">
<div class="section">
<div class="stock-header">
<span class="name" id="si-name">-</span>
<span class="code" id="si-code">-</span>
</div>
<div class="stock-price-main" id="si-price">-</div>
<div class="stock-change" id="si-change">-</div>
<div class="info-grid">
<div>
<table>
<tr><th style="width:100px;">시가</th><td id="si-open">-</td></tr>
<tr><th>고가</th><td id="si-high">-</td></tr>
<tr><th>저가</th><td id="si-low">-</td></tr>
<tr><th>거래량</th><td id="si-volume">-</td></tr>
<tr><th>거래대금</th><td id="si-amount">-</td></tr>
</table>
</div>
<div>
<table class="orderbook-table">
<thead><tr><th>#</th><th>매도</th><th>수량</th></tr></thead>
<tbody id="ob-asks"></tbody>
<tr class="mid-row"><td></td><td id="ob-mid">-</td><td></td></tr>
<thead><tr><th>#</th><th>매수</th><th>수량</th></tr></thead>
<tbody id="ob-bids"></tbody>
</table>
</div>
</div>
</div>
<div class="section">
<h2>일봉 차트</h2>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button class="btn" onclick="loadChart(30)">30일</button>
<button class="btn" onclick="loadChart(60)">60일</button>
<button class="btn" onclick="loadChart(120)">120일</button>
</div>
<canvas id="chart-canvas" class="chart-canvas"></canvas>
</div>
</div>
<div id="stock-info-empty" style="display:none; text-align:center; padding:40px; color:#8b949e;">
종목코드를 입력하고 조회 버튼을 누르세요.
</div>
</div>
</div>
</div>
<script>
const API = '';
let ws;
let historyLoaded = false;
function formatPrice(n) { return n ? n.toLocaleString('ko-KR') + '원' : '-'; }
function formatRate(n) { return n >= 0 ? '+' + n.toFixed(2) + '%' : n.toFixed(2) + '%'; }
function formatDate(d) {
const dt = d ? new Date(d) : null;
if (!dt || isNaN(dt.getTime())) return '-';
return dt.toLocaleString('ko-KR');
}
document.querySelectorAll('.sidebar-nav a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('.sidebar-nav a').forEach(a => a.classList.remove('active'));
link.classList.add('active');
const page = link.dataset.page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.getElementById('page-title').textContent = link.textContent.trim();
if (page === 'trading') loadDashboard();
if (page === 'history' && !historyLoaded) { loadTradeHistory(); historyLoaded = true; }
});
});
async function loadDashboard() {
try {
const res = await fetch(API + '/api/dashboard/');
const data = await res.json();
if (!data.authenticated) document.getElementById('auth-alert').classList.add('show');
const s = data.summary;
document.getElementById('total-invested').textContent = formatPrice(s.total_invested);
document.getElementById('total-evaluated').textContent = formatPrice(s.total_evaluated);
const profitEl = document.getElementById('total-profit');
profitEl.textContent = formatPrice(s.total_profit);
profitEl.className = 'value ' + (s.total_profit >= 0 ? 'positive' : 'negative');
const rateEl = document.getElementById('profit-rate');
rateEl.textContent = formatRate(s.profit_rate);
rateEl.className = 'value ' + (s.profit_rate >= 0 ? 'positive' : 'negative');
document.getElementById('holdings-table').innerHTML = data.holdings.map(h => `
<tr>
<td>${h.stock_code}</td><td>${h.stock_name}</td><td>${h.qty}</td>
<td>${formatPrice(h.avg_price)}</td><td>${formatPrice(h.current_price)}</td>
<td class="${h.profit >= 0 ? 'positive' : 'negative'}">${formatPrice(h.profit)}</td>
<td class="${h.profit_rate >= 0 ? 'positive' : 'negative'}">${formatRate(h.profit_rate)}</td>
</tr>
`).join('');
document.getElementById('trades-table').innerHTML = data.recent_trades.map(t => `
<tr>
<td>${t.created_at ? new Date(t.created_at).toLocaleString('ko-KR') : '-'}</td>
<td>${t.stock_code}</td>
<td><span class="badge ${t.side}">${t.side === 'buy' ? '매수' : '매도'}</span></td>
<td>${t.qty}</td><td>${formatPrice(t.price)}</td>
<td><span class="badge ${t.status}">${t.status}</span></td>
</tr>
`).join('');
} catch (e) { console.error('대시보드 로드 실패:', e); }
}
async function placeOrder() {
const body = {
stock_code: document.getElementById('order-code').value,
side: document.getElementById('order-side').value,
qty: parseInt(document.getElementById('order-qty').value),
price: parseInt(document.getElementById('order-price').value),
};
try {
const res = await fetch(API + '/api/trading/order', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body),
});
const data = await res.json();
alert(data.rt_cd === '0' ? '주문 성공: ' + data.order_no : '주문 실패: ' + data.msg);
loadDashboard();
} catch (e) { alert('주문 오류: ' + e.message); }
}
async function loadTradeHistory() {
const start = document.getElementById('hist-start').value.replace(/-/g, '');
const end = document.getElementById('hist-end').value.replace(/-/g, '');
const code = document.getElementById('hist-code').value.trim();
const params = new URLSearchParams();
if (start) params.set('start_date', start);
if (end) params.set('end_date', end);
if (code) params.set('stock_code', code);
params.set('limit', '200');
const sourceTag = document.getElementById('history-source');
const tbody = document.getElementById('history-table');
const emptyMsg = document.getElementById('history-empty');
try {
const res = await fetch(API + '/api/trading/history?' + params.toString());
const data = await res.json();
const trades = data.trades || [];
sourceTag.textContent = data.source === 'kis' ? 'KIS API' : '로컬 DB';
sourceTag.className = 'source-tag ' + data.source;
if (trades.length === 0) { tbody.innerHTML = ''; emptyMsg.style.display = 'block'; return; }
emptyMsg.style.display = 'none';
tbody.innerHTML = trades.map(t => {
const time = formatDate(t.created_at || t.filled_at || '');
const orderAmt = t.order_amount ? formatPrice(t.order_amount) : formatPrice(t.qty * t.price);
return `<tr>
<td>${time}</td>
<td>${t.stock_code}</td>
<td>${t.stock_name || '-'}</td>
<td><span class="badge ${t.side}">${t.side === 'buy' ? '매수' : '매도'}</span></td>
<td>${t.qty}</td>
<td>${formatPrice(t.price)}</td>
<td>${orderAmt}</td>
<td><span class="badge ${t.status}">${t.status === 'filled' ? '체결' : t.status}</span></td>
</tr>`;
}).join('');
} catch (e) {
console.error('매매 기록 조회 실패:', e);
tbody.innerHTML = '';
emptyMsg.style.display = 'block';
emptyMsg.textContent = '매매 기록을 불러오는 중 오류가 발생했습니다.';
}
}
function initHistoryDates() {
const today = new Date();
const monthAgo = new Date();
monthAgo.setDate(monthAgo.getDate() - 30);
document.getElementById('hist-end').value = today.toISOString().slice(0, 10);
document.getElementById('hist-start').value = monthAgo.toISOString().slice(0, 10);
}
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`);
ws.onclose = () => setTimeout(connectWS, 3000);
}
async function loadStatus() {
try {
const res = await fetch(API + '/api/dashboard/status');
const data = await res.json();
const el = document.getElementById('ws-status');
const text = document.getElementById('status-text');
if (data.realtime_connected) {
el.className = 'connected';
text.textContent = '실시간 연결됨';
} else {
el.className = 'disconnected';
text.textContent = data.authenticated ? '연결 끊김' : '인증 필요';
}
} catch (e) { console.error('상태 조회 실패:', e); }
}
async function loadStockInfo() {
const code = document.getElementById('stock-search-code').value.trim();
if (!code) return;
const content = document.getElementById('stock-info-content');
const empty = document.getElementById('stock-info-empty');
content.style.display = 'none';
empty.style.display = 'none';
try {
const [priceRes, obRes] = await Promise.all([
fetch(API + `/api/stocks/${code}/price`),
fetch(API + `/api/stocks/${code}/orderbook`),
]);
if (!priceRes.ok) { empty.style.display = 'block'; empty.textContent = '시세 조회 실패'; return; }
const p = await priceRes.json();
document.getElementById('si-name').textContent = p.stock_name || code;
document.getElementById('si-code').textContent = code;
const priceEl = document.getElementById('si-price');
priceEl.textContent = formatPrice(p.current_price);
priceEl.className = 'stock-price-main ' + (p.change_price >= 0 ? 'positive' : 'negative');
const chEl = document.getElementById('si-change');
const sign = p.change_price >= 0 ? '+' : '';
chEl.textContent = `${sign}${p.change_price.toLocaleString()} (${sign}${p.change_rate.toFixed(2)}%)`;
chEl.className = 'stock-change ' + (p.change_price >= 0 ? 'positive' : 'negative');
document.getElementById('si-open').textContent = formatPrice(p.open_price);
document.getElementById('si-high').textContent = formatPrice(p.high_price);
document.getElementById('si-low').textContent = formatPrice(p.low_price);
document.getElementById('si-volume').textContent = p.volume ? p.volume.toLocaleString() : '-';
document.getElementById('si-amount').textContent = p.trade_amount ? formatPrice(p.trade_amount) : '-';
if (obRes.ok) {
const ob = await obRes.json();
const askTbody = document.getElementById('ob-asks');
askTbody.innerHTML = ob.ask_prices.slice().reverse().map((price, i) => {
const vol = ob.ask_volumes[4 - i] || 0;
return `<tr class="ask-row"><td>${5 - i}</td><td>${price ? price.toLocaleString() : '-'}</td><td>${vol.toLocaleString()}</td></tr>`;
}).join('');
document.getElementById('ob-mid').textContent = p.current_price ? p.current_price.toLocaleString() : '-';
const bidTbody = document.getElementById('ob-bids');
bidTbody.innerHTML = ob.bid_prices.map((price, i) => {
const vol = ob.bid_volumes[i] || 0;
return `<tr class="bid-row"><td>${i + 1}</td><td>${price ? price.toLocaleString() : '-'}</td><td>${vol.toLocaleString()}</td></tr>`;
}).join('');
}
content.style.display = 'block';
loadChart(30);
} catch (e) {
console.error('종목 조회 실패:', e);
empty.style.display = 'block';
empty.textContent = '조회 중 오류가 발생했습니다.';
}
}
async function loadChart(days) {
const code = document.getElementById('stock-search-code').value.trim();
if (!code) return;
try {
const res = await fetch(API + `/api/stocks/${code}/chart?days=${days}`);
const data = await res.json();
if (!data.length) return;
drawChart(data);
} catch (e) { console.error('차트 조회 실패:', e); }
}
function drawChart(data) {
const canvas = document.getElementById('chart-canvas');
const ctx = canvas.getContext('2d');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = 300;
const W = canvas.width, H = canvas.height;
const pad = { top: 20, right: 60, bottom: 30, left: 10 };
const chartW = W - pad.left - pad.right;
const chartH = H - pad.top - pad.bottom;
ctx.clearRect(0, 0, W, H);
const prices = data.flatMap(d => [d.high, d.low]);
const minP = Math.min(...prices);
const maxP = Math.max(...prices);
const range = maxP - minP || 1;
const barW = Math.max(1, (chartW / data.length) - 1);
const gap = chartW / data.length;
const toY = (p) => pad.top + chartH - ((p - minP) / range) * chartH;
ctx.strokeStyle = '#30363d';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = pad.top + (chartH / 4) * i;
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke();
const val = maxP - (range / 4) * i;
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left';
ctx.fillText(val.toLocaleString(), W - pad.right + 4, y + 4);
}
data.forEach((d, i) => {
const x = pad.left + gap * i + gap / 2;
const isUp = d.close >= d.open;
ctx.strokeStyle = isUp ? '#3fb950' : '#f85149';
ctx.fillStyle = isUp ? '#3fb950' : '#f85149';
ctx.beginPath(); ctx.moveTo(x, toY(d.high)); ctx.lineTo(x, toY(d.low)); ctx.stroke();
const oY = toY(d.open), cY = toY(d.close);
const bodyTop = Math.min(oY, cY);
const bodyH = Math.max(Math.abs(oY - cY), 1);
ctx.fillRect(x - barW / 2, bodyTop, barW, bodyH);
});
if (data.length > 0) {
const step = Math.max(1, Math.floor(data.length / 6));
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
for (let i = 0; i < data.length; i += step) {
const x = pad.left + gap * i + gap / 2;
const label = data[i].date;
ctx.fillText(label.slice(4, 6) + '/' + label.slice(6, 8), x, H - 8);
}
}
}
loadDashboard();
loadStatus();
setInterval(loadDashboard, 10000);
setInterval(loadStatus, 5000);
connectWS();
initHistoryDates();
document.getElementById('stock-search-code').addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadStockInfo();
});
</script>
</body>
</html>