auto: 일일 백업 2026-06-06 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-06 02:00:03 +09:00
parent a61aad145c
commit 020ac27bd0
5 changed files with 130 additions and 24 deletions
+106 -18
View File
@@ -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,'&quot;')+'"'+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())