first commit

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

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

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