initial commit
This commit is contained in:
446
frontend/index.html
Normal file
446
frontend/index.html
Normal file
@@ -0,0 +1,446 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KisStock AI Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="logo">KisStock AI</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="active">대시보드</a>
|
||||
<a href="/trade">매매</a>
|
||||
<a href="/news">AI뉴스</a>
|
||||
<a href="/stocks">종목</a>
|
||||
<a href="/settings">설정</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<!-- Balance Section -->
|
||||
<div class="card">
|
||||
<h2>💰 나의 자산</h2>
|
||||
<div id="balance-info" class="loading-container">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="text-muted">총 평가금액</div>
|
||||
<div class="h2" id="total-eval">Loading...</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted">예수금</div>
|
||||
<div class="h2" id="deposit">Loading...</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted">손익</div>
|
||||
<div class="h2" id="total-profit">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Watchlist Section -->
|
||||
<div class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h2>⭐ 관심 종목</h2>
|
||||
<a href="/stocks" class="btn" style="padding:0.2rem 0.5rem; font-size:0.8rem;">종목 추가</a>
|
||||
</div>
|
||||
<div id="watchlist-container" class="grid" style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap:1rem; margin-top:1rem;">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holdings Section -->
|
||||
<div class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h2>📊 보유 종목</h2>
|
||||
<div>
|
||||
<button class="btn btn-sm" onclick="refreshHoldings()" style="margin-right:8px; padding:4px 8px; font-size:0.8rem; background:#475569;">🔄 조회</button>
|
||||
<button id="btn-domestic" class="active-tab" onclick="showHoldingsTab('domestic')">국내</button>
|
||||
<button id="btn-overseas" class="inactive-tab" onclick="showHoldingsTab('overseas')">해외(NASD)</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.active-tab { background: var(--accent-color); color:white; border:none; padding:4px 8px; border-radius:4px; font-weight:bold; cursor:pointer; }
|
||||
.inactive-tab { background: transparent; color:#888; border:1px solid #555; padding:4px 8px; border-radius:4px; cursor:pointer; }
|
||||
</style>
|
||||
<div style="overflow-x:auto;">
|
||||
<table id="holdings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목명</th>
|
||||
<th>잔고</th>
|
||||
<th>현재가</th>
|
||||
<th>손익률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Rows -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Orders -->
|
||||
<div class="card">
|
||||
<h2>📝 최근 주문 내역</h2>
|
||||
<div style="overflow-x:auto;">
|
||||
<table id="orders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>가격</th>
|
||||
<th>수량</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentHoldingsTab = 'domestic';
|
||||
let lastFilledCount = -1;
|
||||
|
||||
function showHoldingsTab(tab) {
|
||||
currentHoldingsTab = tab;
|
||||
document.getElementById('btn-domestic').className = tab === 'domestic' ? 'active-tab' : 'inactive-tab';
|
||||
document.getElementById('btn-overseas').className = tab === 'overseas' ? 'active-tab' : 'inactive-tab';
|
||||
|
||||
// Clear table and fetch immediately
|
||||
document.querySelector('#holdings-table tbody').innerHTML = '<tr><td colspan="4">Loading...</td></tr>';
|
||||
refreshHoldings();
|
||||
}
|
||||
|
||||
async function refreshHoldings() {
|
||||
const btn = document.querySelector('button[onclick="refreshHoldings()"]');
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = "⏳ 갱신 중...";
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
// Trigger Backend Refresh
|
||||
await fetch('/api/balance/refresh', { method: 'POST' });
|
||||
|
||||
// Then Load Data
|
||||
if(currentHoldingsTab === 'domestic') {
|
||||
await fetchBalance('manual_btn');
|
||||
} else {
|
||||
await fetchOverseasBalance();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
alert("갱신 실패");
|
||||
} finally {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWatchlist() {
|
||||
try {
|
||||
const res = await fetch('/api/watchlist');
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('watchlist-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Switch to List/Table View
|
||||
container.style.display = "block"; // Remove grid
|
||||
container.style.width = "100%";
|
||||
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = "<div class='text-muted' style='text-align:center; padding:2rem;'>등록된 관심 종목이 없습니다.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Table Structure
|
||||
const table = document.createElement('table');
|
||||
table.style.width = "100%";
|
||||
table.style.borderCollapse = "collapse";
|
||||
table.innerHTML = `
|
||||
<thead style="border-bottom: 1px solid rgba(255,255,255,0.1); color:#94a3b8; font-size:0.9rem;">
|
||||
<tr>
|
||||
<th style="padding:1rem; text-align:left;">종목</th>
|
||||
<th style="padding:1rem; text-align:right;">현재가</th>
|
||||
<th style="padding:1rem; text-align:right;">등락률</th>
|
||||
<th style="padding:1rem; text-align:center;">빠른 매매</th>
|
||||
<th style="padding:1rem; text-align:center;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
|
||||
const tbody = table.querySelector('tbody');
|
||||
|
||||
data.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.id = `watch-${item.code}`;
|
||||
tr.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
|
||||
|
||||
const isOverseas = ['NASD', 'NYSE', 'AMEX'].includes(item.market);
|
||||
const marketBadge = `<span class="badge ${isOverseas?'text-danger':'text-success'}">${item.market}</span>`;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="padding:1rem;">
|
||||
<div style="font-weight:bold; font-size:1.1rem;">${item.name}</div>
|
||||
<div style="font-size:0.8rem; color:#64748b;">${item.code} ${marketBadge}</div>
|
||||
</td>
|
||||
<td style="padding:1rem; text-align:right;">
|
||||
<span class="price-tag" style="font-size:1.2rem; font-weight:bold;">-</span>
|
||||
</td>
|
||||
<td style="padding:1rem; text-align:right;">
|
||||
<span class="rate-tag" style="font-size:1rem;">0.00%</span>
|
||||
</td>
|
||||
<td style="padding:1rem; text-align:center;">
|
||||
<div style="display:flex; gap:0.5rem; justify-content:center; align-items:center;">
|
||||
<button class="btn btn-primary" style="padding:0.4rem 0.8rem;" onclick="goToTrade('${item.code}', '${item.market}', 'buy')">매수</button>
|
||||
<button class="btn btn-danger" style="padding:0.4rem 0.8rem;" onclick="goToTrade('${item.code}', '${item.market}', 'sell')">매도</button>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:1rem; text-align:center;">
|
||||
<button class="btn btn-danger" onclick="removeWatchlist('${item.code}')" style="padding:0.4rem; font-size:0.8rem;">삭제</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
|
||||
// Subscribe via WS (If Monitored)
|
||||
if(item.is_monitoring && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`sub:${item.code}`);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(table);
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
function goToTrade(code, market, type) {
|
||||
window.location.href = `/trade.html?code=${code}&market=${market}&type=${type}`;
|
||||
}
|
||||
|
||||
async function removeWatchlist(code) {
|
||||
if(!confirm("삭제하시겠습니까?")) return;
|
||||
await fetch(`/api/watchlist/${code}`, { method: 'DELETE' });
|
||||
fetchWatchlist();
|
||||
}
|
||||
|
||||
async function fetchOverseasBalance() {
|
||||
try {
|
||||
const res = await fetch('/api/balance/overseas');
|
||||
if(!res.ok) throw new Error("Check Server Logs");
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.querySelector('#holdings-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (data.output1 && data.output1.length > 0) {
|
||||
data.output1.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
const name = item.ovrs_item_name || item.prdt_name || item.ovrs_pdno;
|
||||
const qty = item.ovrs_cblc_qty || item.ord_psbl_qty || item.hldg_qty;
|
||||
const price = item.now_pric2 || item.prpr || 0;
|
||||
const profitRate = parseFloat(item.evlu_pfls_rt || 0);
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${name}</td>
|
||||
<td>${qty}</td>
|
||||
<td>$${parseFloat(price).toLocaleString()}</td>
|
||||
<td class="${profitRate >= 0 ? 'text-success' : 'text-danger'}">${profitRate}%</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4">해외 보유 종목이 없습니다.</td></tr>';
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
document.querySelector('#holdings-table tbody').innerHTML = `<tr><td colspan="4">조회 실패/보유없음</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(source = 'unknown') {
|
||||
console.log(`Fetching balance via ${source}`);
|
||||
try {
|
||||
const res = await fetch(`/api/balance?source=${source}`);
|
||||
if(!res.ok) throw new Error("Server Error");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.rt_cd && data.rt_cd !== "0") {
|
||||
throw new Error(data.msg1 || "Unknown API Error");
|
||||
}
|
||||
|
||||
// Keep showing asset summary even if domestic tab is not active
|
||||
if (data.output2 && data.output2[0]) {
|
||||
const output2 = data.output2[0];
|
||||
document.getElementById('total-eval').innerText = parseInt(output2.tot_evlu_amt).toLocaleString() + "원";
|
||||
document.getElementById('deposit').innerText = parseInt(output2.dnca_tot_amt).toLocaleString() + "원";
|
||||
|
||||
const profit = parseInt(output2.evlu_pfls_smtl_amt);
|
||||
const elProfit = document.getElementById('total-profit');
|
||||
elProfit.innerText = profit.toLocaleString() + "원";
|
||||
elProfit.className = profit >= 0 ? "h2 text-success" : "h2 text-danger";
|
||||
}
|
||||
|
||||
if (currentHoldingsTab === 'domestic') {
|
||||
const tbody = document.querySelector('#holdings-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
if (data.output1 && data.output1.length > 0) {
|
||||
data.output1.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
const profitRate = parseFloat(item.evlu_pfls_rt || 0);
|
||||
tr.innerHTML = `
|
||||
<td>${item.prdt_name} (${item.pdno})</td>
|
||||
<td>${item.hldg_qty}</td>
|
||||
<td>${parseInt(item.prpr).toLocaleString()}</td>
|
||||
<td class="${profitRate >= 0 ? 'text-success' : 'text-danger'}">${profitRate}%</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">보유 종목이 없습니다.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// We might not have this element in previous versions, but let's try safely if it exists, or ignore
|
||||
// document.getElementById('sync-info').innerText = ...
|
||||
// Removed to avoid error if element missing
|
||||
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
try {
|
||||
const res = await fetch('/api/orders');
|
||||
const data = await res.json();
|
||||
const tbody = document.querySelector('#orders-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
let currentFilledCount = 0;
|
||||
|
||||
data.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// Simple check for filled status
|
||||
if (item.status === 'FILLED' || (item.status && item.status.includes('체결'))) {
|
||||
currentFilledCount++;
|
||||
}
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${new Date(item.created_at).toLocaleString()}</td>
|
||||
<td>${item.code}</td>
|
||||
<td class="${item.type === 'BUY' ? 'text-danger' : 'text-success'}">${item.type}</td>
|
||||
<td>${item.price.toLocaleString()}</td>
|
||||
<td>${item.quantity}</td>
|
||||
<td>${item.status}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// If number of filled orders increased, refresh balance
|
||||
// Skip the first check to avoid redundant refresh on page load
|
||||
if (lastFilledCount === -1) {
|
||||
lastFilledCount = currentFilledCount;
|
||||
} else if (currentFilledCount > lastFilledCount) {
|
||||
console.log("New order filled! Refreshing holdings...");
|
||||
console.log(`Order count increased: ${lastFilledCount} -> ${currentFilledCount}`);
|
||||
refreshHoldings();
|
||||
lastFilledCount = currentFilledCount;
|
||||
}
|
||||
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
// Init
|
||||
fetchBalance('init');
|
||||
fetchWatchlist();
|
||||
fetchOrders();
|
||||
|
||||
// WebSocket Client
|
||||
let ws = null;
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to Server WS");
|
||||
// Resend subscriptions for currently displayed items
|
||||
const items = document.querySelectorAll('[id^="watch-"]');
|
||||
items.forEach(el => {
|
||||
const code = el.id.replace('watch-', '');
|
||||
ws.send(`sub:${code}`);
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'PRICE') {
|
||||
updatePriceDOM(msg);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("WS Disconnected. Reconnecting in 3s...");
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function updatePriceDOM(data) {
|
||||
// data: {code, price, change, rate, ...}
|
||||
// Find elements with data-code attribute? Or just iterate watchlist items?
|
||||
// Currently our watchlist doesn't have IDs easily addressable.
|
||||
// We generated: <div style="...">...Code...</div>
|
||||
// Let's refine fetchWatchlist to add IDs or classes to help update.
|
||||
|
||||
// Update Watchlist Items
|
||||
const watchItem = document.getElementById(`watch-${data.code}`);
|
||||
if(watchItem) {
|
||||
const diffColor = parseFloat(data.change) > 0 ? 'text-success' : (parseFloat(data.change) < 0 ? 'text-danger' : 'text-muted');
|
||||
|
||||
// Update price text. We need a specific span for price.
|
||||
// Assuming we rebuild DOM to support this.
|
||||
// For now, simpler implementation:
|
||||
// Just flash the card or something?
|
||||
// No, User wants real update.
|
||||
|
||||
const priceEl = watchItem.querySelector('.price-tag');
|
||||
if(priceEl) {
|
||||
priceEl.innerText = parseInt(data.price).toLocaleString();
|
||||
priceEl.className = `price-tag ${diffColor}`;
|
||||
}
|
||||
const rateEl = watchItem.querySelector('.rate-tag');
|
||||
if(rateEl) {
|
||||
rateEl.innerText = `${data.rate}%`;
|
||||
rateEl.className = `rate-tag ${diffColor}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhance fetchWatchlist to support updates and subscribe
|
||||
const originalFetchWatchlist = fetchWatchlist;
|
||||
fetchWatchlist = async function() {
|
||||
await originalFetchWatchlist(); // Call original
|
||||
// After rendering, subscribe!
|
||||
// Wait, original renders HTML. I need to modify it to include IDs.
|
||||
};
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
// Refresh Intervals
|
||||
setInterval(fetchOrders, 30000); // 30s for orders
|
||||
// setInterval(fetchWatchlist, 60000); // Disable polling if we use WS? Or keep as backup.
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user