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

488 lines
19 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.
/**
* 주문창 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);