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메뉴: 실거래 / 과거기록 / 종목조회
This commit is contained in:
@@ -60,6 +60,19 @@
|
||||
.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>
|
||||
@@ -74,6 +87,9 @@
|
||||
<a href="#" data-page="history">
|
||||
<span class="icon">📋</span> 과거 기록 조회
|
||||
</a>
|
||||
<a href="#" data-page="stock-info">
|
||||
<span class="icon">📈</span> 종목 조회
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">v0.1.0</div>
|
||||
</aside>
|
||||
@@ -91,7 +107,7 @@
|
||||
<!-- 실거래 페이지 -->
|
||||
<div id="page-trading" class="page active">
|
||||
<div id="auth-alert" class="alert warning">
|
||||
API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요.
|
||||
API 키가 설정되지 않았거나 유효하지 않습니다. config.yaml 파일에 KIS 설정을 확인하세요.
|
||||
현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다.
|
||||
</div>
|
||||
|
||||
@@ -200,6 +216,65 @@
|
||||
</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>
|
||||
|
||||
@@ -336,25 +411,145 @@
|
||||
function connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`);
|
||||
ws.onopen = () => {
|
||||
document.getElementById('ws-status').className = 'connected';
|
||||
document.getElementById('status-text').textContent = '실시간 연결됨';
|
||||
};
|
||||
ws.onclose = () => {
|
||||
document.getElementById('ws-status').className = 'disconnected';
|
||||
document.getElementById('status-text').textContent = '연결 끊김 - 재연결 중...';
|
||||
setTimeout(connectWS, 3000);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'price') loadDashboard();
|
||||
};
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user