first commit
This commit is contained in:
308
static/js/asset.js
Normal file
308
static/js/asset.js
Normal 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
566
static/js/autotrade.js
Normal 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, '"')})"
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
312
static/js/chart.js
Normal file
312
static/js/chart.js
Normal 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
60
static/js/disclosure.js
Normal 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
56
static/js/indices.js
Normal 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
195
static/js/kospi200.js
Normal 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
49
static/js/news.js
Normal 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
487
static/js/order.js
Normal 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
145
static/js/orderbook.js
Normal 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
140
static/js/ranking.js
Normal 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
68
static/js/search.js
Normal 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
457
static/js/signal.js
Normal 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
255
static/js/theme.js
Normal 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
598
static/js/watchlist.js
Normal 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
148
static/js/websocket.js
Normal 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();
|
||||
Reference in New Issue
Block a user