initial commit
This commit is contained in:
228
frontend/css/style.css
Normal file
228
frontend/css/style.css
Normal file
@@ -0,0 +1,228 @@
|
||||
:root {
|
||||
--bg-color: #0f172a;
|
||||
--card-bg: rgba(30, 41, 59, 0.7);
|
||||
--card-border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent-color: #3b82f6;
|
||||
--accent-glow: 0 0 20px rgba(59, 130, 246, 0.5);
|
||||
--success-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
background-image: radial-gradient(circle at 10% 20%, rgba(59, 130, 246, 0.1) 0%, transparent 20%),
|
||||
radial-gradient(circle at 90% 80%, rgba(16, 185, 129, 0.05) 0%, transparent 20%);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, #3b82f6, #10b981);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
color: var(--text-primary);
|
||||
text-shadow: 0 0 10px rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-success { color: var(--success-color); }
|
||||
.text-danger { color: var(--danger-color); }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: rgba(16, 185, 129, 0.2); color: #10b981; }
|
||||
.badge-secondary { background: rgba(148, 163, 184, 0.2); color: #94a3b8; }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.nav-links { gap: 1rem; font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255,255,255,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
-webkit-animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@-webkit-keyframes spin {
|
||||
to { -webkit-transform: rotate(360deg); }
|
||||
}
|
||||
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>
|
||||
66
frontend/news.html
Normal file
66
frontend/news.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 뉴스 - KisStock AI</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="logo">KisStock AI</div>
|
||||
<div class="nav-links">
|
||||
<a href="/">대시보드</a>
|
||||
<a href="/trade">매매</a>
|
||||
<a href="/news" class="active">AI뉴스</a>
|
||||
<a href="/stocks">종목</a>
|
||||
<a href="/settings">설정</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>📰 실시간 AI 뉴스 분석</h2>
|
||||
<div id="news-list">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function fetchNews() {
|
||||
try {
|
||||
const res = await fetch('/api/news');
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('news-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
data.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.style.marginBottom = "1.5rem";
|
||||
div.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
|
||||
div.style.paddingBottom = "1rem";
|
||||
|
||||
const scoreColor = item.impact_score > 0 ? 'text-success' : (item.impact_score < 0 ? 'text-danger' : 'text-muted');
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||
<a href="${item.link}" target="_blank" style="color:var(--text-primary); text-decoration:none; font-size:1.1rem; font-weight:600;">${item.title.replace(/<[^>]*>?/gm, '')}</a>
|
||||
<span class="badge ${scoreColor}" style="margin-left:8px; font-size:0.9rem;">AI Score: ${item.impact_score}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="margin: 0.5rem 0;">${item.analysis_result || item.related_sector}</div>
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.8rem; color:#64748b;">
|
||||
<span>${item.pub_date}</span>
|
||||
<span>관련종목: ${item.related_sector || '분석중'}</span>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
fetchNews();
|
||||
setInterval(fetchNews, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
188
frontend/settings.html
Normal file
188
frontend/settings.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>설정 - KisStock AI</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="logo">KisStock AI</div>
|
||||
<div class="nav-links">
|
||||
<a href="/">대시보드</a>
|
||||
<a href="/trade">매매</a>
|
||||
<a href="/settings" class="active">설정</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>⚙️ 시스템 설정</h2>
|
||||
<div id="settings-form">
|
||||
<!-- KIS Settings -->
|
||||
<div class="form-group">
|
||||
<label>App Key (실전/모의)</label>
|
||||
<input type="text" id="app_key" placeholder="KIS App Key">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>App Secret</label>
|
||||
<input type="password" id="app_secret" placeholder="KIS App Secret">
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label>계좌번호 (8자리)</label>
|
||||
<input type="text" id="account_no" placeholder="12345678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>계좌식별코드 (2자리)</label>
|
||||
<input type="text" id="account_prod" placeholder="01">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HTS ID</label>
|
||||
<input type="text" id="htsid" placeholder="HTS ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>투자 환경</label>
|
||||
<select id="is_paper">
|
||||
<option value="true">모의투자 (Mock)</option>
|
||||
<option value="false">실전투자 (Real)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="enable_news" style="width:auto;"> 뉴스 수집 및 AI 분석 활성화
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<hr style="border-color: rgba(255,255,255,0.1); margin: 2rem 0;">
|
||||
|
||||
<!-- External APIs -->
|
||||
<div class="form-group">
|
||||
<label>Naver Client ID</label>
|
||||
<input type="text" id="naver_id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Naver Client Secret</label>
|
||||
<input type="password" id="naver_secret">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Google Gemini API Key</label>
|
||||
<input type="password" id="google_key">
|
||||
</div>
|
||||
|
||||
<hr style="border-color: rgba(255,255,255,0.1); margin: 2rem 0;">
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="enable_telegram" style="width:auto;"> 텔레그램 알림 활성화
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Telegram Bot Token</label>
|
||||
<input type="password" id="bot_token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Telegram Chat ID</label>
|
||||
<input type="text" id="chat_id" placeholder="123456789">
|
||||
</div>
|
||||
|
||||
|
||||
<div style="text-align: right; margin-top: 2rem;">
|
||||
<button class="btn btn-primary" onclick="saveSettings()">저장하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🤖 자동매매 설정 (DB)</h2>
|
||||
<div class="form-group">
|
||||
<p class="text-muted">종목별 자동매매 감시 조건은 매매 페이지에서 설정할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentConfig = {};
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
currentConfig = data;
|
||||
|
||||
// Bind to UI
|
||||
if (data.kis) {
|
||||
document.getElementById('app_key').value = data.kis.app_key || '';
|
||||
document.getElementById('app_secret').value = data.kis.app_secret || '';
|
||||
document.getElementById('account_no').value = data.kis.account_no || '';
|
||||
document.getElementById('account_prod').value = data.kis.account_prod || '';
|
||||
document.getElementById('htsid').value = data.kis.htsid || '';
|
||||
document.getElementById('is_paper').value = data.kis.is_paper ? "true" : "false";
|
||||
}
|
||||
if (data.preferences) {
|
||||
document.getElementById('enable_news').checked = data.preferences.enable_news || false;
|
||||
document.getElementById('enable_telegram').checked = data.preferences.enable_telegram !== false; // Default true
|
||||
}
|
||||
|
||||
if (data.naver) {
|
||||
document.getElementById('naver_id').value = data.naver.client_id || '';
|
||||
document.getElementById('naver_secret').value = data.naver.client_secret || '';
|
||||
}
|
||||
if (data.google) {
|
||||
document.getElementById('google_key').value = data.google.api_key || '';
|
||||
}
|
||||
if (data.telegram) {
|
||||
document.getElementById('bot_token').value = data.telegram.bot_token || '';
|
||||
document.getElementById('chat_id').value = data.telegram.chat_id || '';
|
||||
}
|
||||
|
||||
} catch(e) { console.error(e); alert('Load failed'); }
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const newConfig = { ...currentConfig };
|
||||
|
||||
if (!newConfig.kis) newConfig.kis = {};
|
||||
newConfig.kis.app_key = document.getElementById('app_key').value;
|
||||
newConfig.kis.app_secret = document.getElementById('app_secret').value;
|
||||
newConfig.kis.account_no = document.getElementById('account_no').value;
|
||||
newConfig.kis.account_prod = document.getElementById('account_prod').value;
|
||||
newConfig.kis.htsid = document.getElementById('htsid').value;
|
||||
newConfig.kis.is_paper = document.getElementById('is_paper').value === "true";
|
||||
|
||||
if (!newConfig.preferences) newConfig.preferences = {};
|
||||
newConfig.preferences.enable_news = document.getElementById('enable_news').checked;
|
||||
newConfig.preferences.enable_telegram = document.getElementById('enable_telegram').checked;
|
||||
|
||||
|
||||
if (!newConfig.naver) newConfig.naver = {};
|
||||
newConfig.naver.client_id = document.getElementById('naver_id').value;
|
||||
newConfig.naver.client_secret = document.getElementById('naver_secret').value;
|
||||
|
||||
if (!newConfig.google) newConfig.google = {};
|
||||
newConfig.google.api_key = document.getElementById('google_key').value;
|
||||
|
||||
if (!newConfig.telegram) newConfig.telegram = {};
|
||||
newConfig.telegram.bot_token = document.getElementById('bot_token').value;
|
||||
newConfig.telegram.chat_id = document.getElementById('chat_id').value;
|
||||
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(newConfig)
|
||||
});
|
||||
if (res.ok) alert('설정이 저장되었습니다. 서버를 재시작해야 적용될 수 있습니다.');
|
||||
else alert('저장 실패');
|
||||
} catch(e) { console.error(e); alert('Error'); }
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
180
frontend/stocks.html
Normal file
180
frontend/stocks.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>종목 정보 - KisStock AI</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="logo">KisStock AI</div>
|
||||
<div class="nav-links">
|
||||
<a href="/">대시보드</a>
|
||||
<a href="/trade">매매</a>
|
||||
<a href="/news">AI뉴스</a>
|
||||
<a href="/stocks" class="active">종목</a>
|
||||
<a href="/settings">설정</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||
<h2>📦 전체 종목 DB</h2>
|
||||
<div>
|
||||
<button class="btn" onclick="syncMaster()" style="background:#475569; font-size:0.9rem;">🔄 DB 동기화 (최신 데이터 수집)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" style="grid-template-columns: 1fr 2fr 1fr; gap:1rem; margin-bottom:1rem;">
|
||||
<select id="market-filter" onchange="searchStocks()">
|
||||
<option value="">전체 시장</option>
|
||||
<option value="KOSPI">KOSPI</option>
|
||||
<option value="KOSDAQ">KOSDAQ</option>
|
||||
<option value="NASD">NASDAQ</option>
|
||||
<option value="NYSE">NYSE</option>
|
||||
<option value="AMEX">AMEX</option>
|
||||
</select>
|
||||
<input type="text" id="search-keyword" placeholder="종목명 또는 코드 검색 (Enter)" onkeypress="if(event.key==='Enter') searchStocks()">
|
||||
<button class="btn btn-primary" onclick="searchStocks()">검색</button>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x:auto;">
|
||||
<table id="stocks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시장</th>
|
||||
<th>코드</th>
|
||||
<th>종목명</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagination" style="margin-top:1rem; text-align:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
|
||||
async function searchStocks(page=1) {
|
||||
currentPage = page;
|
||||
const keyword = document.getElementById('search-keyword').value;
|
||||
const market = document.getElementById('market-filter').value;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/stocks?keyword=${encodeURIComponent(keyword)}&market=${market}&page=${page}`);
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.querySelector('#stocks-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.items.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td><span class="badge ${item.type === 'DOMESTIC' ? 'text-success' : 'text-danger'}">${item.market}</span></td>
|
||||
<td>${item.code}</td>
|
||||
<td>${item.name}</td>
|
||||
<td>
|
||||
<button class="btn" style="padding:0.2rem 0.5rem; font-size:0.8rem;" onclick="addToWatchlist('${item.code}', '${item.name}', '${item.market}')">⭐ 관심등록</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Pagination Logic (Simple)
|
||||
// Assuming API returns total or we just simpler Next/Prev for now
|
||||
document.getElementById('pagination').innerHTML = `
|
||||
<button class="btn" style="width:auto; display:inline-block;" onclick="searchStocks(${page-1})" ${page<=1?'disabled':''}>이전</button>
|
||||
<span style="margin:0 1rem;">Page ${page}</span>
|
||||
<button class="btn" style="width:auto; display:inline-block;" onclick="searchStocks(${page+1})">다음</button>
|
||||
`;
|
||||
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function addToWatchlist(code, name, market) {
|
||||
try {
|
||||
const res = await fetch('/api/watchlist', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ code, name, market })
|
||||
});
|
||||
if(res.ok) alert("관심종목에 추가되었습니다.");
|
||||
else alert("이미 존재하거나 오류가 발생했습니다.");
|
||||
} catch(e) { alert("Error: " + e.message); }
|
||||
}
|
||||
|
||||
async function syncMaster() {
|
||||
if(!confirm("전체 종목 데이터를 서버로 다운로드하고 동기화합니다.\n시간이 소요될 수 있습니다. 진행하시겠습니까?")) return;
|
||||
try {
|
||||
const res = await fetch('/api/sync/master', { method: 'POST' });
|
||||
if(res.ok) {
|
||||
alert("동기화 작업이 백그라운드에서 시작되었습니다. 상태를 모니터링합니다.");
|
||||
pollSyncStatus();
|
||||
}
|
||||
} catch(e) { alert("요청 실패"); }
|
||||
}
|
||||
|
||||
async function pollSyncStatus() {
|
||||
const statusDiv = document.getElementById('sync-status-display') || createStatusDiv();
|
||||
const btn = document.querySelector('button[onclick="syncMaster()"]');
|
||||
|
||||
// Poll every 2s
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sync/status');
|
||||
const data = await res.json();
|
||||
|
||||
statusDiv.innerHTML = `STATUS: ${data.status.toUpperCase()} - ${data.message}`;
|
||||
|
||||
if (data.status === 'running') {
|
||||
btn.disabled = true;
|
||||
btn.innerText = "⏳ 동기화 중...";
|
||||
statusDiv.className = "alert alert-info";
|
||||
} else if (data.status === 'error') {
|
||||
btn.disabled = false;
|
||||
btn.innerText = "🔄 DB 동기화 (재시도)";
|
||||
statusDiv.className = "alert alert-danger";
|
||||
clearInterval(interval);
|
||||
alert("동기화 중 오류가 발생했습니다: " + data.message);
|
||||
} else if (data.status === 'done') {
|
||||
btn.disabled = false;
|
||||
btn.innerText = "🔄 DB 동기화 (완료)";
|
||||
statusDiv.className = "alert alert-success";
|
||||
clearInterval(interval);
|
||||
alert("동기화 완료!");
|
||||
searchStocks(); // Refresh list
|
||||
} else if (data.status === 'warning') {
|
||||
btn.disabled = false;
|
||||
btn.innerText = "🔄 DB 동기화 (부분완료)";
|
||||
statusDiv.className = "alert alert-warning";
|
||||
clearInterval(interval);
|
||||
alert("부분 완료 (일부 실패): " + data.message);
|
||||
}
|
||||
} catch(e) { clearInterval(interval); }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function createStatusDiv() {
|
||||
const div = document.createElement('div');
|
||||
div.id = 'sync-status-display';
|
||||
div.style.padding = "10px";
|
||||
div.style.marginBottom = "10px";
|
||||
div.style.borderRadius = "4px";
|
||||
div.style.fontSize = "0.9rem";
|
||||
|
||||
// Insert before grid
|
||||
const container = document.querySelector('.card');
|
||||
container.insertBefore(div, container.children[1]);
|
||||
return div;
|
||||
}
|
||||
|
||||
// Init load
|
||||
searchStocks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
377
frontend/trade.html
Normal file
377
frontend/trade.html
Normal file
@@ -0,0 +1,377 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>매매 - KisStock AI</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
.tab-menu {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--accent-color);
|
||||
border-bottom-color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.btn-buy { background: #ef4444; color: white; }
|
||||
.btn-sell { background: #3b82f6; color: white; }
|
||||
.btn-cancel { background: #f59e0b; color: white; padding: 0.4rem; font-size: 0.8rem; width: auto;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="logo">KisStock AI</div>
|
||||
<div class="nav-links">
|
||||
<a href="/">대시보드</a>
|
||||
<a href="/trade" class="active">매매</a>
|
||||
<a href="/settings">설정</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<div class="card">
|
||||
<div class="tab-menu">
|
||||
<button class="tab-btn active" onclick="showTab('order')">주문하기</button>
|
||||
<button class="tab-btn" onclick="showTab('cancel')">정정/취소</button>
|
||||
<button class="tab-btn" onclick="showTab('history')">체결내역</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Order -->
|
||||
<div id="tab-order" class="tab-content active">
|
||||
<h2>⚡ 간편 매매</h2>
|
||||
<div class="grid" style="grid-template-columns: 1fr 1fr; gap: 2rem;">
|
||||
<div>
|
||||
<label>종목코드</label>
|
||||
<input type="text" id="code" placeholder="예: 005930">
|
||||
<button class="btn" style="background:var(--card-bg); border:1px solid #fff;" onclick="searchStock()">현재가 조회</button>
|
||||
|
||||
<div id="price-display" style="margin-top:1rem; font-size:1.2rem;"></div>
|
||||
|
||||
<hr style="margin: 1rem 0; border-color: rgba(255,255,255,0.1);">
|
||||
|
||||
<h3>🤖 자동매매 감시</h3>
|
||||
<div class="form-group">
|
||||
<label>목표가 (익절)</label>
|
||||
<input type="number" id="target-price" placeholder="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>손절가 (로스컷)</label>
|
||||
<input type="number" id="loss-price" placeholder="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="auto-active" style="width:auto;"> 감시 활성화
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn" style="background:var(--accent-color); color:white;" onclick="saveAutoSettings()">감시 설정 저장</button>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<label>수량</label>
|
||||
<input type="number" id="qty" value="1">
|
||||
|
||||
<label>가격 (0 = 시장가)</label>
|
||||
<input type="number" id="price" value="0">
|
||||
|
||||
<div class="grid" style="grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<button class="btn btn-buy" onclick="placeOrder('buy')">매수</button>
|
||||
<button class="btn btn-sell" onclick="placeOrder('sell')">매도</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Cancel -->
|
||||
<div id="tab-cancel" class="tab-content">
|
||||
<h2>♻ 미체결/취소 가능 주문</h2>
|
||||
<button class="btn" onclick="fetchCancelable()" style="background:#475569; margin-bottom:1rem;">목록 갱신</button>
|
||||
<div style="overflow-x:auto;">
|
||||
<table id="cancel-table" style="width:100%; text-align:left;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>주문번호</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>수량/잔량</th>
|
||||
<th>가격</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: History -->
|
||||
<div id="tab-history" class="tab-content">
|
||||
<h2>📜 금일 체결 내역</h2>
|
||||
<button class="btn" onclick="fetchHistory()" style="background:#475569; margin-bottom:1rem;">내역 조회</button>
|
||||
<div style="overflow-x:auto;">
|
||||
<table id="history-table" style="width:100%; text-align:left;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>주문번호</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>체결수량</th>
|
||||
<th>체결단가</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Init: Parse URL params
|
||||
window.onload = function() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const market = params.get('market');
|
||||
const type = params.get('type');
|
||||
|
||||
if(code) {
|
||||
document.getElementById('code').value = code;
|
||||
searchStock(code); // Auto-load price
|
||||
}
|
||||
// Optional: Handle 'market' if we had a market selector in trade page (we might need one for overseas order API)
|
||||
// Optional: Handle 'type' (buy/sell) - could switch button focus or auto-prepare?
|
||||
// For now, user clicks buttons manually, but we could highlight.
|
||||
|
||||
if(type) {
|
||||
// visually highlight button?
|
||||
}
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function showTab(name) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('tab-'+name).classList.add('active');
|
||||
// Find button logic if needed, but simple onclick works for now
|
||||
// We need to match the button that called this.
|
||||
// Better to iterate buttons and regex or id match?
|
||||
// Let's just reset buttons manually or pass 'this'
|
||||
const buttons = document.querySelectorAll('.tab-btn');
|
||||
buttons.forEach(btn => {
|
||||
if(btn.innerText.includes(name === 'order' ? '주문' : (name === 'cancel' ? '취소' : '체결'))) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
if(name === 'cancel') fetchCancelable();
|
||||
if(name === 'history') fetchHistory();
|
||||
}
|
||||
|
||||
async function searchStock(paramCode) {
|
||||
const code = paramCode || document.getElementById('code').value;
|
||||
if(!code) return;
|
||||
try {
|
||||
const res = await fetch(`/api/price/${code}`);
|
||||
const data = await res.json();
|
||||
document.getElementById('price-display').innerText =
|
||||
`${data.bstp_kor_isnm || ''} 현재가: ${parseInt(data.stck_prpr || 0).toLocaleString()}원`;
|
||||
|
||||
// Also load auto settings
|
||||
loadAutoSettings(code);
|
||||
} catch(e) { /* Ignore or alert */ }
|
||||
}
|
||||
|
||||
async function placeOrder(type) {
|
||||
const code = document.getElementById('code').value;
|
||||
const qty = document.getElementById('qty').value;
|
||||
const price = document.getElementById('price').value;
|
||||
|
||||
// Need Market info?
|
||||
// 1. Get from URL if present
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let market = params.get('market') || "DOMESTIC";
|
||||
// Note: If user typed code manually, we default to DOMESTIC logic unless we fetch info.
|
||||
|
||||
if(!code || !qty) return alert("입력값을 확인하세요");
|
||||
|
||||
if(!confirm(`${type === 'buy' ? '매수' : '매도'} 주문을 전송하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/order', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ code, type, qty: parseInt(qty), price: parseInt(price), market })
|
||||
});
|
||||
const data = await res.json();
|
||||
if(data.rt_cd === '0') {
|
||||
alert("주문 전송 성공: " + data.msg1);
|
||||
} else {
|
||||
alert("주문 실패: " + data.msg1);
|
||||
}
|
||||
} catch(e) { alert("Error: " + e.message); }
|
||||
}
|
||||
|
||||
async function fetchCancelable() {
|
||||
try {
|
||||
const res = await fetch('/api/orders/cancelable');
|
||||
const data = await res.json();
|
||||
const tbody = document.querySelector('#cancel-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if(!data.output) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터가 없거나 조회 실패</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.output.forEach(item => {
|
||||
// Only show if psbl_qty > 0 (Cancelable)
|
||||
if (parseInt(item.psbl_qty) <= 0) return;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${item.odno}</td>
|
||||
<td>${item.prdt_name} (${item.pdno})</td>
|
||||
<td>${item.sll_buy_dvsn_cd_name}</td>
|
||||
<td>${item.ord_qty} / <span style="color:#f59e0b">${item.psbl_qty}</span></td>
|
||||
<td>${parseInt(item.ord_unpr).toLocaleString()}</td>
|
||||
<td>
|
||||
<button class="btn btn-cancel" onclick="cancelOrder('${item.krx_fwdg_ord_orgno}', '${item.odno}', ${item.psbl_qty}, '${item.pdno}')">취소</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
if(tbody.children.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">취소 가능한 주문이 없습니다.</td></tr>';
|
||||
}
|
||||
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function cancelOrder(orgNo, orderNo, qty, code) {
|
||||
if(!confirm(`주문번호 ${orderNo} (${qty}주)를 취소하시겠습니까?`)) return;
|
||||
try {
|
||||
const res = await fetch('/api/order/cancel', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
org_no: orgNo,
|
||||
order_no: orderNo,
|
||||
qty: parseInt(qty),
|
||||
is_buy: true,
|
||||
price: 0
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
alert(data.msg1);
|
||||
fetchCancelable();
|
||||
} catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
async function fetchHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/orders/daily');
|
||||
const data = await res.json();
|
||||
const tbody = document.querySelector('#history-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if(!data.output1) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터가 없거나 조회 실패</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.output1.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${item.ord_dt} ${item.ord_tmd}</td>
|
||||
<td>${item.odno}</td>
|
||||
<td>${item.prdt_name}</td>
|
||||
<td>${item.sll_buy_dvsn_cd_name}</td>
|
||||
<td>${item.tot_ccld_qty} / ${item.ord_qty}</td>
|
||||
<td>${parseInt(item.avg_prvs).toLocaleString()}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadAutoSettings(code) {
|
||||
try {
|
||||
const res = await fetch('/api/trade_settings');
|
||||
const data = await res.json();
|
||||
const setting = data.find(s => s.code === code);
|
||||
|
||||
if (setting) {
|
||||
document.getElementById('target-price').value = setting.target_price || '';
|
||||
document.getElementById('loss-price').value = setting.stop_loss_price || '';
|
||||
document.getElementById('auto-active').checked = setting.is_active;
|
||||
} else {
|
||||
document.getElementById('target-price').value = '';
|
||||
document.getElementById('loss-price').value = '';
|
||||
document.getElementById('auto-active').checked = false;
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function saveAutoSettings() {
|
||||
const code = document.getElementById('code').value;
|
||||
if (!code) return alert('종목 코드 입력 필요');
|
||||
|
||||
const target = document.getElementById('target-price').value;
|
||||
const loss = document.getElementById('loss-price').value;
|
||||
const active = document.getElementById('auto-active').checked;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/trade_settings', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
target_price: target ? parseFloat(target) : null,
|
||||
stop_loss_price: loss ? parseFloat(loss) : null,
|
||||
is_active: active
|
||||
})
|
||||
});
|
||||
if(res.ok) alert('감시 설정 저장 완료');
|
||||
} catch(e) { alert("저장 실패"); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user