initial commit

This commit is contained in:
2026-02-04 00:16:34 +09:00
commit ae11528dd9
867 changed files with 209640 additions and 0 deletions

228
frontend/css/style.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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>