// 자동매매 페이지 클라이언트 스크립트 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 = '

규칙이 없습니다.

'; return; } el.innerHTML = rules.map(r => `
${escHtml(r.name)}

진입: RiseScore≥${r.minRiseScore} / 체결강도≥${r.minCntrStr}${r.requireBullish ? ' / AI호재' : ''}

청산: 손절${r.stopLossPct}% / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}

주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목

`).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 = '

보유 포지션 없음

'; return; } el.innerHTML = ` ${active.map(p => { const statusCls = p.status === 'open' ? 'text-green-600' : 'text-yellow-600'; const statusTxt = p.status === 'open' ? '보유중' : '체결대기'; return ` `; }).join('')}
종목 매수가 수량 손절가 상태
${escHtml(p.name)}
${p.code}
${formatMoney(p.buyPrice)} ${p.qty} ${formatMoney(p.stopLoss)} ${statusTxt}
`; } // --- 감시 소스 --- 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 = ''; 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 = '선택된 테마 없음'; return; } container.innerHTML = themes.map(t => ` ${escHtml(t.name)} `).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 ` ${time} ${l.level} ${escHtml(l.code)} ${escHtml(l.message)} `; } 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 = '로그가 없습니다.'; 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, '"'); }