Files
stocksearch/static/js/autotrade.js
2026-03-31 19:32:59 +09:00

567 lines
20 KiB
JavaScript
Raw Permalink Blame History

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