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

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

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