auto: 일일 백업 2026-06-06 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4876,8 +4876,15 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
|
||||
}
|
||||
|
||||
/* 주문 모달 (order-modal) — 거래내역 보기용 trade-modal과 별개. 모달 박스 자체 스크롤 X (호가창만 스크롤) */
|
||||
.order-box { max-width: 560px; overflow-y: hidden; display: flex; flex-direction: column; }
|
||||
.order-box { max-width: 560px; overflow-y: hidden; display: flex; flex-direction: column; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }
|
||||
.order-box > [data-order-step="1"] { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
/* 매수/매도 모드 전체 색조 — 모달 어디를 봐도 side가 한눈에 (매수=빨강/매도=파랑) */
|
||||
.order-box.side-buy { border-color: #c9303e; box-shadow: 0 0 0 1px #c9303e, 0 12px 44px rgba(201,48,62,0.34); background: linear-gradient(180deg, rgba(201,48,62,0.12), rgba(201,48,62,0.02) 130px, #0d1018 300px); }
|
||||
.order-box.side-sell { border-color: #2a5fb3; box-shadow: 0 0 0 1px #2a5fb3, 0 12px 44px rgba(42,95,179,0.34); background: linear-gradient(180deg, rgba(42,95,179,0.12), rgba(42,95,179,0.02) 130px, #0d1018 300px); }
|
||||
.order-box.side-buy .modal-head { border-bottom: 2px solid rgba(201,48,62,0.55); padding-bottom: 10px; }
|
||||
.order-box.side-sell .modal-head { border-bottom: 2px solid rgba(42,95,179,0.55); padding-bottom: 10px; }
|
||||
.order-box.side-buy .orderbook { border-color: rgba(201,48,62,0.38); }
|
||||
.order-box.side-sell .orderbook { border-color: rgba(42,95,179,0.38); }
|
||||
.order-symbol-select { background: #14171f; color: #f0f0f0; border: 1px solid #1f2330; border-radius: 6px; padding: 7px 9px; font-size: 14px; font-weight: 600; font-family: inherit; width: 100%; max-width: 360px; cursor: pointer; color-scheme: dark; }
|
||||
.order-symbol-select:focus { outline: none; border-color: #ff4d5e; }
|
||||
.order-symbol-select option { background: #14171f; color: #f0f0f0; }
|
||||
@@ -4926,8 +4933,9 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
|
||||
.qty-step-buttons button[data-qty-step="max"] { background: #2a1f24; color: #ffcc77; }
|
||||
.qty-step-buttons button[data-qty-step="clear"] { background: #1d1418; color: #ff8a95; }
|
||||
.order-inputs input:disabled, .order-inputs select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.order-inputs .order-actions { margin-top: auto; }
|
||||
.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; }
|
||||
/* 입력이 모달 높이를 넘쳐도 매수/취소 버튼은 항상 하단 고정 — 잘림 방지 */
|
||||
.order-inputs .order-actions { margin-top: auto; position: sticky; bottom: 0; background: #0d1018; padding-top: 8px; }
|
||||
.order-inputs label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
|
||||
.order-inputs label.inline-row { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.order-inputs label.inline-row > select { flex: 1; min-width: 0; }
|
||||
@@ -8100,7 +8108,7 @@ var ACCOUNTS = [
|
||||
{label: '가희_일반', display: '가희 일반', owner: '가희'},
|
||||
{label: '가희_ISA', display: '가희 ISA', owner: '가희'}
|
||||
];
|
||||
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false };
|
||||
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false, accStatus:null };
|
||||
function $(sel, root){ return (root||modal).querySelector(sel); }
|
||||
function $$(sel, root){ return (root||modal).querySelectorAll(sel); }
|
||||
function $p(sel){ return pinModal ? pinModal.querySelector(sel) : null; }
|
||||
@@ -8136,6 +8144,10 @@ function setMsg(text, kind){
|
||||
function setSide(side){
|
||||
state.side = side;
|
||||
$$('.order-side-toggle button[data-side]').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-side')===side); });
|
||||
// 모달 박스 전체에 side 색조 — 테두리·헤더 구분선·호가창 테두리가 매수=빨강/매도=파랑으로 물듦
|
||||
var obox = $('.order-box');
|
||||
if(obox){ obox.classList.toggle('side-buy', side==='BUY'); obox.classList.toggle('side-sell', side==='SELL'); }
|
||||
decorateAccounts(); // 매수/매도에 따라 계좌 색·표기 전환
|
||||
var sb = $('[data-order-submit]'); if(sb){ sb.setAttribute('data-side', side); sb.textContent = (side==='BUY'?'매수':'매도'); }
|
||||
renderQtyButtons();
|
||||
// 매수일 때만 금액 input 표시
|
||||
@@ -8234,6 +8246,7 @@ function renderBook(book){
|
||||
}
|
||||
highlightSelectedTick();
|
||||
updateMarketPhaseDisplay();
|
||||
decorateAccounts(); // 현재가 갱신 → 매수 예수금 충분 여부 색 갱신
|
||||
if(!state.bookCentered){
|
||||
var div = ob.querySelector('.ob-divider');
|
||||
if(div){
|
||||
@@ -8284,12 +8297,11 @@ function renderOrderInfo(){
|
||||
var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
|
||||
if(state.side === 'BUY'){
|
||||
var lines = [
|
||||
'예수금 (D+2): <b>'+fmt(d.d2_entra||0)+'</b>원',
|
||||
'매수가능액: <b>'+fmt(d.ord_alow_amt||0)+'</b>원'
|
||||
'매수가능액 (D+2 예수금): <b>'+fmt(d.d2_entra||0)+'</b>원'
|
||||
];
|
||||
if(orderType === 'MARKET' && d.upper_limit){
|
||||
var needed = (d.upper_limit||0) * qty;
|
||||
var overCls = (needed > (d.ord_alow_amt||0)) ? ' class="qty-over"' : '';
|
||||
var overCls = (needed > (d.d2_entra||0)) ? ' class="qty-over"' : '';
|
||||
lines.push('필요증거금<span class="muted small"> (상한가 '+fmt(d.upper_limit)+'원 기준)</span>: <b'+overCls+'>'+fmt(needed)+'</b>원 ('+fmt(qty)+'주)');
|
||||
} else {
|
||||
lines.push('주문예상액: <b>'+fmt(amount)+'</b>원 ('+fmt(qty)+'주)');
|
||||
@@ -8487,12 +8499,16 @@ function populateSymbolSelect(currentCode, currentName){
|
||||
var groups = {'보유':[], '관심':[], '감시':[], '현재':[]};
|
||||
opts.concat(items).forEach(function(it){ (groups[it.source]||groups['관심']).push(it); });
|
||||
var html = '<option value="">종목 선택…</option>';
|
||||
// 종목코드 단위 통합 — 같은 종목이 여러 계좌(본인·가희)에 있어도 하나만. 매매 계좌는 별도 select.
|
||||
// 라벨은 종목명만 (owner·종목코드 제거). 중복 시 우선순위 그룹(현재>보유>관심>감시)만 노출.
|
||||
var seen = {};
|
||||
['현재','보유','관심','감시'].forEach(function(g){
|
||||
if(!groups[g] || !groups[g].length) return;
|
||||
var rows = groups[g].filter(function(it){ if(seen[it.code]) return false; seen[it.code]=1; return true; });
|
||||
if(!rows.length) return;
|
||||
html += '<optgroup label="'+g+'">';
|
||||
groups[g].forEach(function(it){
|
||||
var ownerTag = it.owner ? ' ['+it.owner+']' : '';
|
||||
var label = (it.name||it.code) + ownerTag + ' ('+it.code+')';
|
||||
rows.forEach(function(it){
|
||||
var label = (it.name||it.code);
|
||||
var selAttr = (it.code === currentCode) ? ' selected' : '';
|
||||
html += '<option value="'+it.code+'" data-name="'+(it.name||'').replace(/"/g,'"')+'"'+selAttr+'>'+label+'</option>';
|
||||
});
|
||||
@@ -8532,6 +8548,9 @@ function onSymbolChange(){
|
||||
// 종목 변경 시 새 max로 자동 채움 (SELL: 보유 100%, BUY: 매수가능 max)
|
||||
if(state.side === 'SELL') state.pendingMaxOnSell = true;
|
||||
else state.pendingMaxOnBuy = true;
|
||||
state.accStatus = null;
|
||||
decorateAccounts();
|
||||
fetchAccountStatus();
|
||||
pollOnce();
|
||||
updateCheck();
|
||||
}
|
||||
@@ -8542,6 +8561,38 @@ function fillAccounts(allowed){
|
||||
if(allowed && allowed.length){ list = ACCOUNTS.filter(function(a){ return allowed.indexOf(a.label) >= 0; }); }
|
||||
list.forEach(function(a){ var opt = document.createElement('option'); opt.value=a.label; opt.textContent=a.display; sel.appendChild(opt); });
|
||||
}
|
||||
// 계좌 옵션에 색 + 텍스트 병행 표시. 매수: 예수금<현재가(1주)면 붉은색·'⚠ 부족'. 매도: 매도가능 주수 표기, 0이면 붉은색.
|
||||
// iOS Safari는 option 글자색 무시 → 텍스트(주수·부족)가 폰 fallback.
|
||||
var ACC_DISPLAY = {}; ACCOUNTS.forEach(function(a){ ACC_DISPLAY[a.label] = a.display; });
|
||||
function decorateAccounts(){
|
||||
var sel = $('[data-order-account]'); if(!sel) return;
|
||||
var st = state.accStatus;
|
||||
var price = (state.lastBook && state.lastBook.price) || (st && st.cur_price) || 0;
|
||||
Array.prototype.forEach.call(sel.options, function(opt){
|
||||
var base = ACC_DISPLAY[opt.value] || opt.value;
|
||||
if(!st || !st.byLabel || !st.byLabel[opt.value]){ opt.textContent = base; opt.style.color=''; return; }
|
||||
var a = st.byLabel[opt.value];
|
||||
if(state.side === 'SELL'){
|
||||
var q = a.trde_able_qty || 0; // 매도가능 수량 (미체결 매도로 묶인 분 제외)
|
||||
opt.textContent = base + (q > 0 ? ' · ' + fmt(q) + '주' : ' · 미보유');
|
||||
opt.style.color = q > 0 ? '#f0f0f0' : '#ff6b75';
|
||||
} else {
|
||||
var ok = price > 0 ? (a.d2_entra >= price) : (a.d2_entra > 0);
|
||||
opt.textContent = base + (ok ? '' : ' · ⚠ 부족');
|
||||
opt.style.color = ok ? '#f0f0f0' : '#ff6b75';
|
||||
}
|
||||
});
|
||||
}
|
||||
function fetchAccountStatus(){
|
||||
if(!state.code){ state.accStatus = null; decorateAccounts(); return; }
|
||||
var code = state.code;
|
||||
fetch('/api/order/account_status?code=' + encodeURIComponent(code)).then(function(r){ return r.json(); }).then(function(d){
|
||||
if(state.code !== code) return; // 그새 종목 바뀌면 무시
|
||||
var byLabel = {}; (d.accounts||[]).forEach(function(a){ byLabel[a.label] = a; });
|
||||
state.accStatus = { byLabel: byLabel, cur_price: d.cur_price || 0 };
|
||||
decorateAccounts();
|
||||
}).catch(function(){});
|
||||
}
|
||||
function reset(){
|
||||
setMsg('');
|
||||
$('[data-order-type]').value = 'LIMIT';
|
||||
@@ -8593,6 +8644,8 @@ function openModal(opts){
|
||||
fillAccounts(opts.accounts);
|
||||
if(opts.account){ var sel = $('[data-order-account]'); if(sel) sel.value = opts.account; }
|
||||
populateSymbolSelect(state.code, state.name);
|
||||
state.accStatus = null;
|
||||
fetchAccountStatus();
|
||||
setSide(opts.side === 'SELL' ? 'SELL' : 'BUY');
|
||||
if(opts.price){ $('[data-order-price-input]').value = opts.price; }
|
||||
modal.classList.remove('hidden');
|
||||
@@ -8676,7 +8729,7 @@ function doPropose(){
|
||||
var basis = (orderType === 'MARKET' && lc.upper_limit) ? '상한가' : '단가';
|
||||
var ref = (orderType === 'MARKET' && lc.upper_limit) ? lc.upper_limit : price;
|
||||
var needed = ref * qty;
|
||||
setMsg('예수금 부족 — 필요 ' + needed.toLocaleString() + '원 (' + basis + ' ' + (ref||0).toLocaleString() + '원 × ' + qty + '주) / 가용 ' + (lc.ord_alow_amt||0).toLocaleString() + '원\n최대 ' + lc.max_qty + '주까지 매수 가능', 'error');
|
||||
setMsg('예수금 부족 — 필요 ' + needed.toLocaleString() + '원 (' + basis + ' ' + (ref||0).toLocaleString() + '원 × ' + qty + '주) / 가용 ' + (lc.d2_entra||0).toLocaleString() + '원\n최대 ' + lc.max_qty + '주까지 매수 가능', 'error');
|
||||
} else {
|
||||
setMsg('보유 부족 — 매도 ' + qty + '주 / 매도가능 ' + lc.max_qty + '주', 'error');
|
||||
}
|
||||
@@ -8826,7 +8879,8 @@ document.addEventListener('click', function(e){
|
||||
if(subOrderBtn){
|
||||
function openWithFirst(items){
|
||||
var first = (items || []).find(function(it){ return it.source === '보유' && it.owner === '본인'; });
|
||||
if(first){ openModal({code: first.code, name: first.name}); }
|
||||
// 보유종목을 자동선택하므로 행 거래버튼과 동일 규칙으로 매도탭 진입. 보유분 없으면 빈 모달(매수 기본).
|
||||
if(first){ openModal({code: first.code, name: first.name, side: 'SELL'}); }
|
||||
else { openModal({code: '', name: ''}); }
|
||||
}
|
||||
if(state.symbolsCache){ openWithFirst(state.symbolsCache); }
|
||||
@@ -9405,9 +9459,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
'order_type': order_type, 'price': price}
|
||||
if side == 'BUY':
|
||||
bal = kc.get_balance(account)
|
||||
ord_alow = int(bal.get('ord_alow_amt') or 0)
|
||||
result['ord_alow_amt'] = ord_alow
|
||||
result['d2_entra'] = int(bal.get('d2_entra') or 0)
|
||||
# 매수가능 기준은 d2_entra(D+2 정산예수금) — propose 가드(validate_balance_for_buy)와
|
||||
# 동일. kt00001 ord_alow_amt는 오늘 즉시현금(entr)만 잡혀 매도결제분(D+2)을 빠뜨려
|
||||
# max_qty가 과소 계산되고 "예수금 부족" 오탐을 냈다. ord_alow_amt는 참고 표시용만 유지.
|
||||
d2 = int(bal.get('d2_entra') or 0)
|
||||
result['ord_alow_amt'] = int(bal.get('ord_alow_amt') or 0)
|
||||
result['d2_entra'] = d2
|
||||
if order_type == 'MARKET':
|
||||
# 키움 시장가 매수 증거금 = 상한가 × qty. 같은 기준으로 max_qty 표시.
|
||||
try:
|
||||
@@ -9417,10 +9474,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
upper = 0
|
||||
result['upper_limit'] = upper
|
||||
result['max_qty'] = (ord_alow // upper) if upper > 0 else 0
|
||||
result['max_qty'] = (d2 // upper) if upper > 0 else 0
|
||||
else:
|
||||
# 키움 ord_alow_amt는 수수료 버퍼 이미 반영된 매수가능액. price>0 일 때만 max_qty.
|
||||
result['max_qty'] = (ord_alow // price) if price > 0 else 0
|
||||
result['max_qty'] = (d2 // price) if price > 0 else 0
|
||||
else: # SELL
|
||||
positions = kc.get_positions(account)
|
||||
pos = next((p for p in positions if p['code'] == code), None)
|
||||
@@ -9442,6 +9498,38 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send_json(200, result)
|
||||
return
|
||||
if parsed.path == '/api/order/account_status':
|
||||
# 계좌 select 색/보유주수 표시용 — 한 종목에 대해 4계좌 d2_entra·보유주수 일괄 반환.
|
||||
qs = parse_qs(parsed.query)
|
||||
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
|
||||
if not code:
|
||||
self._send_json(400, {'error': 'code required'})
|
||||
return
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
labels = [a['label'] for a in kc.list_accounts()]
|
||||
with ThreadPoolExecutor(max_workers=len(labels) + 2) as ex:
|
||||
pos_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels)
|
||||
bal_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels}
|
||||
pos_by = pos_f.result()
|
||||
bals = {lb: bal_fs[lb].result() for lb in labels}
|
||||
cur_price = 0
|
||||
accounts = []
|
||||
for lb in labels:
|
||||
p = next((x for x in (pos_by.get(lb, []) or []) if x['code'] == code), None)
|
||||
if p and p.get('cur_price') and not cur_price:
|
||||
cur_price = p['cur_price']
|
||||
accounts.append({
|
||||
'label': lb,
|
||||
'd2_entra': int(bals[lb].get('d2_entra') or 0),
|
||||
'hold_qty': p['qty'] if p else 0,
|
||||
'trde_able_qty': p['trde_able_qty'] if p else 0,
|
||||
})
|
||||
self._send_json(200, {'code': code, 'cur_price': cur_price, 'accounts': accounts})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'error': f'account_status failed: {e}'})
|
||||
return
|
||||
if parsed.path == '/api/chart_svg':
|
||||
qs = parse_qs(parsed.query)
|
||||
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
|
||||
|
||||
Reference in New Issue
Block a user