first commit

This commit is contained in:
hayato5246
2026-03-31 19:32:59 +09:00
commit d10b794c9f
78 changed files with 1671595 additions and 0 deletions

308
static/js/asset.js Normal file
View File

@@ -0,0 +1,308 @@
/**
* 자산 현황 페이지 로직
* - 요약 카드, 예수금, 보유 종목, 미체결/체결내역
*/
// -----------------------------------
// 초기화
// -----------------------------------
document.addEventListener('DOMContentLoaded', () => {
loadSummary();
loadCash();
loadPending();
});
// -----------------------------------
// 요약 카드 + 보유 종목 (GET /api/account/balance)
// -----------------------------------
async function loadSummary() {
try {
const res = await fetch('/api/account/balance');
const data = await res.json();
if (!res.ok) {
renderSummaryError(data.error || '잔고 조회 실패');
return;
}
renderSummaryCards(data);
renderHoldings(data.stocks);
} catch (e) {
renderSummaryError('네트워크 오류: ' + e.message);
}
}
function renderSummaryCards(data) {
const plRate = parseFloat(data.totPrftRt || '0');
const plAmt = parseInt(data.totEvltPl || '0');
const plClass = plRate >= 0 ? 'text-red-500' : 'text-blue-500';
const cards = [
{
label: '추정예탁자산',
value: parseInt(data.prsmDpstAsetAmt || '0').toLocaleString('ko-KR') + '원',
cls: 'text-gray-800',
},
{
label: '총평가금액',
value: parseInt(data.totEvltAmt || '0').toLocaleString('ko-KR') + '원',
cls: 'text-gray-800',
},
{
label: '총평가손익',
value: (plAmt >= 0 ? '+' : '') + plAmt.toLocaleString('ko-KR') + '원',
cls: plClass,
},
{
label: '수익률',
value: (plRate >= 0 ? '+' : '') + plRate.toFixed(2) + '%',
cls: plClass,
},
];
document.getElementById('summaryCards').innerHTML = cards.map(c => `
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<p class="text-xs text-gray-400 mb-1">${c.label}</p>
<p class="text-lg font-bold ${c.cls}">${c.value}</p>
</div>
`).join('');
}
function renderSummaryError(msg) {
document.getElementById('summaryCards').innerHTML = `
<div class="col-span-4 text-sm text-red-400 text-center py-4">${msg}</div>
`;
document.getElementById('holdingsTable').innerHTML = '';
}
function renderHoldings(stocks) {
const tbody = document.getElementById('holdingsTable');
if (!stocks || stocks.length === 0) {
tbody.innerHTML = '<div class="px-5 py-10 text-center text-sm text-gray-400">보유 종목이 없습니다.</div>';
return;
}
tbody.innerHTML = stocks.map(s => {
const prft = parseFloat(s.prftRt || '0');
const evlt = parseInt(s.evltvPrft || '0');
const cls = prft >= 0 ? 'text-red-500' : 'text-blue-500';
const sign = prft >= 0 ? '+' : '';
return `
<div class="grid grid-cols-[1fr_80px_90px_90px_100px_80px] text-sm px-5 py-3 border-b border-gray-50 hover:bg-gray-50 gap-2 items-center">
<a href="/stock/${s.stkCd}" class="font-medium text-gray-800 hover:text-blue-600 truncate">${s.stkNm}</a>
<span class="text-right text-gray-600">${parseInt(s.rmndQty || '0').toLocaleString('ko-KR')}주</span>
<span class="text-right text-gray-600">${parseInt(s.purPric || '0').toLocaleString('ko-KR')}</span>
<span class="text-right text-gray-600">${parseInt(s.curPrc || '0').toLocaleString('ko-KR')}</span>
<span class="text-right ${cls}">${(evlt >= 0 ? '+' : '') + evlt.toLocaleString('ko-KR')}원</span>
<span class="text-right ${cls}">${sign}${prft.toFixed(2)}%</span>
</div>`;
}).join('');
}
// -----------------------------------
// 예수금 카드 (GET /api/account/deposit — kt00001)
// -----------------------------------
async function loadCash() {
try {
const res = await fetch('/api/account/deposit');
const data = await res.json();
if (!res.ok) {
document.getElementById('cashEntr').textContent = '조회 실패';
document.getElementById('cashOrdAlowa').textContent = '조회 실패';
return;
}
// entr: 예수금, d2Entra: D+2 추정예수금, ordAlowAmt: 주문가능금액
document.getElementById('cashEntr').textContent =
data.d2Entra ? parseInt(data.d2Entra).toLocaleString('ko-KR') + '원' : '-';
document.getElementById('cashOrdAlowa').textContent =
data.ordAlowAmt ? parseInt(data.ordAlowAmt).toLocaleString('ko-KR') + '원' : '-';
} catch (e) {
document.getElementById('cashEntr').textContent = '오류';
document.getElementById('cashOrdAlowa').textContent = '오류';
}
}
// -----------------------------------
// 탭 전환
// -----------------------------------
function showAssetTab(tab) {
['pending', 'history'].forEach(t => {
const btn = document.getElementById('asset' + capitalize(t) + 'Tab');
const panel = document.getElementById('asset' + capitalize(t) + 'Panel');
const active = t === tab;
if (btn) {
btn.classList.toggle('border-b-2', active);
btn.classList.toggle('border-blue-500', active);
btn.classList.toggle('text-blue-600', active);
btn.classList.toggle('text-gray-500', !active);
}
if (panel) panel.classList.toggle('hidden', !active);
});
if (tab === 'pending') loadPending();
if (tab === 'history') loadHistory();
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
// -----------------------------------
// 미체결 목록 (GET /api/account/pending)
// -----------------------------------
async function loadPending() {
const panel = document.getElementById('assetPendingPanel');
if (!panel) return;
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>';
try {
const res = await fetch('/api/account/pending');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">${list.error || '조회 실패'}</div>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6">미체결 주문이 없습니다.</div>';
return;
}
panel.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-500 bg-gray-50 border-b border-gray-100">
<th class="px-3 py-2 text-left font-medium">종목명</th>
<th class="px-3 py-2 text-center font-medium">구분</th>
<th class="px-3 py-2 text-right font-medium">주문가</th>
<th class="px-3 py-2 text-right font-medium">미체결/주문</th>
<th class="px-3 py-2 text-center font-medium">취소</th>
</tr>
</thead>
<tbody>
${list.map(o => {
const isBuy = o.trdeTp === '2';
const cls = isBuy ? 'text-red-500' : 'text-blue-500';
const label = isBuy ? '매수' : '매도';
return `
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="px-3 py-2.5 font-medium text-gray-800">${o.stkNm}</td>
<td class="px-3 py-2.5 text-center ${cls}">${label}</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.ordPric || '0').toLocaleString('ko-KR')}원</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.osoQty || '0').toLocaleString('ko-KR')} / ${parseInt(o.ordQty || '0').toLocaleString('ko-KR')}주</td>
<td class="px-3 py-2.5 text-center">
<button onclick="assetCancelOrder('${o.ordNo}','${o.stkCd}')"
class="px-2.5 py-1 text-xs rounded border border-gray-300 text-gray-600 hover:bg-gray-100">취소</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">오류: ${e.message}</div>`;
}
}
// -----------------------------------
// 미체결 취소
// -----------------------------------
async function assetCancelOrder(ordNo, stkCd) {
if (!confirm('전량 취소하시겠습니까?')) return;
try {
const res = await fetch('/api/order', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ origOrdNo: ordNo, code: stkCd, qty: '0', exchange: 'KRX' }),
});
const data = await res.json();
if (!res.ok || data.error) {
showAssetToast(data.error || '취소 실패', 'error');
return;
}
showAssetToast('취소 주문 접수 완료', 'success');
loadPending();
} catch (e) {
showAssetToast('네트워크 오류', 'error');
}
}
// -----------------------------------
// 체결내역 (GET /api/account/history)
// -----------------------------------
async function loadHistory() {
const panel = document.getElementById('assetHistoryPanel');
if (!panel) return;
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>';
try {
const res = await fetch('/api/account/history');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">${list.error || '조회 실패'}</div>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6">체결 내역이 없습니다.</div>';
return;
}
panel.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-500 bg-gray-50 border-b border-gray-100">
<th class="px-3 py-2 text-left font-medium">종목명</th>
<th class="px-3 py-2 text-center font-medium">구분</th>
<th class="px-3 py-2 text-right font-medium">체결가</th>
<th class="px-3 py-2 text-right font-medium">체결수량</th>
<th class="px-3 py-2 text-right font-medium">수수료+세금</th>
</tr>
</thead>
<tbody>
${list.map(o => {
const isBuy = o.trdeTp === '2';
const cls = isBuy ? 'text-red-500' : 'text-blue-500';
const label = isBuy ? '매수' : '매도';
const fee = (parseInt(o.trdeCmsn || '0') + parseInt(o.trdeTax || '0')).toLocaleString('ko-KR');
return `
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="px-3 py-2.5 font-medium text-gray-800">${o.stkNm}</td>
<td class="px-3 py-2.5 text-center ${cls}">${label}</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.cntrPric || '0').toLocaleString('ko-KR')}원</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.cntrQty || '0').toLocaleString('ko-KR')}주</td>
<td class="px-3 py-2.5 text-right text-gray-500">${fee}원</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">오류: ${e.message}</div>`;
}
}
// -----------------------------------
// 토스트 알림
// -----------------------------------
function showAssetToast(msg, type) {
const toast = document.createElement('div');
toast.className = `fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-lg text-sm font-medium shadow-lg transition-opacity
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 3000);
}

566
static/js/autotrade.js Normal file
View File

@@ -0,0 +1,566 @@
// 자동매매 페이지 클라이언트 스크립트
let logLevelFilter = 'all'; // 'all' | 'action'
let watchSource = { useScanner: true, selectedThemes: [] };
let localLogs = []; // 클라이언트 로그 버퍼 (오래된 것 → 최신 순)
let tradeLogWS = null;
document.addEventListener('DOMContentLoaded', () => {
loadStatus();
loadPositions();
loadLogs(); // 기존 로그 초기 로드 (HTTP)
loadRules();
loadThemeList();
loadWatchSource();
connectTradeLogWS(); // WS 실시간 연결
// 자동스크롤 체크박스: ON 전환 시 즉시 최신으로 이동
const chk = document.getElementById('autoScrollChk');
if (chk) {
chk.addEventListener('change', () => {
if (chk.checked) scrollLogsToBottom();
});
}
// 상태: 3초 주기, 포지션: 5초 주기 (로그는 WS로 대체)
setInterval(loadStatus, 3000);
setInterval(loadPositions, 5000);
});
// --- 엔진 상태 ---
async function loadStatus() {
try {
const res = await fetch('/api/autotrade/status');
const data = await res.json();
updateStatusUI(data);
} catch (e) {
console.error('상태 조회 실패:', e);
}
}
function updateStatusUI(data) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (data.running) {
dot.className = 'w-3 h-3 rounded-full bg-green-500';
text.textContent = '● 실행중';
text.className = 'text-sm font-medium text-green-600';
} else {
dot.className = 'w-3 h-3 rounded-full bg-gray-300';
text.textContent = '○ 중지';
text.className = 'text-sm font-medium text-gray-500';
}
document.getElementById('statActivePos').textContent = data.activePositions ?? 0;
document.getElementById('statTradeCount').textContent = data.tradeCount ?? 0;
const pl = data.totalPL ?? 0;
const plEl = document.getElementById('statTotalPL');
plEl.textContent = formatMoney(pl) + '원';
plEl.className = 'text-2xl font-bold ' + (pl > 0 ? 'text-red-500' : pl < 0 ? 'text-blue-500' : 'text-gray-800');
}
// --- 엔진 제어 ---
async function startEngine() {
if (!confirm('자동매매 엔진을 시작하시겠습니까?')) return;
try {
await fetch('/api/autotrade/start', { method: 'POST' });
await loadStatus();
} catch (e) {
alert('엔진 시작 실패: ' + e.message);
}
}
async function stopEngine() {
if (!confirm('자동매매 엔진을 중지하시겠습니까?')) return;
try {
await fetch('/api/autotrade/stop', { method: 'POST' });
await loadStatus();
} catch (e) {
alert('엔진 중지 실패: ' + e.message);
}
}
async function emergencyStop() {
if (!confirm('⚠ 긴급청산: 모든 포지션을 즉시 시장가 매도합니다.\n계속하시겠습니까?')) return;
try {
await fetch('/api/autotrade/emergency', { method: 'POST' });
await loadStatus();
await loadPositions();
} catch (e) {
alert('긴급청산 실패: ' + e.message);
}
}
// --- 규칙 ---
async function loadRules() {
try {
const res = await fetch('/api/autotrade/rules');
const rules = await res.json();
renderRules(rules);
} catch (e) {
console.error('규칙 조회 실패:', e);
}
}
function renderRules(rules) {
const el = document.getElementById('rulesList');
if (!rules || rules.length === 0) {
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">규칙이 없습니다.</p>';
return;
}
el.innerHTML = rules.map(r => `
<div class="border border-gray-100 rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-800">${escHtml(r.name)}</span>
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleRule('${r.id}')"
class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-checked:bg-blue-600 rounded-full peer
peer-focus:ring-2 peer-focus:ring-blue-300 transition-colors relative
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
peer-checked:after:translate-x-4"></div>
</label>
</div>
<div class="text-xs text-gray-500 space-y-0.5">
<p>진입: RiseScore≥${r.minRiseScore} / 체결강도≥${r.minCntrStr}${r.requireBullish ? ' / AI호재' : ''}</p>
<p>청산: 손절${r.stopLossPct}% / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}</p>
<p>주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목</p>
</div>
<div class="flex gap-2">
<button onclick="showEditRuleModal(${JSON.stringify(r).replace(/"/g, '&quot;')})"
class="px-2 py-1 text-xs text-blue-600 border border-blue-200 rounded hover:bg-blue-50 transition-colors">수정</button>
<button onclick="deleteRule('${r.id}')"
class="px-2 py-1 text-xs text-red-500 border border-red-200 rounded hover:bg-red-50 transition-colors">삭제</button>
</div>
</div>
`).join('');
}
async function deleteRule(id) {
if (!confirm('규칙을 삭제하시겠습니까?')) return;
try {
await fetch('/api/autotrade/rules/' + id, { method: 'DELETE' });
await loadRules();
} catch (e) {
alert('삭제 실패: ' + e.message);
}
}
async function toggleRule(id) {
try {
await fetch('/api/autotrade/rules/' + id + '/toggle', { method: 'POST' });
await loadRules();
} catch (e) {
alert('토글 실패: ' + e.message);
}
}
// --- 모달 ---
function showAddRuleModal() {
document.getElementById('modalTitle').textContent = '규칙 추가';
document.getElementById('ruleId').value = '';
document.getElementById('fName').value = '';
document.getElementById('fRiseScore').value = 60;
document.getElementById('riseScoreVal').textContent = '60';
document.getElementById('fCntrStr').value = 110;
document.getElementById('fRequireBullish').checked = false;
document.getElementById('fOrderAmount').value = 500000;
document.getElementById('fMaxPositions').value = 3;
document.getElementById('fStopLoss').value = -3;
document.getElementById('fTakeProfit').value = 5;
document.getElementById('fMaxHold').value = 60;
document.getElementById('fExitBeforeClose').checked = true;
document.getElementById('ruleModal').classList.remove('hidden');
}
function showEditRuleModal(r) {
document.getElementById('modalTitle').textContent = '규칙 수정';
document.getElementById('ruleId').value = r.id;
document.getElementById('fName').value = r.name;
document.getElementById('fRiseScore').value = r.minRiseScore;
document.getElementById('riseScoreVal').textContent = r.minRiseScore;
document.getElementById('fCntrStr').value = r.minCntrStr;
document.getElementById('fRequireBullish').checked = r.requireBullish;
document.getElementById('fOrderAmount').value = r.orderAmount;
document.getElementById('fMaxPositions').value = r.maxPositions;
document.getElementById('fStopLoss').value = r.stopLossPct;
document.getElementById('fTakeProfit').value = r.takeProfitPct;
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
document.getElementById('fExitBeforeClose').checked = r.exitBeforeClose;
document.getElementById('ruleModal').classList.remove('hidden');
}
function hideModal() {
document.getElementById('ruleModal').classList.add('hidden');
}
async function submitRule() {
const id = document.getElementById('ruleId').value;
const rule = {
name: document.getElementById('fName').value.trim(),
enabled: true,
minRiseScore: parseInt(document.getElementById('fRiseScore').value),
minCntrStr: parseFloat(document.getElementById('fCntrStr').value),
requireBullish: document.getElementById('fRequireBullish').checked,
orderAmount: parseInt(document.getElementById('fOrderAmount').value),
maxPositions: parseInt(document.getElementById('fMaxPositions').value),
stopLossPct: parseFloat(document.getElementById('fStopLoss').value),
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').value),
maxHoldMinutes: parseInt(document.getElementById('fMaxHold').value),
exitBeforeClose: document.getElementById('fExitBeforeClose').checked,
};
if (!rule.name) { alert('규칙명을 입력해주세요.'); return; }
try {
if (id) {
await fetch('/api/autotrade/rules/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
});
} else {
await fetch('/api/autotrade/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
});
}
hideModal();
await loadRules();
} catch (e) {
alert('저장 실패: ' + e.message);
}
}
// --- 포지션 ---
async function loadPositions() {
try {
const res = await fetch('/api/autotrade/positions');
const positions = await res.json();
renderPositions(positions);
} catch (e) {
console.error('포지션 조회 실패:', e);
}
}
function renderPositions(positions) {
const el = document.getElementById('positionsList');
const active = (positions || []).filter(p => p.status === 'open' || p.status === 'pending');
if (active.length === 0) {
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">보유 포지션 없음</p>';
return;
}
el.innerHTML = `
<table class="w-full text-xs">
<thead>
<tr class="text-left text-gray-400 border-b border-gray-100">
<th class="pb-2 font-medium">종목</th>
<th class="pb-2 font-medium text-right">매수가</th>
<th class="pb-2 font-medium text-right">수량</th>
<th class="pb-2 font-medium text-right">손절가</th>
<th class="pb-2 font-medium text-center">상태</th>
</tr>
</thead>
<tbody>
${active.map(p => {
const statusCls = p.status === 'open' ? 'text-green-600' : 'text-yellow-600';
const statusTxt = p.status === 'open' ? '보유중' : '체결대기';
return `
<tr class="border-b border-gray-50">
<td class="py-2">
<div class="font-medium text-gray-800">${escHtml(p.name)}</div>
<div class="text-gray-400">${p.code}</div>
</td>
<td class="py-2 text-right text-gray-700">${formatMoney(p.buyPrice)}</td>
<td class="py-2 text-right text-gray-700">${p.qty}</td>
<td class="py-2 text-right text-blue-500">${formatMoney(p.stopLoss)}</td>
<td class="py-2 text-center font-medium ${statusCls}">${statusTxt}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
// --- 감시 소스 ---
async function loadThemeList() {
try {
const res = await fetch('/api/themes?date=1&sort=3');
const themes = await res.json();
const sel = document.getElementById('themeSelect');
if (!sel || !themes) return;
// 가나다순 정렬 후 추가
const sorted = [...themes].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
sel.innerHTML = '<option value="">테마를 선택하세요...</option>';
sorted.forEach(t => {
const opt = document.createElement('option');
opt.value = t.code;
opt.textContent = `${t.name} (${t.fluRt >= 0 ? '+' : ''}${t.fluRt.toFixed(2)}%)`;
opt.dataset.name = t.name;
sel.appendChild(opt);
});
} catch (e) {
console.error('테마 목록 조회 실패:', e);
}
}
async function loadWatchSource() {
try {
const res = await fetch('/api/autotrade/watch-source');
watchSource = await res.json();
renderWatchSource();
} catch (e) {
console.error('감시 소스 조회 실패:', e);
}
}
async function saveWatchSource() {
const scannerChk = document.getElementById('wsScanner');
watchSource.useScanner = scannerChk ? scannerChk.checked : true;
try {
await fetch('/api/autotrade/watch-source', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(watchSource),
});
} catch (e) {
alert('감시 소스 저장 실패: ' + e.message);
}
}
function renderWatchSource() {
const scannerChk = document.getElementById('wsScanner');
if (scannerChk) scannerChk.checked = watchSource.useScanner;
const container = document.getElementById('selectedThemes');
const noMsg = document.getElementById('noThemeMsg');
if (!container) return;
const themes = watchSource.selectedThemes || [];
if (themes.length === 0) {
container.innerHTML = '<span class="text-xs text-gray-400" id="noThemeMsg">선택된 테마 없음</span>';
return;
}
container.innerHTML = themes.map(t => `
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
${escHtml(t.name)}
<button onclick="removeTheme('${escHtml(t.code)}')" class="hover:text-blue-900 text-blue-400 font-bold leading-none">×</button>
</span>
`).join('');
}
function addSelectedTheme() {
const sel = document.getElementById('themeSelect');
if (!sel || !sel.value) return;
const code = sel.value;
const name = sel.options[sel.selectedIndex]?.dataset.name || sel.options[sel.selectedIndex]?.text || code;
if (!watchSource.selectedThemes) watchSource.selectedThemes = [];
if (watchSource.selectedThemes.some(t => t.code === code)) {
alert('이미 추가된 테마입니다.');
return;
}
watchSource.selectedThemes.push({ code, name });
renderWatchSource();
saveWatchSource();
sel.value = '';
}
function removeTheme(code) {
if (!watchSource.selectedThemes) return;
watchSource.selectedThemes = watchSource.selectedThemes.filter(t => t.code !== code);
renderWatchSource();
saveWatchSource();
}
// --- 로그 ---
// --- WS 실시간 로그 ---
function connectTradeLogWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
tradeLogWS = new WebSocket(`${proto}//${location.host}/ws`);
tradeLogWS.onopen = () => {
updateWSStatus(true);
};
tradeLogWS.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type !== 'tradelog') return;
const log = msg.data;
localLogs.push(log);
if (localLogs.length > 300) localLogs.shift();
// 필터 조건에 맞으면 DOM에 행 추가
if (logLevelFilter !== 'action' || log.level !== 'debug') {
appendLogRow(log);
}
updateLogTime();
};
tradeLogWS.onclose = () => {
updateWSStatus(false);
// 3초 후 재연결 + 로그 재로드
setTimeout(() => {
loadLogs();
connectTradeLogWS();
}, 3000);
};
tradeLogWS.onerror = () => {
tradeLogWS.close();
};
}
function updateWSStatus(connected) {
const el = document.getElementById('wsStatus');
if (!el) return;
el.textContent = connected ? '● 실시간' : '○ 연결중...';
el.className = connected
? 'text-xs text-green-500 font-medium'
: 'text-xs text-gray-400';
}
function scrollLogsToBottom() {
const wrapper = document.getElementById('logsWrapper');
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
}
function updateLogTime() {
const el = document.getElementById('logUpdateTime');
if (el) el.textContent = new Date().toLocaleTimeString('ko-KR',
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' 갱신';
}
function buildLogRow(l) {
let rowCls, levelCls, msgCls;
if (l.level === 'debug') {
rowCls = 'border-b border-gray-50 bg-gray-50';
levelCls = 'text-gray-400';
msgCls = 'text-gray-400';
} else if (l.level === 'error') {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-red-500';
msgCls = 'text-gray-700';
} else if (l.level === 'warn') {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-yellow-600';
msgCls = 'text-gray-700';
} else {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-gray-500';
msgCls = 'text-gray-700';
}
const time = new Date(l.at).toLocaleTimeString('ko-KR',
{ hour: '2-digit', minute: '2-digit', second: '2-digit' });
return `<tr class="${rowCls}">
<td class="py-1.5 px-1 text-gray-400">${time}</td>
<td class="py-1.5 px-1 font-medium ${levelCls}">${l.level}</td>
<td class="py-1.5 px-1 text-gray-600">${escHtml(l.code)}</td>
<td class="py-1.5 px-1 ${msgCls}">${escHtml(l.message)}</td>
</tr>`;
}
function appendLogRow(log) {
const tbody = document.getElementById('logsList');
if (!tbody) return;
// 빈 상태 메시지 제거
if (tbody.firstElementChild?.tagName === 'TR' &&
tbody.firstElementChild.querySelector('td[colspan]')) {
tbody.innerHTML = '';
}
tbody.insertAdjacentHTML('beforeend', buildLogRow(log));
// 표시 행 수 300개 초과 시 맨 위 행 제거
while (tbody.children.length > 300) tbody.removeChild(tbody.firstChild);
// 자동스크롤
const chk = document.getElementById('autoScrollChk');
if (chk && chk.checked) scrollLogsToBottom();
}
function setLogFilter(level) {
logLevelFilter = level;
const allBtn = document.getElementById('filterAll');
const actionBtn = document.getElementById('filterAction');
if (allBtn && actionBtn) {
if (level === 'all') {
allBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
} else {
allBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
}
}
// 버퍼에서 필터 적용 후 전체 재렌더
renderLogsFromBuffer();
}
async function loadLogs() {
try {
const res = await fetch('/api/autotrade/logs');
const logs = await res.json();
// 서버는 최신순으로 반환 → 뒤집어 오래된 것부터 저장
localLogs = (logs || []).slice(0, 300).reverse();
renderLogsFromBuffer();
} catch (e) {
console.error('로그 초기 로드 실패:', e);
}
}
function renderLogsFromBuffer() {
const tbody = document.getElementById('logsList');
if (!tbody) return;
const filtered = logLevelFilter === 'action'
? localLogs.filter(l => l.level !== 'debug')
: localLogs;
// 최근 300개 표시
const items = filtered.slice(-300);
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-400">로그가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = items.map(l => buildLogRow(l)).join('');
updateLogTime();
scrollLogsToBottom();
}
// --- 유틸 ---
function formatMoney(n) {
if (!n) return '0';
return n.toLocaleString('ko-KR');
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

312
static/js/chart.js Normal file
View File

@@ -0,0 +1,312 @@
/**
* StockChart - TradingView Lightweight Charts 기반 주식 차트
* 틱(기본) / 일봉 / 1분봉 / 5분봉 전환, 실시간 업데이트 지원
*/
let candleChart = null; // 캔들 차트 인스턴스 (lazy)
let candleSeries = null; // 캔들스틱 시리즈
let maSeries = {}; // 이동평균선 시리즈 { 5, 20, 60 }
let tickChart = null; // 틱 차트 인스턴스
let tickSeries = null; // 틱 라인 시리즈
let currentPeriod = 'minute1';
// 틱 데이터 버퍼
const tickBuffer = [];
// 공통 차트 옵션 (autoSize로 컨테이너 크기 자동 추적)
const CHART_BASE_OPTIONS = {
autoSize: true,
layout: {
background: { color: '#ffffff' },
textColor: '#374151',
},
grid: {
vertLines: { color: '#f3f4f6' },
horzLines: { color: '#f3f4f6' },
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
rightPriceScale: { borderColor: '#e5e7eb' },
timeScale: {
borderColor: '#e5e7eb',
timeVisible: true,
},
};
// 틱 차트 초기화
function initTickChart() {
const container = document.getElementById('tickChartContainer');
if (!container || tickChart) return;
tickChart = LightweightCharts.createChart(container, CHART_BASE_OPTIONS);
tickSeries = tickChart.addLineSeries({
color: '#6366f1',
lineWidth: 2,
crosshairMarkerVisible: true,
lastValueVisible: true,
priceLineVisible: false,
});
if (tickBuffer.length > 0) {
tickSeries.setData([...tickBuffer]);
tickChart.timeScale().fitContent();
}
}
// SMA 계산: closes 배열과 period를 받아 [{time, value}] 반환
function calcSMA(candles, period) {
const result = [];
for (let i = period - 1; i < candles.length; i++) {
let sum = 0;
for (let j = i - period + 1; j <= i; j++) sum += candles[j].close;
result.push({ time: candles[i].time, value: Math.round(sum / period) });
}
return result;
}
// 캔들 차트 lazy 초기화
function ensureCandleChart() {
if (candleChart) return;
const container = document.getElementById('chartContainer');
if (!container) return;
candleChart = LightweightCharts.createChart(container, CHART_BASE_OPTIONS);
candleSeries = candleChart.addCandlestickSeries({
upColor: '#ef4444',
downColor: '#3b82f6',
borderUpColor: '#ef4444',
borderDownColor: '#3b82f6',
wickUpColor: '#ef4444',
wickDownColor: '#3b82f6',
});
// 이동평균선 시리즈 추가 (20분/60분/120분)
maSeries[20] = candleChart.addLineSeries({ color: '#f59e0b', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
maSeries[60] = candleChart.addLineSeries({ color: '#10b981', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
maSeries[120] = candleChart.addLineSeries({ color: '#8b5cf6', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
}
// 캔들 데이터 로딩 + 이동평균선 계산
async function loadChart(period) {
if (!STOCK_CODE) return;
const endpoint = period === 'daily'
? `/api/stock/${STOCK_CODE}/chart`
: `/api/stock/${STOCK_CODE}/chart?period=${period}`;
try {
const resp = await fetch(endpoint);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const candles = await resp.json();
if (!candles || candles.length === 0) return;
const data = candles.map(c => ({
time: c.time, open: c.open, high: c.high, low: c.low, close: c.close,
}));
candleSeries.setData(data);
// 이동평균선 업데이트
[20, 60, 120].forEach(n => {
if (maSeries[n]) maSeries[n].setData(calcSMA(data, n));
});
candleChart.timeScale().fitContent();
} catch (err) {
console.error('차트 데이터 로딩 실패:', err);
}
}
// 탭 UI 업데이트
function updateTabUI(period) {
['daily', 'minute1', 'minute5', 'tick'].forEach(p => {
const tab = document.getElementById(`tab-${p}`);
if (!tab) return;
tab.className = p === period
? 'px-4 py-1.5 text-sm rounded-full bg-blue-500 text-white font-medium'
: 'px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200';
});
}
// 탭 전환
function switchChart(period) {
currentPeriod = period;
updateTabUI(period);
const candleEl = document.getElementById('chartContainer');
const tickEl = document.getElementById('tickChartContainer');
if (period === 'tick') {
if (candleEl) candleEl.style.display = 'none';
if (tickEl) tickEl.style.display = 'block';
if (!tickChart) initTickChart();
if (tickSeries && tickBuffer.length > 0) {
tickSeries.setData([...tickBuffer]);
tickChart.timeScale().fitContent();
}
} else {
if (tickEl) tickEl.style.display = 'none';
if (candleEl) candleEl.style.display = 'block';
ensureCandleChart();
loadChart(period);
}
}
// WebSocket 체결 수신 시 틱 버퍼에 추가
function appendTick(price) {
if (!price.currentPrice) return;
const now = Math.floor(Date.now() / 1000);
const point = { time: now, value: price.currentPrice };
if (tickBuffer.length > 0 && tickBuffer[tickBuffer.length - 1].time === now) {
tickBuffer[tickBuffer.length - 1].value = price.currentPrice;
} else {
tickBuffer.push(point);
if (tickBuffer.length > 1000) tickBuffer.shift();
}
if (currentPeriod === 'tick' && tickSeries) {
try { tickSeries.update(point); } catch (_) {}
}
}
// 실시간 현재가로 마지막 캔들 업데이트
function updateLastCandle(price) {
if (!candleSeries || !price.currentPrice) return;
const now = Math.floor(Date.now() / 1000);
try {
candleSeries.update({
time: now,
open: price.open || price.currentPrice,
high: price.high || price.currentPrice,
low: price.low || price.currentPrice,
close: price.currentPrice,
});
} catch (_) {}
}
// 체결시각 포맷 (HHMMSS → HH:MM:SS)
function formatTradeTime(t) {
if (!t || t.length < 6) return '-';
return `${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}`;
}
// 거래대금 포맷
function formatTradeMoney(n) {
if (!n) return '-';
if (n >= 1000000000000) return (n / 1000000000000).toFixed(2) + '조';
if (n >= 100000000) return (n / 100000000).toFixed(1) + '억';
if (n >= 10000) return Math.round(n / 10000) + '만';
return n.toLocaleString('ko-KR');
}
// 현재가 DOM 업데이트
function updatePriceUI(price) {
const priceEl = document.getElementById('currentPrice');
const changeEl = document.getElementById('changeInfo');
const updatedAtEl = document.getElementById('updatedAt');
const highEl = document.getElementById('highPrice');
const lowEl = document.getElementById('lowPrice');
const volumeEl = document.getElementById('volume');
if (!priceEl) return;
const prevPrice = parseInt(priceEl.dataset.raw || '0');
const isUp = price.currentPrice > prevPrice;
const isDown = price.currentPrice < prevPrice;
priceEl.textContent = formatNumber(price.currentPrice) + '원';
priceEl.dataset.raw = price.currentPrice;
const sign = price.changePrice >= 0 ? '+' : '';
changeEl.textContent = `${sign}${formatNumber(price.changePrice)}원 (${sign}${price.changeRate.toFixed(2)}%)`;
changeEl.className = price.changeRate > 0 ? 'text-lg mt-1 text-red-500'
: price.changeRate < 0 ? 'text-lg mt-1 text-blue-500'
: 'text-lg mt-1 text-gray-500';
if (highEl) highEl.textContent = formatNumber(price.high) + '원';
if (lowEl) lowEl.textContent = formatNumber(price.low) + '원';
if (volumeEl) volumeEl.textContent = formatNumber(price.volume);
const tradeTimeEl = document.getElementById('tradeTime');
if (tradeTimeEl && price.tradeTime) tradeTimeEl.textContent = formatTradeTime(price.tradeTime);
const tradeVolEl = document.getElementById('tradeVolume');
if (tradeVolEl && price.tradeVolume) tradeVolEl.textContent = formatNumber(price.tradeVolume);
const tradeMoneyEl = document.getElementById('tradeMoney');
if (tradeMoneyEl) tradeMoneyEl.textContent = formatTradeMoney(price.tradeMoney);
const ask1El = document.getElementById('askPrice1');
const bid1El = document.getElementById('bidPrice1');
if (ask1El && price.askPrice1) ask1El.textContent = formatNumber(price.askPrice1) + '원';
if (bid1El && price.bidPrice1) bid1El.textContent = formatNumber(price.bidPrice1) + '원';
if (updatedAtEl) {
const d = new Date(price.updatedAt);
updatedAtEl.textContent = `${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')} 기준`;
}
if (isUp) {
priceEl.classList.remove('flash-down');
void priceEl.offsetWidth;
priceEl.classList.add('flash-up');
} else if (isDown) {
priceEl.classList.remove('flash-up');
void priceEl.offsetWidth;
priceEl.classList.add('flash-down');
}
updateLastCandle(price);
appendTick(price);
}
// 장운영 상태 업데이트
function updateMarketStatus(ms) {
const el = document.getElementById('marketStatusBadge');
if (!el) return;
el.textContent = ms.statusName || '장 중';
const code = ms.statusCode;
el.className = 'px-2 py-0.5 rounded text-xs font-medium ';
if (code === '3') el.className += 'bg-green-100 text-green-700';
else if (['4','8','9'].includes(code)) el.className += 'bg-gray-100 text-gray-600';
else if (['a','b','c','d'].includes(code)) el.className += 'bg-yellow-100 text-yellow-700';
else el.className += 'bg-blue-50 text-blue-600';
}
// 종목 메타 업데이트
function updateStockMeta(meta) {
const upperEl = document.getElementById('upperLimit');
const lowerEl = document.getElementById('lowerLimit');
const baseEl = document.getElementById('basePrice');
if (upperEl && meta.upperLimit) upperEl.textContent = formatNumber(meta.upperLimit) + '원';
if (lowerEl && meta.lowerLimit) lowerEl.textContent = formatNumber(meta.lowerLimit) + '원';
if (baseEl && meta.basePrice) baseEl.textContent = formatNumber(meta.basePrice) + '원';
}
// 숫자 천 단위 콤마 포맷
function formatNumber(n) {
return Math.abs(n).toLocaleString('ko-KR');
}
// DOMContentLoaded: WebSocket만 먼저 연결
document.addEventListener('DOMContentLoaded', () => {
if (typeof STOCK_CODE === 'undefined' || !STOCK_CODE) return;
stockWS.subscribe(STOCK_CODE);
stockWS.onPrice(STOCK_CODE, updatePriceUI);
stockWS.onOrderBook(STOCK_CODE, renderOrderBook);
stockWS.onProgram(STOCK_CODE, renderProgram);
stockWS.onMeta(STOCK_CODE, updateStockMeta);
stockWS.onMarket(updateMarketStatus);
initOrderBook();
updateTabUI('minute1');
});
// window.load: 레이아웃 완전 확정 후 차트 초기화
window.addEventListener('load', () => {
if (typeof STOCK_CODE === 'undefined' || !STOCK_CODE) return;
// 1분봉 캔들 차트를 기본으로 초기화
const candleEl = document.getElementById('chartContainer');
if (candleEl) candleEl.style.display = 'block';
const tickEl = document.getElementById('tickChartContainer');
if (tickEl) tickEl.style.display = 'none';
ensureCandleChart();
loadChart('minute1');
});

60
static/js/disclosure.js Normal file
View File

@@ -0,0 +1,60 @@
// YYYYMMDD → YYYY.MM.DD
function formatDate(d) {
return d ? `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}` : '-';
}
// 태그별 색상 매핑
const TAG_COLORS = {
'실적': 'bg-blue-100 text-blue-700',
'유증': 'bg-red-100 text-red-700',
'무증': 'bg-orange-100 text-orange-700',
'수주': 'bg-green-100 text-green-700',
'소송': 'bg-red-100 text-red-700',
'M&A': 'bg-purple-100 text-purple-700',
'지분': 'bg-indigo-100 text-indigo-700',
'자사주':'bg-teal-100 text-teal-700',
'경영': 'bg-gray-100 text-gray-600',
'CB/BW': 'bg-yellow-100 text-yellow-700',
'공시': 'bg-gray-100 text-gray-500',
};
function tagBadge(tag) {
const cls = TAG_COLORS[tag] || TAG_COLORS['공시'];
return `<span class="text-xs font-medium px-2 py-0.5 rounded-full ${cls} shrink-0">${tag}</span>`;
}
async function loadDisclosures() {
try {
const resp = await fetch(`/api/disclosure?code=${STOCK_CODE}`);
if (!resp.ok) throw new Error();
const list = await resp.json();
document.getElementById('disclosureLoading').classList.add('hidden');
if (!list || list.length === 0) {
document.getElementById('disclosureEmpty').classList.remove('hidden');
return;
}
const ul = document.getElementById('disclosureList');
list.forEach(item => {
const li = document.createElement('li');
li.className = 'py-3';
li.innerHTML = `<a href="${item.url}" target="_blank" rel="noopener noreferrer"
class="flex items-center gap-2 hover:bg-gray-50 px-1 rounded transition-colors">
${tagBadge(item.tag)}
<span class="text-sm text-gray-800 flex-1 min-w-0 truncate">${item.reportNm}</span>
<span class="text-xs text-gray-400 shrink-0">${formatDate(item.rceptDt)}</span>
</a>`;
ul.appendChild(li);
});
ul.classList.remove('hidden');
} catch {
document.getElementById('disclosureLoading').classList.add('hidden');
document.getElementById('disclosureError').classList.remove('hidden');
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof STOCK_CODE !== 'undefined') loadDisclosures();
});

56
static/js/indices.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* 주요 지수 티커 (코스피·코스닥·다우·나스닥)
* - 10초 주기 폴링
* - 내비게이션 바 하단 어두운 띠에 표시
*/
(function () {
const ticker = document.getElementById('indexTicker');
const INTERVAL = 10 * 1000;
function colorClass(rate) {
if (rate > 0) return 'text-red-400';
if (rate < 0) return 'text-blue-400';
return 'text-gray-400';
}
function arrow(rate) {
if (rate > 0) return '▲';
if (rate < 0) return '▼';
return '';
}
function fmtPrice(name, price) {
if (!price) return '-';
// 코스피·코스닥은 소수점 2자리, 해외는 정수 + 소수점 2자리
return price.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function render(quotes) {
if (!quotes || quotes.length === 0) return;
ticker.innerHTML = quotes.map(q => {
const cls = colorClass(q.changeRate);
const arr = arrow(q.changeRate);
const rate = q.changeRate != null ? (q.changeRate >= 0 ? '+' : '') + q.changeRate.toFixed(2) + '%' : '-';
return `
<span class="shrink-0 flex items-center gap-1.5">
<span class="text-gray-400 font-medium">${q.name}</span>
<span class="font-mono font-semibold">${fmtPrice(q.name, q.price)}</span>
<span class="${cls} font-mono">${arr} ${rate}</span>
</span>`;
}).join('<span class="text-gray-600 shrink-0">|</span>');
}
async function fetch_() {
try {
const resp = await fetch('/api/indices');
if (!resp.ok) return;
const data = await resp.json();
render(data);
} catch (e) {
// 조용히 실패 (티커 미표시)
}
}
fetch_();
setInterval(fetch_, INTERVAL);
})();

195
static/js/kospi200.js Normal file
View File

@@ -0,0 +1,195 @@
/**
* 코스피200 종목 목록 페이지
* - /api/kospi200 폴링 (1분 캐시 기반)
* - 정렬: 등락률 / 거래량 / 현재가
* - 필터: 전체 / 상승 / 하락
* - 종목명 검색
*/
(function () {
let allStocks = [];
let currentSort = 'fluRt'; // fluRt | volume | curPrc
let currentDir = 'all'; // all | up | down
let sortDesc = true; // 기본 내림차순
const listEl = document.getElementById('k200List');
const countEl = document.getElementById('k200Count');
const searchEl = document.getElementById('k200Search');
const updatedEl = document.getElementById('lastUpdated');
// ── 포맷 유틸 ────────────────────────────────────────────────
function fmtNum(n) {
if (n == null || n === 0) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
const sign = f > 0 ? '+' : '';
return sign + f.toFixed(2) + '%';
}
function fmtDiff(n) {
if (n == null || n === 0) return '0';
const sign = n > 0 ? '+' : '';
return sign + Math.abs(n).toLocaleString('ko-KR');
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBg(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// ── 행 렌더 ──────────────────────────────────────────────────
function makeRow(s, rank) {
const cls = rateClass(s.fluRt);
const bgCls = rateBg(s.fluRt);
return `
<a href="/stock/${s.code}"
class="grid grid-cols-[2.5rem_1fr_1fr_90px_90px_100px_90px_90px_90px] gap-2
px-4 py-2.5 hover:bg-gray-50 transition-colors text-sm items-center">
<span class="text-xs text-gray-400 text-center">${rank}</span>
<div class="min-w-0">
<p class="font-medium text-gray-800 truncate">${s.name}</p>
<p class="text-xs text-gray-400 font-mono">${s.code}</p>
</div>
<div class="text-right font-semibold ${cls}">${fmtNum(s.curPrc)}원</div>
<div class="text-right text-xs ${cls}">${fmtDiff(s.predPre)}</div>
<div class="text-right">
<span class="text-xs px-1.5 py-0.5 rounded font-semibold ${bgCls}">${fmtRate(s.fluRt)}</span>
</div>
<div class="text-right text-xs text-gray-500">${fmtNum(s.volume)}</div>
<div class="text-right text-xs text-gray-400">${fmtNum(s.open)}</div>
<div class="text-right text-xs text-red-400">${fmtNum(s.high)}</div>
<div class="text-right text-xs text-blue-400">${fmtNum(s.low)}</div>
</a>`;
}
// ── 필터 + 정렬 + 렌더 ───────────────────────────────────────
function renderList() {
const q = searchEl.value.trim();
let filtered = allStocks;
// 상승/하락 필터
if (currentDir === 'up') filtered = filtered.filter(s => s.fluRt > 0);
if (currentDir === 'down') filtered = filtered.filter(s => s.fluRt < 0);
// 종목명 검색
if (q) filtered = filtered.filter(s => s.name.includes(q) || s.code.includes(q));
// 정렬
filtered = [...filtered].sort((a, b) => {
const diff = b[currentSort] - a[currentSort];
return sortDesc ? diff : -diff;
});
countEl.textContent = `${filtered.length}개 종목`;
if (filtered.length === 0) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400">조건에 맞는 종목이 없습니다.</div>`;
return;
}
listEl.innerHTML = filtered.map((s, i) => makeRow(s, i + 1)).join('');
}
// ── 데이터 로드 ───────────────────────────────────────────────
async function loadData() {
try {
const resp = await fetch('/api/kospi200');
if (!resp.ok) throw new Error('조회 실패');
allStocks = await resp.json();
updatedEl.textContent = new Date().toTimeString().slice(0, 8) + ' 기준';
renderList();
} catch (e) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-red-400">데이터를 불러오지 못했습니다.</div>`;
console.error('코스피200 조회 실패:', e);
}
}
// ── 헤더 정렬 화살표 갱신 ────────────────────────────────────
function updateColHeaders() {
document.querySelectorAll('.col-sort').forEach(el => {
const arrow = el.querySelector('.sort-arrow');
if (!arrow) return;
if (el.dataset.col === currentSort) {
arrow.textContent = sortDesc ? '▼' : '▲';
arrow.className = 'sort-arrow text-blue-400';
el.classList.add('text-blue-500');
el.classList.remove('text-gray-500');
} else {
arrow.textContent = '';
arrow.className = 'sort-arrow';
el.classList.remove('text-blue-500');
el.classList.add('text-gray-500');
}
});
}
// ── 정렬 탭 이벤트 ───────────────────────────────────────────
document.querySelectorAll('.sort-tab').forEach(btn => {
btn.addEventListener('click', () => {
if (currentSort === btn.dataset.sort) {
sortDesc = !sortDesc;
} else {
currentSort = btn.dataset.sort;
sortDesc = true;
}
document.querySelectorAll('.sort-tab').forEach(b => {
const active = b.dataset.sort === currentSort;
b.className = active
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
updateColHeaders();
renderList();
});
});
// ── 헤더 컬럼 클릭 정렬 ──────────────────────────────────────
document.querySelectorAll('.col-sort').forEach(el => {
el.addEventListener('click', () => {
const col = el.dataset.col;
if (currentSort === col) {
sortDesc = !sortDesc;
} else {
currentSort = col;
sortDesc = true;
}
// 상단 정렬 탭 동기화
document.querySelectorAll('.sort-tab').forEach(b => {
const active = b.dataset.sort === currentSort;
b.className = active
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
updateColHeaders();
renderList();
});
});
// ── 방향 필터 탭 이벤트 ──────────────────────────────────────
document.querySelectorAll('.dir-tab').forEach(btn => {
btn.addEventListener('click', () => {
currentDir = btn.dataset.dir;
document.querySelectorAll('.dir-tab').forEach(b => {
const active = b.dataset.dir === currentDir;
b.className = active
? 'dir-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'dir-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
renderList();
});
});
// ── 검색 이벤트 ──────────────────────────────────────────────
searchEl.addEventListener('input', renderList);
// ── 초기 로드 + 1분 자동 갱신 ────────────────────────────────
updateColHeaders();
loadData();
setInterval(loadData, 60 * 1000);
})();

49
static/js/news.js Normal file
View File

@@ -0,0 +1,49 @@
// RFC1123Z → "MM/DD HH:MM" 형식으로 변환
function formatNewsDate(s) {
if (!s) return '';
const d = new Date(s);
if (isNaN(d)) return s;
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${min}`;
}
async function loadNews() {
try {
const resp = await fetch(`/api/news?name=${encodeURIComponent(STOCK_NAME)}`);
if (!resp.ok) throw new Error();
const list = await resp.json();
document.getElementById('newsLoading').classList.add('hidden');
if (!list || list.length === 0) {
document.getElementById('newsEmpty').classList.remove('hidden');
return;
}
const ul = document.getElementById('newsList');
list.forEach(item => {
const li = document.createElement('li');
li.className = 'py-3';
li.innerHTML = `<a href="${item.url}" target="_blank" rel="noopener noreferrer"
class="flex items-start gap-3 hover:bg-gray-50 px-1 rounded transition-colors">
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-800 truncate">${item.title}</p>
<p class="text-xs text-gray-400 mt-0.5">${item.source}</p>
</div>
<span class="text-xs text-gray-400 shrink-0 mt-0.5">${formatNewsDate(item.publishedAt)}</span>
</a>`;
ul.appendChild(li);
});
ul.classList.remove('hidden');
} catch {
document.getElementById('newsLoading').classList.add('hidden');
document.getElementById('newsError').classList.remove('hidden');
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof STOCK_NAME !== 'undefined') loadNews();
});

487
static/js/order.js Normal file
View File

@@ -0,0 +1,487 @@
/**
* 주문창 UI 전체 로직
* - 매수/매도 탭, 주문유형, 단가/수량 입력, 주문 제출
* - 미체결/체결/잔고 탭
*/
// 현재 활성 탭: 'buy' | 'sell'
let orderSide = 'buy';
// 주문가능수량 (퍼센트 버튼용)
let orderableQty = 0;
// 현재 종목 코드 (전역에서 STOCK_CODE 사용)
// -----------------------------------
// 호가 단위(틱) 계산
// -----------------------------------
function getTickSize(price) {
if (price < 2000) return 1;
if (price < 5000) return 5;
if (price < 20000) return 10;
if (price < 50000) return 50;
if (price < 200000) return 100;
if (price < 500000) return 500;
return 1000;
}
// -----------------------------------
// 초기화
// -----------------------------------
function initOrder() {
updateOrderSide('buy');
loadOrderable();
loadPendingTab();
}
// -----------------------------------
// 매수/매도 탭 전환
// -----------------------------------
function updateOrderSide(side) {
orderSide = side;
const buyTab = document.getElementById('orderBuyTab');
const sellTab = document.getElementById('orderSellTab');
const submitBtn = document.getElementById('orderSubmitBtn');
if (side === 'buy') {
buyTab.classList.add('bg-red-500', 'text-white');
buyTab.classList.remove('bg-gray-100', 'text-gray-600');
sellTab.classList.add('bg-gray-100', 'text-gray-600');
sellTab.classList.remove('bg-blue-500', 'text-white');
submitBtn.textContent = '매수';
submitBtn.className = 'w-full py-2.5 text-sm font-bold rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors';
} else {
sellTab.classList.add('bg-blue-500', 'text-white');
sellTab.classList.remove('bg-gray-100', 'text-gray-600');
buyTab.classList.add('bg-gray-100', 'text-gray-600');
buyTab.classList.remove('bg-red-500', 'text-white');
submitBtn.textContent = '매도';
submitBtn.className = 'w-full py-2.5 text-sm font-bold rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors';
}
// 폼 초기화
resetOrderForm();
loadOrderable();
}
function resetOrderForm() {
const priceEl = document.getElementById('orderPrice');
const qtyEl = document.getElementById('orderQty');
if (priceEl) priceEl.value = '';
if (qtyEl) qtyEl.value = '';
updateOrderTotal();
hideOrderConfirm();
}
// -----------------------------------
// 주문유형 변경 처리
// -----------------------------------
function onTradeTypeChange() {
const tp = document.getElementById('orderTradeType').value;
const priceEl = document.getElementById('orderPrice');
const priceUpBtn = document.getElementById('priceUpBtn');
const priceDownBtn = document.getElementById('priceDownBtn');
// 시장가(3)일 때 단가 입력 비활성화
const isMarket = (tp === '3');
priceEl.disabled = isMarket;
priceUpBtn.disabled = isMarket;
priceDownBtn.disabled = isMarket;
if (isMarket) {
priceEl.value = '';
priceEl.placeholder = '시장가';
updateOrderTotal();
} else {
priceEl.placeholder = '단가 입력';
}
}
// -----------------------------------
// 단가 ▲▼ 버튼
// -----------------------------------
function adjustPrice(direction) {
const el = document.getElementById('orderPrice');
// 입력란이 비어 있으면 현재가를 기준으로 시작
let price = parseInt(el.value, 10);
if (!price || isNaN(price)) {
const rawEl = document.getElementById('currentPrice');
price = rawEl ? parseInt(rawEl.dataset.raw || '0', 10) : 0;
}
const tick = getTickSize(price);
price += direction * tick;
if (price < 1) price = 1;
el.value = price;
updateOrderTotal();
loadOrderable();
}
// -----------------------------------
// 호가창 클릭 → 단가 자동 입력
// -----------------------------------
window.setOrderPrice = function(price) {
const priceEl = document.getElementById('orderPrice');
if (!priceEl || priceEl.disabled) return;
priceEl.value = price;
updateOrderTotal();
loadOrderable();
};
// -----------------------------------
// 총 주문금액 실시간 표시
// -----------------------------------
function updateOrderTotal() {
const price = parseInt(document.getElementById('orderPrice').value.replace(/,/g, ''), 10) || 0;
const qty = parseInt(document.getElementById('orderQty').value.replace(/,/g, ''), 10) || 0;
const total = price * qty;
const el = document.getElementById('orderTotal');
if (el) el.textContent = total > 0 ? total.toLocaleString('ko-KR') + '원' : '-';
}
// -----------------------------------
// 수량 퍼센트 버튼
// -----------------------------------
function setQtyPercent(pct) {
if (orderableQty <= 0) return;
const qty = Math.floor(orderableQty * pct / 100);
const el = document.getElementById('orderQty');
if (el) el.value = qty > 0 ? qty : 0;
updateOrderTotal();
}
// -----------------------------------
// 주문가능금액/수량 조회
// -----------------------------------
async function loadOrderable() {
const code = typeof STOCK_CODE !== 'undefined' ? STOCK_CODE : '';
const price = document.getElementById('orderPrice')?.value || '0';
const side = orderSide === 'buy' ? 'buy' : 'sell';
try {
const res = await fetch(`/api/account/orderable?code=${code}&price=${price}&side=${side}`);
const data = await res.json();
const qtyEl = document.getElementById('orderableQty');
const amtEl = document.getElementById('orderableAmt');
const entrEl = document.getElementById('orderableEntr');
if (data.ordAlowq) {
orderableQty = parseInt(data.ordAlowq.replace(/,/g, ''), 10) || 0;
if (qtyEl) qtyEl.textContent = orderableQty.toLocaleString('ko-KR') + '주';
} else {
orderableQty = 0;
if (qtyEl) qtyEl.textContent = '-';
}
if (amtEl) amtEl.textContent = data.ordAlowa ? parseInt(data.ordAlowa.replace(/,/g, ''), 10).toLocaleString('ko-KR') + '원' : '-';
if (entrEl) entrEl.textContent = data.entr ? parseInt(data.entr.replace(/,/g, ''), 10).toLocaleString('ko-KR') + '원' : '-';
} catch (e) {
// 조회 실패 시 조용히 처리
}
}
// -----------------------------------
// 주문 확인 메시지 표시/숨김
// -----------------------------------
function showOrderConfirm() {
const price = parseInt(document.getElementById('orderPrice').value.replace(/,/g, ''), 10) || 0;
const qty = parseInt(document.getElementById('orderQty').value.replace(/,/g, ''), 10) || 0;
const tp = document.getElementById('orderTradeType').value;
const name = typeof STOCK_NAME !== 'undefined' ? STOCK_NAME : '';
if (qty <= 0) {
showOrderToast('수량을 입력해주세요.', 'error');
return;
}
if (tp !== '3' && price <= 0) {
showOrderToast('단가를 입력해주세요.', 'error');
return;
}
const sideText = orderSide === 'buy' ? '매수' : '매도';
const priceText = tp === '3' ? '시장가' : price.toLocaleString('ko-KR') + '원';
const msg = `${name} ${qty.toLocaleString('ko-KR')}${priceText} ${sideText} 하시겠습니까?`;
const confirmEl = document.getElementById('orderConfirmMsg');
const confirmBox = document.getElementById('orderConfirmBox');
if (confirmEl) confirmEl.textContent = msg;
if (confirmBox) confirmBox.classList.remove('hidden');
document.getElementById('orderSubmitBtn').classList.add('hidden');
}
function hideOrderConfirm() {
const confirmBox = document.getElementById('orderConfirmBox');
const submitBtn = document.getElementById('orderSubmitBtn');
if (confirmBox) confirmBox.classList.add('hidden');
if (submitBtn) submitBtn.classList.remove('hidden');
}
// -----------------------------------
// 주문 제출
// -----------------------------------
async function submitOrder() {
const price = document.getElementById('orderPrice').value || '';
const qty = document.getElementById('orderQty').value || '';
const tradeTP = document.getElementById('orderTradeType').value;
const exchange = document.querySelector('input[name="orderExchange"]:checked')?.value || 'KRX';
const code = typeof STOCK_CODE !== 'undefined' ? STOCK_CODE : '';
hideOrderConfirm();
const payload = {
exchange: exchange,
code: code,
qty: qty.replace(/,/g, ''),
price: tradeTP === '3' ? '' : price.replace(/,/g, ''),
tradeTP: tradeTP,
};
const url = orderSide === 'buy' ? '/api/order/buy' : '/api/order/sell';
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.error) {
showOrderToast(data.error || '주문 실패', 'error');
return;
}
const sideText = orderSide === 'buy' ? '매수' : '매도';
showOrderToast(`${sideText} 주문 접수 완료 (주문번호: ${data.orderNo})`, 'success');
resetOrderForm();
loadOrderable();
// 미체결 탭 갱신
loadPendingTab();
if (document.getElementById('pendingTab')?.classList.contains('active-tab')) {
showAccountTab('pending');
}
} catch (e) {
showOrderToast('네트워크 오류: ' + e.message, 'error');
}
}
// -----------------------------------
// 토스트 알림
// -----------------------------------
function showOrderToast(msg, type) {
const toast = document.createElement('div');
toast.className = `fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-lg text-sm font-medium shadow-lg transition-opacity
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 3000);
}
// -----------------------------------
// 계좌 탭 전환
// -----------------------------------
function showAccountTab(tab) {
['pending', 'history', 'balance'].forEach(t => {
const btn = document.getElementById(t + 'Tab');
const panel = document.getElementById(t + 'Panel');
if (btn) {
if (t === tab) {
btn.classList.add('border-b-2', 'border-blue-500', 'text-blue-600', 'active-tab');
btn.classList.remove('text-gray-500');
} else {
btn.classList.remove('border-b-2', 'border-blue-500', 'text-blue-600', 'active-tab');
btn.classList.add('text-gray-500');
}
}
if (panel) panel.classList.toggle('hidden', t !== tab);
});
if (tab === 'pending') loadPendingTab();
if (tab === 'history') loadHistoryTab();
if (tab === 'balance') loadBalanceTab();
}
// -----------------------------------
// 미체결 탭
// -----------------------------------
async function loadPendingTab() {
const panel = document.getElementById('pendingPanel');
if (!panel) return;
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
try {
const res = await fetch('/api/account/pending');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${list.error || '조회 실패'}</p>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">미체결 내역이 없습니다.</p>';
return;
}
panel.innerHTML = list.map(o => {
const isBuy = o.trdeTp === '2';
const sideClass = isBuy ? 'text-red-500' : 'text-blue-500';
const sideText = isBuy ? '매수' : '매도';
return `
<div class="flex items-center justify-between py-2 border-b border-gray-100 text-xs gap-2">
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-800 truncate">${o.stkNm}</p>
<p class="${sideClass}">${sideText} · ${parseInt(o.ordPric||0).toLocaleString('ko-KR')}원</p>
<p class="text-gray-400">미체결 ${parseInt(o.osoQty||0).toLocaleString('ko-KR')}/${parseInt(o.ordQty||0).toLocaleString('ko-KR')}주</p>
</div>
<div class="flex gap-1 shrink-0">
<button onclick="cancelOrder('${o.ordNo}','${o.stkCd}')"
class="px-2 py-1 text-xs rounded border border-gray-300 text-gray-600 hover:bg-gray-100">취소</button>
</div>
</div>`;
}).join('');
} catch (e) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
}
}
// -----------------------------------
// 미체결 취소
// -----------------------------------
async function cancelOrder(ordNo, stkCd) {
if (!confirm('전량 취소하시겠습니까?')) return;
try {
const res = await fetch('/api/order', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ origOrdNo: ordNo, code: stkCd, qty: '0', exchange: 'KRX' }),
});
const data = await res.json();
if (!res.ok || data.error) {
showOrderToast(data.error || '취소 실패', 'error');
return;
}
showOrderToast('취소 주문 접수 완료', 'success');
loadPendingTab();
} catch (e) {
showOrderToast('네트워크 오류', 'error');
}
}
// -----------------------------------
// 체결내역 탭
// -----------------------------------
async function loadHistoryTab() {
const panel = document.getElementById('historyPanel');
if (!panel) return;
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
try {
const res = await fetch('/api/account/history');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${list.error || '조회 실패'}</p>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">체결 내역이 없습니다.</p>';
return;
}
panel.innerHTML = list.map(o => {
const isBuy = o.trdeTp === '2';
const sideClass = isBuy ? 'text-red-500' : 'text-blue-500';
const sideText = isBuy ? '매수' : '매도';
const fee = (parseInt(o.trdeCmsn||0) + parseInt(o.trdeTax||0)).toLocaleString('ko-KR');
return `
<div class="py-2 border-b border-gray-100 text-xs">
<div class="flex justify-between">
<span class="font-semibold text-gray-800">${o.stkNm}</span>
<span class="${sideClass}">${sideText}</span>
</div>
<div class="flex justify-between text-gray-500 mt-0.5">
<span>체결가 ${parseInt(o.cntrPric||0).toLocaleString('ko-KR')}× ${parseInt(o.cntrQty||0).toLocaleString('ko-KR')}주</span>
<span>수수료+세금 ${fee}원</span>
</div>
</div>`;
}).join('');
} catch (e) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
}
}
// -----------------------------------
// 잔고 탭
// -----------------------------------
async function loadBalanceTab() {
const panel = document.getElementById('balancePanel');
if (!panel) return;
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
try {
const res = await fetch('/api/account/balance');
const data = await res.json();
if (!res.ok) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${data.error || '조회 실패'}</p>`;
return;
}
const plClass = parseFloat(data.totPrftRt || '0') >= 0 ? 'text-red-500' : 'text-blue-500';
let html = `
<div class="grid grid-cols-2 gap-2 text-xs mb-3 pb-2 border-b border-gray-100">
<div>
<p class="text-gray-400">추정예탁자산</p>
<p class="font-semibold">${parseInt(data.prsmDpstAsetAmt||0).toLocaleString('ko-KR')}원</p>
</div>
<div>
<p class="text-gray-400">총평가손익</p>
<p class="font-semibold ${plClass}">${parseInt(data.totEvltPl||0).toLocaleString('ko-KR')}원</p>
</div>
<div>
<p class="text-gray-400">총평가금액</p>
<p class="font-semibold">${parseInt(data.totEvltAmt||0).toLocaleString('ko-KR')}원</p>
</div>
<div>
<p class="text-gray-400">수익률</p>
<p class="font-semibold ${plClass}">${parseFloat(data.totPrftRt||0).toFixed(2)}%</p>
</div>
</div>`;
if (!data.stocks || data.stocks.length === 0) {
html += '<p class="text-xs text-gray-400 text-center py-2">보유 종목이 없습니다.</p>';
} else {
html += data.stocks.map(s => {
const prft = parseFloat(s.prftRt || '0');
const cls = prft >= 0 ? 'text-red-500' : 'text-blue-500';
return `
<div class="py-2 border-b border-gray-100 text-xs">
<div class="flex justify-between">
<span class="font-semibold text-gray-800">${s.stkNm}</span>
<span class="${cls}">${prft >= 0 ? '+' : ''}${prft.toFixed(2)}%</span>
</div>
<div class="flex justify-between text-gray-500 mt-0.5">
<span>${parseInt(s.rmndQty||0).toLocaleString('ko-KR')}주 / 평단 ${parseInt(s.purPric||0).toLocaleString('ko-KR')}원</span>
<span class="${cls}">${parseInt(s.evltvPrft||0).toLocaleString('ko-KR')}원</span>
</div>
</div>`;
}).join('');
}
panel.innerHTML = html;
} catch (e) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
}
}
// -----------------------------------
// DOM 준비 후 초기화
// -----------------------------------
document.addEventListener('DOMContentLoaded', initOrder);

145
static/js/orderbook.js Normal file
View File

@@ -0,0 +1,145 @@
/**
* 실시간 호가창 (OrderBook) 렌더러
* 0D: 주식호가잔량, 0w: 프로그램매매
*/
// 호가창 초기화
function initOrderBook() {
renderOrderBook(null);
renderProgram(null);
}
// 호가창 렌더링
// asks[0] = 매도1호가(최우선, 가장 낮은 매도가), asks[9] = 매도10호가
// bids[0] = 매수1호가(최우선, 가장 높은 매수가), bids[9] = 매수10호가
function renderOrderBook(ob) {
const tbody = document.getElementById('orderbookBody');
if (!tbody) return;
if (!ob) {
tbody.innerHTML = `<tr><td colspan="3" class="text-center py-4 text-xs text-gray-400">호가 데이터 수신 대기 중...</td></tr>`;
return;
}
// 최대 잔량 (진행바 비율 계산용)
const maxVol = Math.max(
...ob.asks.map(a => a.volume),
...ob.bids.map(b => b.volume),
1
);
let html = '';
// 매도호가: 10호가부터 1호가 순으로 위에서 아래 (asks[9]→asks[0])
for (let i = 9; i >= 0; i--) {
const ask = ob.asks[i] || { price: 0, volume: 0 };
const pct = Math.round((ask.volume / maxVol) * 100);
html += `
<tr class="ask-row border-b border-gray-50 hover:bg-red-50 transition-colors cursor-pointer" data-price="${ask.price}">
<td class="py-1.5 px-2 text-right">
<div class="relative h-6 flex items-center justify-end">
<div class="absolute right-0 top-0 h-full bg-red-100 rounded-l" style="width:${pct}%"></div>
<span class="relative text-xs text-gray-600 font-mono z-10">${ask.volume > 0 ? ask.volume.toLocaleString('ko-KR') : ''}</span>
</div>
</td>
<td class="py-1.5 px-2 text-center">
<span class="text-sm font-bold text-red-500">${ask.price > 0 ? ask.price.toLocaleString('ko-KR') : '-'}</span>
</td>
<td class="py-1.5 px-2"></td>
</tr>`;
}
// 예상체결 행 (스프레드 사이)
if (ob.expectedPrc > 0) {
html += `
<tr class="bg-yellow-50 border-y-2 border-yellow-300">
<td class="py-1.5 px-2 text-right text-xs text-yellow-700">예상</td>
<td class="py-1.5 px-2 text-center text-sm font-bold text-yellow-700">${ob.expectedPrc.toLocaleString('ko-KR')}</td>
<td class="py-1.5 px-2 text-left text-xs text-yellow-700">${ob.expectedVol > 0 ? ob.expectedVol.toLocaleString('ko-KR') : ''}</td>
</tr>`;
}
// 매수호가: 1호가부터 10호가 순으로 위에서 아래 (bids[0]→bids[9])
for (let i = 0; i < ob.bids.length; i++) {
const bid = ob.bids[i] || { price: 0, volume: 0 };
const pct = Math.round((bid.volume / maxVol) * 100);
html += `
<tr class="bid-row border-b border-gray-50 hover:bg-blue-50 transition-colors cursor-pointer" data-price="${bid.price}">
<td class="py-1.5 px-2"></td>
<td class="py-1.5 px-2 text-center">
<span class="text-sm font-bold text-blue-500">${bid.price > 0 ? bid.price.toLocaleString('ko-KR') : '-'}</span>
</td>
<td class="py-1.5 px-2 text-left">
<div class="relative h-6 flex items-center">
<div class="absolute left-0 top-0 h-full bg-blue-100 rounded-r" style="width:${pct}%"></div>
<span class="relative text-xs text-gray-600 font-mono z-10">${bid.volume > 0 ? bid.volume.toLocaleString('ko-KR') : ''}</span>
</div>
</td>
</tr>`;
}
tbody.innerHTML = html;
// 호가행 클릭 → 주문창 단가 자동 입력
tbody.querySelectorAll('tr[data-price]').forEach(row => {
row.addEventListener('click', () => {
const price = parseInt(row.dataset.price, 10);
if (price > 0 && window.setOrderPrice) window.setOrderPrice(price);
});
});
// 총잔량 업데이트
const totalAsk = document.getElementById('totalAskVol');
const totalBid = document.getElementById('totalBidVol');
if (totalAsk) totalAsk.textContent = ob.totalAskVol.toLocaleString('ko-KR');
if (totalBid) totalBid.textContent = ob.totalBidVol.toLocaleString('ko-KR');
// 호가 시간 업데이트
const askTime = document.getElementById('askTime');
if (askTime && ob.askTime && ob.askTime.length >= 6) {
const t = ob.askTime;
askTime.textContent = `${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}`;
}
}
// 프로그램 매매 렌더링
function renderProgram(pg) {
const container = document.getElementById('programTrading');
if (!container) return;
if (!pg) {
container.innerHTML = `<span class="text-xs text-gray-400">프로그램 매매 데이터 수신 대기 중...</span>`;
return;
}
const netClass = pg.netBuyVolume >= 0 ? 'text-red-500' : 'text-blue-500';
const netSign = pg.netBuyVolume >= 0 ? '+' : '';
container.innerHTML = `
<div class="grid grid-cols-3 gap-3 text-xs">
<div class="text-center">
<p class="text-gray-400 mb-0.5">매도</p>
<p class="font-semibold text-blue-500">${(pg.sellVolume||0).toLocaleString('ko-KR')}</p>
<p class="text-gray-500">${formatMoney(pg.sellAmount)}원</p>
</div>
<div class="text-center border-x border-gray-100">
<p class="text-gray-400 mb-0.5">순매수</p>
<p class="font-semibold ${netClass}">${netSign}${(pg.netBuyVolume||0).toLocaleString('ko-KR')}</p>
<p class="${netClass}">${netSign}${formatMoney(pg.netBuyAmount)}원</p>
</div>
<div class="text-center">
<p class="text-gray-400 mb-0.5">매수</p>
<p class="font-semibold text-red-500">${(pg.buyVolume||0).toLocaleString('ko-KR')}</p>
<p class="text-gray-500">${formatMoney(pg.buyAmount)}원</p>
</div>
</div>`;
}
// 금액을 억/만 단위로 포맷
function formatMoney(n) {
if (!n) return '0';
const abs = Math.abs(n);
if (abs >= 100000000) return (n / 100000000).toFixed(1) + '억';
if (abs >= 10000) return Math.round(n / 10000) + '만';
return n.toLocaleString('ko-KR');
}

140
static/js/ranking.js Normal file
View File

@@ -0,0 +1,140 @@
/**
* 상승률 TOP 10 실시간 갱신
* - 페이지 로드 시 즉시 조회 + WS 개별 구독
* - 30초마다 랭킹 순위 재조회 (종목 변동 반영)
* - WS로 현재가/등락률/거래량/체결강도 1초 단위 갱신
*/
(function () {
const gridEl = document.getElementById('rankingGrid');
const updatedEl = document.getElementById('rankingUpdatedAt');
const INTERVAL = 30 * 1000; // 30초 (순위 재조회 주기)
// 현재 구독 중인 종목 코드 목록 (재조회 시 해제용)
let currentCodes = [];
// --- 숫자 포맷 ---
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
const sign = f >= 0 ? '+' : '';
return sign + f.toFixed(2) + '%';
}
function fmtCntr(f) {
if (!f) return '-';
return f.toFixed(2);
}
// --- 등락률에 따른 CSS 클래스 ---
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBadgeClass(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
function cntrClass(f) {
if (f > 100) return 'text-red-500';
if (f > 0 && f < 100) return 'text-blue-500';
return 'text-gray-400';
}
// --- 카드 HTML 생성 (실시간 업데이트용 ID 포함) ---
function makeCard(s) {
const colorCls = rateClass(s.changeRate);
return `
<a href="/stock/${s.code}" id="rk-${s.code}"
class="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-4 border border-gray-100">
<div class="flex justify-between items-start mb-2">
<span class="text-xs text-gray-400 font-mono">${s.code}</span>
<span id="rk-rate-${s.code}" class="text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(s.changeRate)}">
${fmtRate(s.changeRate)}
</span>
</div>
<p class="font-semibold text-gray-800 text-sm truncate mb-1">${s.name}</p>
<p id="rk-price-${s.code}" class="text-lg font-bold ${colorCls}">${fmtNum(s.currentPrice)}원</p>
<div class="flex justify-between text-xs mt-1">
<span class="text-gray-400">거래량 <span id="rk-vol-${s.code}">${fmtNum(s.volume)}</span></span>
<span id="rk-cntr-${s.code}" class="${cntrClass(s.cntrStr)}">체결강도 ${fmtCntr(s.cntrStr)}</span>
</div>
</a>`;
}
// --- WS 실시간 카드 업데이트 ---
function updateCard(code, data) {
const priceEl = document.getElementById(`rk-price-${code}`);
const rateEl = document.getElementById(`rk-rate-${code}`);
const volEl = document.getElementById(`rk-vol-${code}`);
const cntrEl = document.getElementById(`rk-cntr-${code}`);
if (!priceEl) return;
const rate = data.changeRate ?? 0;
const colorCls = rateClass(rate);
const sign = rate >= 0 ? '+' : '';
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-lg font-bold ${colorCls}`;
rateEl.textContent = sign + rate.toFixed(2) + '%';
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(rate)}`;
volEl.textContent = fmtNum(data.volume);
if (cntrEl && data.cntrStr !== undefined) {
cntrEl.textContent = '체결강도 ' + fmtCntr(data.cntrStr);
cntrEl.className = cntrClass(data.cntrStr);
}
}
// --- 이전 구독 해제 후 새 종목 구독 ---
function resubscribe(newCodes) {
// 빠진 종목 구독 해제
currentCodes.forEach(code => {
if (!newCodes.includes(code)) {
stockWS.unsubscribe(code);
}
});
// 새로 추가된 종목 구독
newCodes.forEach(code => {
if (!currentCodes.includes(code)) {
stockWS.subscribe(code);
stockWS.onPrice(code, data => updateCard(code, data));
}
});
currentCodes = newCodes;
}
// --- 랭킹 조회 및 렌더링 ---
async function fetchAndRender() {
try {
const resp = await fetch('/api/ranking?market=J&dir=up');
if (!resp.ok) throw new Error('조회 실패');
const stocks = await resp.json();
if (!Array.isArray(stocks) || stocks.length === 0) {
gridEl.innerHTML = '<p class="col-span-5 text-gray-400 text-center py-8">데이터가 없습니다.</p>';
return;
}
gridEl.innerHTML = stocks.map(makeCard).join('');
// WS 구독 갱신
resubscribe(stocks.map(s => s.code));
// 순위 갱신 시각 표시
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
updatedEl.textContent = `${hh}:${mm}:${ss} 기준`;
} catch (e) {
console.error('랭킹 조회 실패:', e);
}
}
// 초기 로드 + 30초마다 순위 재조회
fetchAndRender();
setInterval(fetchAndRender, INTERVAL);
})();

68
static/js/search.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* 종목 검색 자동완성 (Debounce 300ms)
*/
(function () {
const input = document.getElementById('searchInput');
const dropdown = document.getElementById('searchDropdown');
if (!input || !dropdown) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = input.value.trim();
if (q.length < 1) {
dropdown.classList.add('hidden');
return;
}
debounceTimer = setTimeout(() => fetchSuggestions(q), 300);
});
// 외부 클릭 시 드롭다운 닫기
document.addEventListener('click', (e) => {
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// 엔터 시 검색 결과 페이지 이동
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const q = input.value.trim();
if (q) location.href = `/search?q=${encodeURIComponent(q)}`;
}
});
async function fetchSuggestions(q) {
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
if (!resp.ok) return;
const results = await resp.json();
renderDropdown(results);
} catch (e) {
console.error('검색 요청 실패:', e);
}
}
function renderDropdown(results) {
if (!results || results.length === 0) {
dropdown.classList.add('hidden');
return;
}
dropdown.innerHTML = results.slice(0, 8).map(item => `
<a href="/stock/${item.code}"
class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 cursor-pointer border-b border-gray-50 last:border-0">
<div>
<span class="font-medium text-gray-800 text-sm">${item.name}</span>
<span class="text-xs text-gray-400 ml-2">${item.code}</span>
</div>
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full">${item.market}</span>
</a>
`).join('');
dropdown.classList.remove('hidden');
}
})();

457
static/js/signal.js Normal file
View File

@@ -0,0 +1,457 @@
/**
* 체결강도 상승 감지 실시간 표시
* - 10초마다 /api/signal 폴링
* - 감지된 종목 WS 구독으로 실시간 가격/체결강도 갱신
* - 각 카드에 체결강도 히스토리 미니 라인차트 표시
*/
(function () {
const gridEl = document.getElementById('signalGrid');
const emptyEl = document.getElementById('signalEmpty');
const updatedEl = document.getElementById('signalUpdatedAt');
const INTERVAL = 10 * 1000;
const MAX_HISTORY = 60; // 최대 60개 포인트 유지
let currentCodes = [];
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
const cntrHistory = new Map(); // code → number[]
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
return (f >= 0 ? '+' : '') + f.toFixed(2) + '%';
}
function fmtCntr(f) {
return f ? f.toFixed(2) : '-';
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBadgeClass(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// 체결강도 히스토리에 값 추가
function recordCntr(code, value) {
if (value == null || value === 0) return;
if (!cntrHistory.has(code)) cntrHistory.set(code, []);
const arr = cntrHistory.get(code);
arr.push(value);
if (arr.length > MAX_HISTORY) arr.shift();
}
// canvas에 체결강도 라인차트 그리기
function drawChart(code) {
const canvas = document.getElementById(`sg-chart-${code}`);
if (!canvas) return;
const data = cntrHistory.get(code) || [];
if (data.length < 2) return;
// canvas 픽셀 크기를 실제 표시 크기에 맞춤 (선명도 유지)
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.floor(rect.width * dpr);
const h = Math.floor(rect.height * dpr);
if (w === 0 || h === 0) return;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const ctx = canvas.getContext('2d');
const pad = 3 * dpr; // 상하 여백
const drawH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const step = w / (data.length - 1);
const getY = val => h - pad - ((val - min) / range) * drawH;
// 그라디언트 필
const lastX = (data.length - 1) * step;
const grad = ctx.createLinearGradient(0, pad, 0, h);
grad.addColorStop(0, 'rgba(249,115,22,0.35)');
grad.addColorStop(1, 'rgba(249,115,22,0)');
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(lastX, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// 라인
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 1.5 * dpr;
ctx.lineJoin = 'round';
ctx.stroke();
// 마지막 포인트 강조
const lastVal = data[data.length - 1];
ctx.beginPath();
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#f97316';
ctx.fill();
}
// 신호 유형 뱃지 HTML 생성
function signalTypeBadge(s) {
if (!s.signalType) return '';
const map = {
'강한매수': 'bg-red-600 text-white border-red-700',
'매수우세': 'bg-orange-400 text-white border-orange-500',
'물량소화': 'bg-yellow-100 text-yellow-700 border-yellow-300',
'추격위험': 'bg-gray-800 text-white border-gray-900',
'약한상승': 'bg-gray-100 text-gray-500 border-gray-300',
};
const cls = map[s.signalType] || 'bg-gray-100 text-gray-500';
return `<span class="border ${cls} text-xs px-1.5 py-0.5 rounded font-bold">${s.signalType}</span>`;
}
// 1시간 이내 상승 확률 뱃지 HTML 생성
function riseProbBadge(s) {
if (!s.riseLabel) return '';
const isVeryHigh = s.riseLabel === '매우 높음';
const cls = isVeryHigh
? 'bg-emerald-500 text-white border border-emerald-600'
: 'bg-teal-100 text-teal-700 border border-teal-200';
const icon = isVeryHigh ? '🚀' : '📈';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: ${s.riseScore}점">${icon} ${s.riseLabel}</span>`;
}
// 호재/악재/중립 뱃지 HTML 생성 ("정보없음"은 빈 문자열 반환)
function sentimentBadge(s) {
const map = {
'호재': 'bg-green-100 text-green-700 border border-green-200',
'악재': 'bg-red-100 text-red-600 border border-red-200',
'중립': 'bg-gray-100 text-gray-500',
};
const cls = map[s.sentiment];
if (!cls) return '';
const title = s.sentimentReason ? ` title="${s.sentimentReason}"` : '';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-semibold"${title}>${s.sentiment}</span>`;
}
// 연속 상승 횟수에 따른 뱃지 텍스트
function risingBadge(n) {
if (n >= 4) return `<span class="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">🔥${n}연속</span>`;
if (n >= 2) return `<span class="bg-orange-400 text-white text-xs px-1.5 py-0.5 rounded font-bold">▲${n}연속</span>`;
return `<span class="bg-yellow-100 text-yellow-700 text-xs px-1.5 py-0.5 rounded font-semibold">↑상승</span>`;
}
// 복합 지표 섹션 HTML 생성 (거래량 배수 · 매도잔량비 · 가격 위치)
function complexIndicators(s) {
const rows = [];
// 거래량 증가율
if (s.volRatio > 0) {
let volCls = 'text-gray-500';
let volLabel = `${s.volRatio.toFixed(1)}`;
if (s.volRatio >= 10) { volCls = 'text-gray-400 line-through'; volLabel += ' ⚠과열'; }
else if (s.volRatio >= 5) volCls = 'text-orange-400';
else if (s.volRatio >= 2) volCls = 'text-green-600 font-semibold';
else if (s.volRatio >= 1) volCls = 'text-green-500';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">거래량 증가</span>
<span class="${volCls}">${volLabel}</span>
</div>`);
}
// 매도/매수 잔량비
if (s.totalAskVol > 0 && s.totalBidVol > 0) {
const ratio = s.askBidRatio.toFixed(2);
let ratioCls = 'text-gray-500';
let ratioLabel = `${ratio} (`;
if (s.askBidRatio <= 0.7) { ratioCls = 'text-green-600 font-semibold'; ratioLabel += '매수 강세)'; }
else if (s.askBidRatio <= 1.0) { ratioCls = 'text-green-500'; ratioLabel += '매수 우세)'; }
else if (s.askBidRatio <= 1.5) { ratioCls = 'text-gray-500'; ratioLabel += '균형)'; }
else { ratioCls = 'text-blue-500'; ratioLabel += '매도 우세)'; }
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">매도/매수 잔량</span>
<span class="${ratioCls} text-xs">${ratioLabel}</span>
</div>`);
}
// 가격 위치 (장중 저가~고가 내 %)
if (s.pricePos !== undefined) {
const pos = s.pricePos.toFixed(0);
let posCls = 'text-gray-500';
if (s.pricePos >= 80) posCls = 'text-red-500 font-semibold';
else if (s.pricePos >= 60) posCls = 'text-orange-400';
else if (s.pricePos <= 30) posCls = 'text-blue-400';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">가격 위치</span>
<span class="${posCls}">${pos}%</span>
</div>`);
}
if (rows.length === 0) return '';
return `<div class="mt-2 pt-2 border-t border-gray-100 space-y-1 text-sm">${rows.join('')}</div>`;
}
// 익일 추세 예상 리포팅 HTML 생성
function nextDayBadge(s) {
const trendMap = {
'상승': { bg: 'bg-red-50 border-red-200', icon: '▲', cls: 'text-red-500' },
'하락': { bg: 'bg-blue-50 border-blue-200', icon: '▼', cls: 'text-blue-500' },
'횡보': { bg: 'bg-gray-50 border-gray-200', icon: '─', cls: 'text-gray-500' },
};
// LLM 결과가 없으면 "분석 중..." 표시
if (!s.nextDayTrend) {
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="text-xs text-gray-400 animate-pulse">분석 중...</span>
</div>
</div>`;
}
const style = trendMap[s.nextDayTrend] || trendMap['횡보'];
const confBadge = s.nextDayConf
? `<span class="text-xs text-gray-400 ml-1">(${s.nextDayConf})</span>`
: '';
const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : '';
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="${style.bg} ${style.cls} border text-xs px-2 py-0.5 rounded font-bold"${reasonTip}>
${style.icon} ${s.nextDayTrend}${confBadge}
</span>
</div>
${s.nextDayReason ? `<p class="text-xs text-gray-400 mt-1 truncate" title="${s.nextDayReason}">${s.nextDayReason}</p>` : ''}
</div>`;
}
// AI 목표가 뱃지 HTML 생성
function targetPriceBadge(s) {
if (!s.targetPrice || s.targetPrice === 0) return '';
const diff = s.targetPrice - s.currentPrice;
const pct = ((diff / s.currentPrice) * 100).toFixed(1);
const sign = diff >= 0 ? '+' : '';
const cls = diff >= 0 ? 'bg-purple-50 text-purple-600 border border-purple-200' : 'bg-gray-100 text-gray-500';
const title = s.targetReason ? ` title="${s.targetReason}"` : '';
return `<div class="flex justify-between items-center mt-2 pt-2 border-t border-purple-50">
<span class="text-xs text-gray-400">AI 목표가</span>
<span class="${cls} text-xs px-2 py-0.5 rounded font-semibold"${title}>
${fmtNum(s.targetPrice)}원 <span class="opacity-70">(${sign}${pct}%)</span>
</span>
</div>`;
}
// 시그널 종목 카드 HTML 생성
function makeCard(s) {
const diff = s.cntrStr - s.prevCntrStr;
const rising = s.risingCount || 1;
return `
<a href="/stock/${s.code}" id="sg-${s.code}"
class="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-5 border border-orange-100">
<div class="flex justify-between items-start mb-3">
<span class="text-sm text-gray-400 font-mono">${s.code}</span>
<div class="flex items-center gap-1.5 flex-wrap justify-end">
${signalTypeBadge(s)}
${riseProbBadge(s)}
${risingBadge(rising)}
${sentimentBadge(s)}
<span id="sg-rate-${s.code}" class="text-sm px-2.5 py-0.5 rounded-full font-semibold ${rateBadgeClass(s.changeRate)}">
${fmtRate(s.changeRate)}
</span>
</div>
</div>
<p class="font-bold text-gray-800 text-base truncate mb-1">${s.name}</p>
<p id="sg-price-${s.code}" class="text-2xl font-bold ${rateClass(s.changeRate)} mb-3">${fmtNum(s.currentPrice)}원</p>
<div class="pt-3 border-t border-gray-50 text-sm space-y-1.5">
<div class="flex justify-between">
<span class="text-gray-400">체결강도</span>
<span id="sg-cntr-${s.code}" class="font-bold text-orange-500">${fmtCntr(s.cntrStr)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">직전 대비</span>
<span class="text-gray-500">${fmtCntr(s.prevCntrStr)} → <span class="text-green-500 font-semibold">+${diff.toFixed(2)}</span></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">거래량</span>
<span id="sg-vol-${s.code}" class="text-gray-600">${fmtNum(s.volume)}</span>
</div>
${complexIndicators(s)}
${targetPriceBadge(s)}
${nextDayBadge(s)}
</div>
<!-- 체결강도 미니 라인차트 -->
<canvas id="sg-chart-${s.code}"
style="width:100%;height:48px;display:block;margin-top:12px;"
class="rounded-sm"></canvas>
</a>`;
}
// 카드 삽입 후 canvas 초기화 및 초기값 기록
function initChart(code, cntrStr) {
recordCntr(code, cntrStr);
drawChart(code);
}
// WebSocket 실시간 가격 수신 시 카드 업데이트
function updateCard(code, data) {
const priceEl = document.getElementById(`sg-price-${code}`);
const rateEl = document.getElementById(`sg-rate-${code}`);
const cntrEl = document.getElementById(`sg-cntr-${code}`);
const volEl = document.getElementById(`sg-vol-${code}`);
if (!priceEl) return;
const rate = data.changeRate ?? 0;
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-lg font-bold ${rateClass(rate)}`;
rateEl.textContent = fmtRate(rate);
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(rate)}`;
if (cntrEl && data.cntrStr !== undefined) {
cntrEl.textContent = fmtCntr(data.cntrStr);
}
if (volEl) volEl.textContent = fmtNum(data.volume);
// 체결강도 히스토리 기록 후 차트 갱신
if (data.cntrStr != null && data.cntrStr !== 0) {
recordCntr(code, data.cntrStr);
drawChart(code);
}
}
// 이전 구독 종목 해제 후 신규 종목 구독
function resubscribe(newCodes) {
currentCodes.forEach(code => {
if (!newCodes.includes(code)) stockWS.unsubscribe(code);
});
newCodes.forEach(code => {
if (!currentCodes.includes(code)) {
stockWS.subscribe(code);
stockWS.onPrice(code, data => updateCard(code, data));
}
});
currentCodes = newCodes;
}
// /api/signal 조회 후 그리드 렌더링
async function fetchAndRender() {
try {
const resp = await fetch('/api/signal');
if (!resp.ok) throw new Error('조회 실패');
const signals = await resp.json();
const now = new Date();
updatedEl.textContent = now.toTimeString().slice(0, 8) + ' 기준';
if (!Array.isArray(signals) || signals.length === 0) {
gridEl.innerHTML = '';
if (emptyEl) emptyEl.classList.remove('hidden');
resubscribe([]);
return;
}
if (emptyEl) emptyEl.classList.add('hidden');
gridEl.innerHTML = signals.map(makeCard).join('');
// 카드 삽입 후 각 종목 초기 체결강도 기록 및 차트 초기화
signals.forEach(s => initChart(s.code, s.cntrStr));
resubscribe(signals.map(s => s.code));
} catch (e) {
console.error('시그널 조회 실패:', e);
}
}
let pollTimer = null;
function startPolling() {
if (pollTimer) return;
fetchAndRender();
pollTimer = setInterval(fetchAndRender, INTERVAL);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
// 그리드 비우기
gridEl.innerHTML = '';
if (emptyEl) {
emptyEl.textContent = '스캐너가 꺼져 있습니다. 버튼을 눌러 켜주세요.';
emptyEl.classList.remove('hidden');
}
resubscribe([]);
}
// 토글 버튼 상태 적용
const toggleBtn = document.getElementById('scannerToggleBtn');
function applyState(enabled) {
if (!toggleBtn) return;
if (enabled) {
toggleBtn.textContent = '● ON';
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-green-100 text-green-700 border-green-300';
startPolling();
} else {
toggleBtn.textContent = '○ OFF';
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-gray-100 text-gray-400 border-gray-300';
stopPolling();
}
}
// 페이지 로드 시 백엔드 상태 조회
fetch('/api/scanner/status')
.then(r => r.json())
.then(d => applyState(d.enabled))
.catch(() => applyState(true)); // 실패 시 켜짐으로 fallback
// 토글 버튼 클릭
if (toggleBtn) {
toggleBtn.addEventListener('click', async () => {
try {
const resp = await fetch('/api/scanner/toggle', { method: 'POST' });
const data = await resp.json();
applyState(data.enabled);
} catch (e) {
console.error('스캐너 토글 실패:', e);
}
});
}
})();
// 시그널 판단 기준 모달 열기/닫기
(function () {
const btn = document.getElementById('signalGuideBtn');
const modal = document.getElementById('signalGuideModal');
const close = document.getElementById('signalGuideClose');
if (!btn || !modal) return;
btn.addEventListener('click', () => modal.classList.remove('hidden'));
close.addEventListener('click', () => modal.classList.add('hidden'));
// 모달 바깥 클릭 시 닫기
modal.addEventListener('click', e => {
if (e.target === modal) modal.classList.add('hidden');
});
// ESC 키로 닫기
document.addEventListener('keydown', e => {
if (e.key === 'Escape') modal.classList.add('hidden');
});
})();

255
static/js/theme.js Normal file
View File

@@ -0,0 +1,255 @@
/**
* 테마 분석 페이지
* - 테마 목록 조회 (ka90001)
* - 테마 구성종목 조회 (ka90002)
*/
(function () {
let currentDate = '1';
let currentSort = '3'; // 3=등락률순, 1=기간수익률순
let selectedCode = null;
let selectedName = null;
let allThemes = [];
let clientSortCol = null; // 헤더 클릭 정렬 컬럼 (null=서버 순서 유지)
let clientSortDesc = true;
const listEl = document.getElementById('themeList');
const countEl = document.getElementById('themeCount');
const searchEl = document.getElementById('themeSearch');
const emptyEl = document.getElementById('themeDetailEmpty');
const contentEl = document.getElementById('themeDetailContent');
const loadingEl = document.getElementById('themeDetailLoading');
const nameEl = document.getElementById('detailThemeName');
const fluRtEl = document.getElementById('detailFluRt');
const periodRtEl = document.getElementById('detailPeriodRt');
const stockListEl = document.getElementById('detailStockList');
// ── 포맷 유틸 ────────────────────────────────────────────────
function fmtRate(f) {
if (f == null) return '-';
const sign = f >= 0 ? '+' : '';
return sign + f.toFixed(2) + '%';
}
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBg(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// ── 테마 목록 행 렌더 ────────────────────────────────────────
function makeRow(t, rank) {
const fluCls = rateClass(t.fluRt);
const periodCls = t.periodRt >= 0 ? 'text-purple-600' : 'text-blue-500';
const isSelected = t.code === selectedCode;
return `
<div data-code="${t.code}" data-name="${t.name}"
class="theme-row grid grid-cols-[2fr_1fr_80px_80px_80px_80px] gap-0
px-4 py-3 cursor-pointer transition-colors text-sm
${isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'}">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs text-gray-400 w-5 shrink-0">${rank}</span>
<span class="font-medium text-gray-800 truncate">${t.name}</span>
</div>
<div class="text-xs text-gray-500 truncate self-center">${t.mainStock || '-'}</div>
<div class="text-right self-center font-semibold ${fluCls}">${fmtRate(t.fluRt)}</div>
<div class="text-right self-center font-semibold ${periodCls}">${fmtRate(t.periodRt)}</div>
<div class="text-center self-center text-gray-500">${t.stockCount}</div>
<div class="text-center self-center text-xs">
<span class="text-red-400">${t.risingCount}▲</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-blue-400">${t.fallCount}▼</span>
</div>
</div>`;
}
// ── 헤더 정렬 화살표 갱신 ────────────────────────────────────
function updateThemeColHeaders() {
document.querySelectorAll('.theme-col-sort').forEach(el => {
const arrow = el.querySelector('.sort-arrow');
if (!arrow) return;
if (el.dataset.col === clientSortCol) {
arrow.textContent = clientSortDesc ? '▼' : '▲';
arrow.className = 'sort-arrow text-blue-400';
el.classList.add('text-blue-500');
el.classList.remove('text-gray-500');
} else {
arrow.textContent = '';
arrow.className = 'sort-arrow';
el.classList.remove('text-blue-500');
el.classList.add('text-gray-500');
}
});
}
// ── 클라이언트 정렬 적용 ─────────────────────────────────────
function applyClientSort(themes) {
if (!clientSortCol) return themes;
return [...themes].sort((a, b) => {
const av = a[clientSortCol], bv = b[clientSortCol];
if (typeof av === 'string') {
const cmp = av.localeCompare(bv, 'ko');
return clientSortDesc ? cmp : -cmp;
}
const diff = bv - av;
return clientSortDesc ? diff : -diff;
});
}
// ── 테마 목록 렌더 ───────────────────────────────────────────
function renderList(themes) {
const q = searchEl.value.trim();
let filtered = q
? themes.filter(t => t.name.includes(q))
: themes;
filtered = applyClientSort(filtered);
countEl.textContent = `${filtered.length}개 테마`;
if (filtered.length === 0) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400">검색 결과가 없습니다.</div>`;
return;
}
listEl.innerHTML = filtered.map((t, i) => makeRow(t, i + 1)).join('');
// 행 클릭 이벤트
listEl.querySelectorAll('.theme-row').forEach(row => {
row.addEventListener('click', () => {
const code = row.dataset.code;
const name = row.dataset.name;
if (code === selectedCode) return;
selectedCode = code;
selectedName = name;
// 선택 상태 표시 갱신
listEl.querySelectorAll('.theme-row').forEach(r => {
r.classList.toggle('bg-blue-50', r.dataset.code === code);
r.classList.toggle('hover:bg-gray-50', r.dataset.code !== code);
});
loadDetail(code, name);
});
});
}
// ── 테마 목록 조회 ───────────────────────────────────────────
async function loadThemes() {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>`;
try {
const resp = await fetch(`/api/themes?date=${currentDate}&sort=${currentSort}`);
if (!resp.ok) throw new Error('조회 실패');
allThemes = await resp.json();
renderList(allThemes);
} catch (e) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-red-400">데이터를 불러오지 못했습니다.</div>`;
console.error('테마 목록 조회 실패:', e);
}
}
// ── 구성종목 조회 ─────────────────────────────────────────────
async function loadDetail(code, name) {
emptyEl.classList.add('hidden');
contentEl.classList.add('hidden');
loadingEl.classList.remove('hidden');
try {
const resp = await fetch(`/api/themes/${code}?date=${currentDate}`);
if (!resp.ok) throw new Error('조회 실패');
const data = await resp.json();
nameEl.textContent = name;
fluRtEl.textContent = fmtRate(data.fluRt);
fluRtEl.className = 'font-semibold ' + rateClass(data.fluRt);
periodRtEl.textContent = fmtRate(data.periodRt);
const stocks = data.stocks || [];
if (stocks.length === 0) {
stockListEl.innerHTML = `<div class="px-4 py-8 text-center text-xs text-gray-400">구성종목이 없습니다.</div>`;
} else {
stockListEl.innerHTML = stocks.map(s => {
const cls = rateClass(s.fluRt);
const bgCls = rateBg(s.fluRt);
const sign = s.predPre >= 0 ? '+' : '';
return `
<a href="/stock/${s.code}"
class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 transition-colors">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">${s.name}</p>
<p class="text-xs text-gray-400 font-mono">${s.code}</p>
</div>
<div class="text-right shrink-0 ml-3">
<p class="text-sm font-semibold ${cls}">${fmtNum(s.curPrc)}원</p>
<span class="text-xs px-1.5 py-0.5 rounded ${bgCls}">${sign}${fmtRate(s.fluRt)}</span>
</div>
</a>`;
}).join('');
}
loadingEl.classList.add('hidden');
contentEl.classList.remove('hidden');
} catch (e) {
loadingEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
emptyEl.innerHTML = '<p class="text-red-400">구성종목을 불러오지 못했습니다.</p>';
console.error('테마 구성종목 조회 실패:', e);
}
}
// ── 날짜 탭 이벤트 ───────────────────────────────────────────
document.querySelectorAll('.date-tab').forEach(btn => {
btn.addEventListener('click', () => {
currentDate = btn.dataset.date;
document.querySelectorAll('.date-tab').forEach(b => {
b.className = b.dataset.date === currentDate
? 'date-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
loadThemes();
});
});
// ── 정렬 탭 이벤트 (서버 사이드) ─────────────────────────────
document.querySelectorAll('.sort-tab').forEach(btn => {
btn.addEventListener('click', () => {
currentSort = btn.dataset.sort;
// 헤더 클라이언트 정렬 초기화 (서버 순서 우선)
clientSortCol = null;
document.querySelectorAll('.sort-tab').forEach(b => {
b.className = b.dataset.sort === currentSort
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
updateThemeColHeaders();
loadThemes();
});
});
// ── 헤더 컬럼 클릭 정렬 ──────────────────────────────────────
document.querySelectorAll('.theme-col-sort').forEach(el => {
el.addEventListener('click', () => {
const col = el.dataset.col;
if (clientSortCol === col) {
clientSortDesc = !clientSortDesc;
} else {
clientSortCol = col;
clientSortDesc = true;
}
updateThemeColHeaders();
renderList(allThemes);
});
});
// ── 검색 필터 이벤트 ─────────────────────────────────────────
searchEl.addEventListener('input', () => renderList(allThemes));
// ── 초기 로드 ────────────────────────────────────────────────
loadThemes();
})();

598
static/js/watchlist.js Normal file
View File

@@ -0,0 +1,598 @@
/**
* 관심종목 관리 + WebSocket 실시간 시세 + 10초 폴링 분석
* - 서버 API (/api/watchlist)에 종목 저장
* - WS 구독으로 1초마다 현재가/등락률/체결강도 + 미니 차트 갱신
* - 10초마다 /api/watchlist-signal 폴링으로 복합 분석 뱃지 갱신
*/
(function () {
const MAX_HISTORY = 60; // 체결강도 히스토리 최대 포인트
const SIGNAL_INTERVAL = 10 * 1000; // 10초 폴링
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
const cntrHistory = new Map(); // code → number[]
// --- 서버 API 헬퍼 ---
// 메모리 캐시 (서버 동기화용)
let cachedList = [];
async function loadFromServer() {
try {
const resp = await fetch('/api/watchlist');
if (!resp.ok) return [];
const list = await resp.json();
cachedList = Array.isArray(list) ? list : [];
return cachedList;
} catch {
return cachedList;
}
}
function loadList() {
return cachedList;
}
async function addToServer(code, name) {
const resp = await fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, name }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || '추가 실패');
}
cachedList.push({ code, name });
}
async function removeFromServer(code) {
await fetch(`/api/watchlist/${code}`, { method: 'DELETE' });
cachedList = cachedList.filter(s => s.code !== code);
}
// --- DOM 요소 ---
const input = document.getElementById('watchlistInput');
const addBtn = document.getElementById('watchlistAddBtn');
const msgEl = document.getElementById('watchlistMsg');
const sidebarEl = document.getElementById('watchlistSidebar');
const emptyEl = document.getElementById('watchlistEmpty');
const panelEl = document.getElementById('watchlistPanel');
const panelEmpty = document.getElementById('watchlistPanelEmpty');
const wsStatusEl = document.getElementById('wsStatus');
// --- WS 상태 표시 ---
function setWsStatus(text, color) {
if (!wsStatusEl) return;
wsStatusEl.textContent = text;
wsStatusEl.className = `text-xs font-normal ml-1 ${color}`;
}
// ─────────────────────────────────────────────
// 포맷 유틸
// ─────────────────────────────────────────────
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
return (f >= 0 ? '+' : '') + f.toFixed(2) + '%';
}
function fmtCntr(f) {
return f ? f.toFixed(2) : '-';
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBadgeClass(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// ─────────────────────────────────────────────
// 뱃지 HTML 생성 함수 (signal.js와 동일)
// ─────────────────────────────────────────────
function signalTypeBadge(s) {
if (!s.signalType) return '';
const map = {
'강한매수': 'bg-red-600 text-white border-red-700',
'매수우세': 'bg-orange-400 text-white border-orange-500',
'물량소화': 'bg-yellow-100 text-yellow-700 border-yellow-300',
'추격위험': 'bg-gray-800 text-white border-gray-900',
'약한상승': 'bg-gray-100 text-gray-500 border-gray-300',
};
const cls = map[s.signalType] || 'bg-gray-100 text-gray-500';
return `<span class="border ${cls} text-xs px-1.5 py-0.5 rounded font-bold">${s.signalType}</span>`;
}
function riseProbBadge(s) {
if (!s.riseLabel) return '';
const isVeryHigh = s.riseLabel === '매우 높음';
const cls = isVeryHigh
? 'bg-emerald-500 text-white border border-emerald-600'
: 'bg-teal-100 text-teal-700 border border-teal-200';
const icon = isVeryHigh ? '🚀' : '📈';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: ${s.riseScore}점">${icon} ${s.riseLabel}</span>`;
}
function risingBadge(n) {
if (!n) return '';
if (n >= 4) return `<span class="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">🔥${n}연속</span>`;
if (n >= 2) return `<span class="bg-orange-400 text-white text-xs px-1.5 py-0.5 rounded font-bold">▲${n}연속</span>`;
return `<span class="bg-yellow-100 text-yellow-700 text-xs px-1.5 py-0.5 rounded font-semibold">↑상승</span>`;
}
function sentimentBadge(s) {
const map = {
'호재': 'bg-green-100 text-green-700 border border-green-200',
'악재': 'bg-red-100 text-red-600 border border-red-200',
'중립': 'bg-gray-100 text-gray-500',
};
const cls = map[s.sentiment];
if (!cls) return '';
const title = s.sentimentReason ? ` title="${s.sentimentReason}"` : '';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-semibold"${title}>${s.sentiment}</span>`;
}
function complexIndicators(s) {
const rows = [];
if (s.volRatio > 0) {
let volCls = 'text-gray-500';
let volLabel = `${s.volRatio.toFixed(1)}`;
if (s.volRatio >= 10) { volCls = 'text-gray-400 line-through'; volLabel += ' ⚠과열'; }
else if (s.volRatio >= 5) volCls = 'text-orange-400';
else if (s.volRatio >= 2) volCls = 'text-green-600 font-semibold';
else if (s.volRatio >= 1) volCls = 'text-green-500';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">거래량 증가</span>
<span class="${volCls}">${volLabel}</span>
</div>`);
}
if (s.totalAskVol > 0 && s.totalBidVol > 0) {
const ratio = s.askBidRatio.toFixed(2);
let ratioCls = 'text-gray-500';
let ratioLabel = `${ratio} (`;
if (s.askBidRatio <= 0.7) { ratioCls = 'text-green-600 font-semibold'; ratioLabel += '매수 강세)'; }
else if (s.askBidRatio <= 1.0) { ratioCls = 'text-green-500'; ratioLabel += '매수 우세)'; }
else if (s.askBidRatio <= 1.5) { ratioCls = 'text-gray-500'; ratioLabel += '균형)'; }
else { ratioCls = 'text-blue-500'; ratioLabel += '매도 우세)'; }
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">매도/매수 잔량</span>
<span class="${ratioCls} text-xs">${ratioLabel}</span>
</div>`);
}
if (s.pricePos !== undefined) {
const pos = s.pricePos.toFixed(0);
let posCls = 'text-gray-500';
if (s.pricePos >= 80) posCls = 'text-red-500 font-semibold';
else if (s.pricePos >= 60) posCls = 'text-orange-400';
else if (s.pricePos <= 30) posCls = 'text-blue-400';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">가격 위치</span>
<span class="${posCls}">${pos}%</span>
</div>`);
}
if (rows.length === 0) return '';
return `<div class="mt-2 pt-2 border-t border-gray-100 space-y-1 text-sm">${rows.join('')}</div>`;
}
function targetPriceBadge(s) {
if (!s.targetPrice || s.targetPrice === 0) return '';
const diff = s.targetPrice - s.currentPrice;
const pct = ((diff / s.currentPrice) * 100).toFixed(1);
const sign = diff >= 0 ? '+' : '';
const cls = diff >= 0 ? 'bg-purple-50 text-purple-600 border border-purple-200' : 'bg-gray-100 text-gray-500';
const title = s.targetReason ? ` title="${s.targetReason}"` : '';
return `<div class="flex justify-between items-center mt-2 pt-2 border-t border-purple-50">
<span class="text-xs text-gray-400">AI 목표가</span>
<span class="${cls} text-xs px-2 py-0.5 rounded font-semibold"${title}>
${fmtNum(s.targetPrice)}원 <span class="opacity-70">(${sign}${pct}%)</span>
</span>
</div>`;
}
function nextDayBadge(s) {
const trendMap = {
'상승': { bg: 'bg-red-50 border-red-200', icon: '▲', cls: 'text-red-500' },
'하락': { bg: 'bg-blue-50 border-blue-200', icon: '▼', cls: 'text-blue-500' },
'횡보': { bg: 'bg-gray-50 border-gray-200', icon: '─', cls: 'text-gray-500' },
};
if (!s.nextDayTrend) {
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="text-xs text-gray-400 animate-pulse">분석 중...</span>
</div>
</div>`;
}
const style = trendMap[s.nextDayTrend] || trendMap['횡보'];
const confBadge = s.nextDayConf ? `<span class="text-xs text-gray-400 ml-1">(${s.nextDayConf})</span>` : '';
const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : '';
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="${style.bg} ${style.cls} border text-xs px-2 py-0.5 rounded font-bold"${reasonTip}>
${style.icon} ${s.nextDayTrend}${confBadge}
</span>
</div>
${s.nextDayReason ? `<p class="text-xs text-gray-400 mt-1 truncate" title="${s.nextDayReason}">${s.nextDayReason}</p>` : ''}
</div>`;
}
// ─────────────────────────────────────────────
// 체결강도 히스토리 + 미니 차트
// ─────────────────────────────────────────────
function recordCntr(code, value) {
if (value == null || value === 0) return;
if (!cntrHistory.has(code)) cntrHistory.set(code, []);
const arr = cntrHistory.get(code);
arr.push(value);
if (arr.length > MAX_HISTORY) arr.shift();
}
function drawChart(code) {
const canvas = document.getElementById(`wc-chart-${code}`);
if (!canvas) return;
const data = cntrHistory.get(code) || [];
if (data.length < 2) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.floor(rect.width * dpr);
const h = Math.floor(rect.height * dpr);
if (w === 0 || h === 0) return;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const ctx = canvas.getContext('2d');
const pad = 3 * dpr;
const drawH = h - pad * 2;
ctx.clearRect(0, 0, w, h);
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const step = w / (data.length - 1);
const getY = val => h - pad - ((val - min) / range) * drawH;
// 그라디언트 필
const lastX = (data.length - 1) * step;
const grad = ctx.createLinearGradient(0, pad, 0, h);
grad.addColorStop(0, 'rgba(249,115,22,0.35)');
grad.addColorStop(1, 'rgba(249,115,22,0)');
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(lastX, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// 라인
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 1.5 * dpr;
ctx.lineJoin = 'round';
ctx.stroke();
// 마지막 포인트 강조
const lastVal = data[data.length - 1];
ctx.beginPath();
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#f97316';
ctx.fill();
}
// ─────────────────────────────────────────────
// 관심종목 추가 / 삭제
// ─────────────────────────────────────────────
async function addStock(code) {
code = code.trim().toUpperCase();
if (!/^\d{6}$/.test(code)) {
showMsg('6자리 숫자 종목코드를 입력해주세요.');
return;
}
if (loadList().find(s => s.code === code)) {
showMsg('이미 추가된 종목입니다.');
return;
}
showMsg('조회 중...', false);
try {
const resp = await fetch(`/api/stock/${code}`);
if (!resp.ok) throw new Error('조회 실패');
const data = await resp.json();
if (!data.name) throw new Error('종목 없음');
await addToServer(code, data.name);
hideMsg();
input.value = '';
renderSidebar();
addPanelCard(code, data.name);
updateCard(code, data);
subscribeCode(code);
} catch (e) {
showMsg(e.message || '종목을 찾을 수 없습니다.');
}
}
async function removeStock(code) {
await removeFromServer(code);
stockWS.unsubscribe(code);
document.getElementById(`si-${code}`)?.remove();
document.getElementById(`wc-${code}`)?.remove();
cntrHistory.delete(code);
updateEmptyStates();
}
// ─────────────────────────────────────────────
// 사이드바 렌더링
// ─────────────────────────────────────────────
function renderSidebar() {
const list = loadList();
Array.from(sidebarEl.children).forEach(el => {
if (el.id !== 'watchlistEmpty') el.remove();
});
list.forEach(s => sidebarEl.appendChild(makeSidebarItem(s.code, s.name)));
updateEmptyStates();
}
function makeSidebarItem(code, name) {
const li = document.createElement('li');
li.id = `si-${code}`;
li.className = 'flex items-center justify-between px-3 py-2.5 hover:bg-gray-50 group';
li.innerHTML = `
<a href="/stock/${code}" class="flex-1 min-w-0">
<p class="text-xs font-medium text-gray-800 truncate">${name}</p>
<p class="text-xs text-gray-400 font-mono">${code}</p>
</a>
<button onclick="removeStock('${code}')" title="삭제"
class="ml-2 text-gray-300 hover:text-red-400 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none">
×
</button>`;
return li;
}
// ─────────────────────────────────────────────
// 패널 카드 추가 (signal.js makeCard 구조와 동일)
// ─────────────────────────────────────────────
function addPanelCard(code, name) {
if (document.getElementById(`wc-${code}`)) return;
const card = document.createElement('div');
card.id = `wc-${code}`;
card.className = 'bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-4 border border-gray-100 relative';
card.innerHTML = `
<!-- 헤더: 종목코드 + 분석 뱃지 + 등락률 -->
<div class="flex justify-between items-start mb-2">
<a href="/stock/${code}" class="text-xs text-gray-400 font-mono hover:text-blue-500">${code}</a>
<div id="wc-badges-${code}" class="flex items-center gap-1 flex-wrap justify-end">
<span id="wc-rate-${code}" class="text-xs px-2 py-0.5 rounded-full font-semibold bg-gray-100 text-gray-500">-</span>
</div>
</div>
<!-- 종목명 + 현재가 -->
<a href="/stock/${code}" class="block">
<p class="font-semibold text-gray-800 text-sm truncate mb-1">${name}</p>
<p id="wc-price-${code}" class="text-xl font-bold text-gray-900 mb-2">-</p>
</a>
<!-- info 섹션 -->
<div class="pt-2 border-t border-gray-50 text-sm space-y-1">
<div class="flex justify-between">
<span class="text-gray-400">체결강도</span>
<span id="wc-cntr-${code}" class="font-bold text-orange-500">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">직전 대비</span>
<span id="wc-prev-${code}" class="text-gray-500">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">거래량</span>
<span id="wc-vol-${code}" class="text-gray-600">-</span>
</div>
<!-- 복합 지표 (10초 폴링 갱신) -->
<div id="wc-complex-${code}"></div>
<!-- AI 목표가 (10초 폴링 갱신) -->
<div id="wc-target-${code}"></div>
<!-- 익일 추세 (10초 폴링 갱신) -->
<div id="wc-nextday-${code}"></div>
</div>
<!-- 체결강도 미니 라인차트 -->
<canvas id="wc-chart-${code}"
style="width:100%;height:48px;display:block;margin-top:10px;"
class="rounded-sm"></canvas>
<!-- 삭제 버튼 -->
<button onclick="removeStock('${code}')" title="삭제"
class="absolute top-3 right-3 text-gray-200 hover:text-red-400 text-lg leading-none opacity-0 hover:opacity-100 transition-opacity" style="font-size:18px;">×</button>`;
panelEl.appendChild(card);
updateEmptyStates();
}
// ─────────────────────────────────────────────
// WS 실시간 카드 업데이트 (1초)
// ─────────────────────────────────────────────
function updateCard(code, data) {
const priceEl = document.getElementById(`wc-price-${code}`);
const rateEl = document.getElementById(`wc-rate-${code}`);
const volEl = document.getElementById(`wc-vol-${code}`);
const cntrEl = document.getElementById(`wc-cntr-${code}`);
if (!priceEl) return;
const rate = data.changeRate ?? 0;
const colorCls = rateClass(rate);
const bgCls = rateBadgeClass(rate);
const sign = rate > 0 ? '+' : '';
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
rateEl.textContent = sign + rate.toFixed(2) + '%';
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
if (volEl) volEl.textContent = fmtNum(data.volume);
if (cntrEl && data.cntrStr !== undefined) {
const cs = data.cntrStr;
cntrEl.textContent = fmtCntr(cs);
cntrEl.className = `font-bold ${cs >= 100 ? 'text-orange-500' : 'text-blue-400'}`;
}
// 체결강도 히스토리 기록 + 미니 차트 갱신
if (data.cntrStr != null && data.cntrStr !== 0) {
recordCntr(code, data.cntrStr);
drawChart(code);
}
}
// ─────────────────────────────────────────────
// 10초 폴링 분석 결과 DOM 갱신
// ─────────────────────────────────────────────
function updateAnalysis(code, sig) {
// 뱃지 영역 갱신 (등락률 뱃지는 유지하면서 분석 뱃지 앞에 삽입)
const badgesEl = document.getElementById(`wc-badges-${code}`);
const rateEl = document.getElementById(`wc-rate-${code}`);
if (badgesEl && rateEl) {
// 기존 분석 뱃지 제거
badgesEl.querySelectorAll('.analysis-badge').forEach(el => el.remove());
// 분석 뱃지 생성 후 등락률 앞에 삽입
const frag = document.createRange().createContextualFragment(
[
signalTypeBadge(sig),
riseProbBadge(sig),
risingBadge(sig.risingCount),
sentimentBadge(sig),
].filter(Boolean).join('')
);
// analysis-badge 클래스 추가 (제거 시 사용)
frag.querySelectorAll('span').forEach(el => el.classList.add('analysis-badge'));
badgesEl.insertBefore(frag, rateEl);
}
// 직전 대비 갱신
const prevEl = document.getElementById(`wc-prev-${code}`);
if (prevEl && sig.prevCntrStr != null && sig.cntrStr != null) {
const diff = sig.cntrStr - sig.prevCntrStr;
prevEl.innerHTML = `${fmtCntr(sig.prevCntrStr)} → <span class="${diff >= 0 ? 'text-green-500' : 'text-blue-400'} font-semibold">${diff >= 0 ? '+' : ''}${diff.toFixed(2)}</span>`;
}
// 복합 지표 갱신
const complexEl = document.getElementById(`wc-complex-${code}`);
if (complexEl) complexEl.innerHTML = complexIndicators(sig);
// AI 목표가 갱신
const targetEl = document.getElementById(`wc-target-${code}`);
if (targetEl) targetEl.innerHTML = targetPriceBadge(sig);
// 익일 추세 갱신
const nextEl = document.getElementById(`wc-nextday-${code}`);
if (nextEl) nextEl.innerHTML = nextDayBadge(sig);
}
// ─────────────────────────────────────────────
// WS 구독
// ─────────────────────────────────────────────
function subscribeCode(code) {
stockWS.subscribe(code);
stockWS.onPrice(code, (data) => updateCard(code, data));
}
// ─────────────────────────────────────────────
// 10초 폴링: /api/watchlist-signal
// ─────────────────────────────────────────────
async function fetchWatchlistSignal() {
const codes = cachedList.map(s => s.code).join(',');
if (!codes) return;
try {
const resp = await fetch(`/api/watchlist-signal?codes=${codes}`);
if (!resp.ok) throw new Error('조회 실패');
const signals = await resp.json();
if (!Array.isArray(signals)) return;
signals.forEach(sig => updateAnalysis(sig.code, sig));
} catch (e) {
console.error('관심종목 분석 조회 실패:', e);
}
}
// ─────────────────────────────────────────────
// 빈 상태 처리
// ─────────────────────────────────────────────
function updateEmptyStates() {
const hasItems = cachedList.length > 0;
emptyEl.classList.toggle('hidden', hasItems);
panelEmpty?.classList.toggle('hidden', hasItems);
}
// ─────────────────────────────────────────────
// 메시지
// ─────────────────────────────────────────────
function showMsg(text, isError = true) {
msgEl.textContent = text;
msgEl.className = `text-xs mt-1 ${isError ? 'text-red-500' : 'text-gray-400'}`;
msgEl.classList.remove('hidden');
}
function hideMsg() { msgEl.classList.add('hidden'); }
// ─────────────────────────────────────────────
// WS 상태 모니터링
// ─────────────────────────────────────────────
function monitorWS() {
setInterval(() => {
if (stockWS.ws?.readyState === WebSocket.OPEN) {
setWsStatus('● 실시간', 'text-green-500 font-normal ml-1');
} else {
setWsStatus('○ 연결 중...', 'text-gray-400 font-normal ml-1');
}
}, 1000);
}
// ─────────────────────────────────────────────
// 이벤트 바인딩
// ─────────────────────────────────────────────
addBtn.addEventListener('click', () => addStock(input.value));
input.addEventListener('keydown', e => { if (e.key === 'Enter') addStock(input.value); });
// removeStock을 전역으로 노출 (onclick 속성에서 호출)
window.removeStock = removeStock;
// ─────────────────────────────────────────────
// 초기화
// ─────────────────────────────────────────────
monitorWS();
// 서버에서 관심종목 로드 후 카드 생성 + WS 구독
(async function init() {
await loadFromServer();
renderSidebar();
cachedList.forEach(s => {
addPanelCard(s.code, s.name);
subscribeCode(s.code);
fetch(`/api/stock/${s.code}`)
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) updateCard(s.code, data); })
.catch(() => {});
});
updateEmptyStates();
// 10초 폴링 시작 (즉시 1회 + 10초 주기)
fetchWatchlistSignal();
setInterval(fetchWatchlistSignal, SIGNAL_INTERVAL);
})();
})();

148
static/js/websocket.js Normal file
View File

@@ -0,0 +1,148 @@
/**
* StockWebSocket - 키움 주식 실시간 시세 WebSocket 클라이언트
* 자동 재연결 (지수 백오프), 구독 목록 자동 복구 지원
*/
class StockWebSocket {
constructor() {
this.ws = null;
this.subscriptions = new Set(); // 현재 구독 중인 종목 코드
// 메시지 타입별 핸들러 맵: type → { code → callbacks[] }
this.handlers = {
price: new Map(),
orderbook: new Map(),
program: new Map(),
meta: new Map(),
};
// 전역 핸들러 (코드 무관한 메시지용)
this.globalHandlers = {
market: [],
};
this.reconnectDelay = 1000; // 초기 재연결 대기 시간 (ms)
this.maxReconnectDelay = 30000; // 최대 재연결 대기 시간 (ms)
this.intentionalClose = false;
this.connect();
}
connect() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${location.host}/ws`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('WebSocket 연결됨');
this.reconnectDelay = 1000; // 성공 시 재연결 대기 시간 초기화
// 기존 구독 목록 자동 복구
this.subscriptions.forEach(code => this._send({ type: 'subscribe', code }));
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
this._handleMessage(msg);
} catch (e) {
console.error('메시지 파싱 실패:', e);
}
};
this.ws.onclose = () => {
if (!this.intentionalClose) {
console.log(`WebSocket 연결 끊김. ${this.reconnectDelay}ms 후 재연결...`);
setTimeout(() => this.connect(), this.reconnectDelay);
// 지수 백오프
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
};
this.ws.onerror = (err) => {
console.error('WebSocket 오류:', err);
};
}
// 종목 구독
subscribe(code) {
this.subscriptions.add(code);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this._send({ type: 'subscribe', code });
}
}
// 종목 구독 해제
unsubscribe(code) {
this.subscriptions.delete(code);
Object.values(this.handlers).forEach(map => map.delete(code));
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this._send({ type: 'unsubscribe', code });
}
}
// 현재가 수신 콜백 등록
onPrice(code, callback) {
this._addCodeHandler('price', code, callback);
}
// 호가창 수신 콜백 등록
onOrderBook(code, callback) {
this._addCodeHandler('orderbook', code, callback);
}
// 프로그램 매매 수신 콜백 등록
onProgram(code, callback) {
this._addCodeHandler('program', code, callback);
}
// 종목 메타 수신 콜백 등록
onMeta(code, callback) {
this._addCodeHandler('meta', code, callback);
}
// 장운영 상태 수신 콜백 등록 (전역)
onMarket(callback) {
this.globalHandlers.market.push(callback);
}
// 내부: 코드별 핸들러 등록
_addCodeHandler(type, code, callback) {
if (!this.handlers[type]) return;
if (!this.handlers[type].has(code)) {
this.handlers[type].set(code, []);
}
this.handlers[type].get(code).push(callback);
}
// 내부: 메시지 처리
_handleMessage(msg) {
const { type, code, data } = msg;
if (type === 'market') {
this.globalHandlers.market.forEach(fn => fn(data));
return;
}
if (type === 'error') {
console.warn(`서버 오류 [${code}]:`, data?.message);
return;
}
if (this.handlers[type] && code) {
const callbacks = this.handlers[type].get(code) || [];
callbacks.forEach(fn => fn(data));
}
}
// 내부: JSON 메시지 전송
_send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
close() {
this.intentionalClose = true;
this.ws?.close();
}
}
// 전역 인스턴스 (stock_detail.html에서 사용)
const stockWS = new StockWebSocket();