auto: 일일 백업 2026-09-12 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-09-12 02:00:06 +09:00
parent b4f510d1ad
commit 34498c554e
139 changed files with 1839 additions and 1574 deletions
+140 -5
View File
@@ -3893,11 +3893,14 @@ def _render_ohlc_mini_dual(krx: dict | None, al: dict | None, active_market: str
def _render_ohlc_mini(o: int, h: int, l: int, current: int, pred_krx: int, pred_nxt: int) -> str:
"""OHLC 4행 mini-table — 라벨/가격/전일(KRX)/전일(NXT) 4-col grid.
"""OHLC 4행 mini-table — 라벨/가격/KRX 대비/통합 대비 4-col grid.
헤더 row 한 줄에 '전일(KRX)' '전일(NXT)' 컬럼명, 데이터 row는 4행(현재가/시가/고가/저가).
가격은 통합(AL) 값 하나이고 등락률만 두 기준으로 나뉜다 — ①KRX 전일종가 ②어제 통합
마지막 체결(= 어제 NXT 종가, `pred_nxt`). 보유행 미니표의 `KRX | 통합` 두 열과 같은
개념이라 2026-09-11 헤더 라벨을 'NXT' → '통합' 으로 맞췄다(값·데이터원은 그대로).
pred_krx/pred_nxt 중 하나가 0이면 해당 컬럼은 모두 `-` placeholder — 컬럼 레이아웃 유지.
KRX/NXT 기준이 같은 종목(NXT 미운영)에선 두 컬럼 값이 동일.
⚠️ 폴백(없으면 KRX 전일종가 사용)은 두지 않는다 — 두 컬럼이 같은 값이 되어 중복이다.
NXT 미운영 종목은 어제 종가가 곧 KRX 종가라 두 컬럼 값이 원래 동일하다.
"""
if not (o > 0 and h > 0 and l > 0 and current > 0):
return ''
@@ -3922,7 +3925,7 @@ def _render_ohlc_mini(o: int, h: int, l: int, current: int, pred_krx: int, pred_
'<span class="ohlc-th"></span>',
'<span class="ohlc-th"></span>',
'<span class="ohlc-th">KRX</span>',
'<span class="ohlc-th">NXT</span>',
'<span class="ohlc-th">통합</span>',
]
for lbl, p in rows:
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
@@ -7303,6 +7306,16 @@ input[data-order-qty].qty-over { color: #ff8a95; }
.open-order-row .row-action button { background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); border-radius: 6px; padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.open-order-row .row-action button:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; }
.open-order-row .row-action button:disabled { opacity: 0.5; cursor: not-allowed; }
.open-order-row .row-action { display: flex; flex-direction: column; gap: 5px; }
.open-order-row .row-action button.btn-modify { background: #101a2a; color: #7fb2ff; border-color: rgba(127,178,255,0.35); }
.open-order-row .row-action button.btn-modify:hover { background: rgba(127,178,255,0.14); border-color: #7fb2ff; }
.order-edit { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.order-edit .oe-label, .order-edit .oe-unit { font-size: 11px; color: #8b8f9a; }
.order-edit .oe-input { width: 92px; background: #0e1118; color: #f0f0f0; border: 1px solid #2a3040; border-radius: 6px; padding: 5px 8px; font-size: 12px; font-family: inherit; font-variant-numeric: tabular-nums; -webkit-appearance: none; }
.order-edit .oe-input::-webkit-outer-spin-button, .order-edit .oe-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.order-edit button { border-radius: 6px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.order-edit button:disabled { opacity: 0.5; cursor: not-allowed; }
.order-edit .oe-ok { background: #12301c; color: #6fdc8c; border: 1px solid rgba(111,220,140,0.35); }
.open-order-row.active-card .row-action button { background: rgba(255,204,51,0.12); color: #ffcc77; border-color: rgba(255,204,51,0.45); }
.open-order-row.active-card .row-action button:hover { background: rgba(255,204,51,0.25); }
/* 매매 거부·검증 에러 토스트 — 화면 하단, 자동 사라짐 */
@@ -12845,8 +12858,21 @@ function loadOpenOrders(){
}
var safeOrd = String(r.ord_no || '').replace(/"/g,'&quot;');
var safeAcc = String(r.account || '').replace(/"/g,'&quot;');
// 정정은 지정가 일반 주문만. 스톱·트레일링은 조건단가가 빠지고 정정 시 바뀌는 ord_no 를
// 감시 루프가 추적하지 못한다. 시장가는 가격이 0이라 애초에 정정 불가(서버도 같은 기준).
var canModify = !r.trailing && !r.stop_price && r.order_price > 0;
var editLine = canModify
? '<div class="order-edit hidden" data-order-edit>' +
'<span class="oe-label">정정가</span>' +
'<input type="number" inputmode="numeric" step="1" min="1" class="oe-input"' +
' data-edit-price value="' + r.order_price + '">' +
'<span class="oe-unit">원</span>' +
'<button type="button" class="oe-ok" data-edit-apply>확인</button>' +
'</div>'
: '';
return (
'<div class="open-order-row" data-ord-no="' + safeOrd + '" data-account="' + safeAcc + '">' +
'<div class="open-order-row" data-ord-no="' + safeOrd + '" data-account="' + safeAcc + '"' +
' data-order-price="' + (r.order_price || 0) + '">' +
'<div class="row-info">' +
'<div class="row-line1">' +
'<span class="badge-side ' + sideCls + '">' + sideLabel + '</span>' +
@@ -12859,8 +12885,10 @@ function loadOpenOrders(){
' · ' + r.exchange + ' · ' + (r.order_time || '') +
'</div>' +
condLine +
editLine +
'</div>' +
'<div class="row-action">' +
(canModify ? '<button type="button" class="btn-modify" data-modify-open-order>정정</button>' : '') +
'<button type="button" data-cancel-open-order>취소</button>' +
'</div>' +
'</div>'
@@ -12921,6 +12949,44 @@ function rearmFromRecent(idx){
});
showToast('이전 설정을 불러왔습니다 — 확인 후 매도를 눌러주세요', 'info', 4000);
}
// 미체결 가격 정정 — 행 안에서 바로 고친다(prompt 는 모바일에서 숫자 키패드가 안 뜬다).
function toggleOrderEdit(row, on){
var box = row.querySelector('[data-order-edit]');
if(!box) return;
box.classList.toggle('hidden', !on);
var mbtn = row.querySelector('[data-modify-open-order]');
if(mbtn) mbtn.textContent = on ? '접기' : '정정';
if(on){
var inp = box.querySelector('[data-edit-price]');
if(inp){ inp.focus(); inp.select(); }
}
}
function modifyOpenOrder(ordNo, account, price, row){
var btn = row ? row.querySelector('[data-edit-apply]') : null;
if(btn){ btn.disabled = true; btn.textContent = '정정중…'; }
var body = new URLSearchParams();
body.set('ord_no', ordNo);
body.set('account', account);
body.set('price', String(price));
fetch('/api/orders/modify', {
method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
body: body.toString()
}).then(function(r){ return r.json(); })
.then(function(d){
if(d.ok){
showToast(d.message || '✅ 정정 접수됨', 'success', 5000);
// 정정하면 ord_no 가 신규 번호로 바뀐다 — 목록을 다시 읽어야 다음 조작이 맞는 주문에 간다.
loadOpenOrders();
} else {
showToast(d.message || d.error || '정정 실패', 'error');
if(btn){ btn.disabled = false; btn.textContent = '확인'; }
}
}).catch(function(e){
showToast('정정 오류: ' + e, 'error');
if(btn){ btn.disabled = false; btn.textContent = '확인'; }
});
}
function cancelOpenOrder(ordNo, account, btn){
if(!confirm('주문 #' + ordNo + ' 취소하시겠습니까?')) return;
if(btn){ btn.disabled = true; btn.textContent = '취소중…'; }
@@ -13671,6 +13737,33 @@ if(openOrdersModal) openOrdersModal.addEventListener('click', function(e){
if(t.classList && t.classList.contains('modal-close')){ closeOpenOrdersModal(); return; }
var refBtn = t.closest && t.closest('[data-open-orders-refresh]');
if(refBtn){ loadOpenOrders(); return; }
var modBtn = t.closest && t.closest('[data-modify-open-order]');
if(modBtn){
var mrow = modBtn.closest('.open-order-row');
if(!mrow) return;
var mbox = mrow.querySelector('[data-order-edit]');
toggleOrderEdit(mrow, !!(mbox && mbox.classList.contains('hidden')));
return;
}
var editOk = t.closest && t.closest('[data-edit-apply]');
if(editOk){
var erow = editOk.closest('.open-order-row');
if(!erow) return;
var einp = erow.querySelector('[data-edit-price]');
var raw = parseInt((einp && einp.value) || '0', 10);
if(!(raw > 0)){ showToast('정정가를 입력해주세요', 'error'); return; }
// 호가단위에 안 맞으면 키움이 거부한다 — 거부 메시지를 보기 전에 맞춰준다.
var np = snapToTick(raw);
if(np !== raw){
if(einp) einp.value = np;
showToast('호가단위에 맞춰 ' + fmt(np) + '원으로 조정했습니다', 'info', 3000);
}
var cur = parseInt(erow.getAttribute('data-order-price') || '0', 10);
if(np === cur){ showToast('기존 주문가와 같습니다', 'info'); return; }
if(!confirm(fmt(cur) + '원 → ' + fmt(np) + '원으로 정정하시겠습니까?')) return;
modifyOpenOrder(erow.getAttribute('data-ord-no'), erow.getAttribute('data-account'), np, erow);
return;
}
var cancelBtn = t.closest && t.closest('[data-cancel-open-order]');
if(cancelBtn){
var row = cancelBtn.closest('.open-order-row');
@@ -13693,6 +13786,16 @@ if(openOrdersModal) openOrdersModal.addEventListener('click', function(e){
return;
}
});
// 정정가 입력에서 Enter → [확인]
if(openOrdersModal) openOrdersModal.addEventListener('keydown', function(e){
if(e.key !== 'Enter') return;
var t = e.target;
if(!(t.matches && t.matches('[data-edit-price]'))) return;
e.preventDefault();
var erow = t.closest('.open-order-row');
var okBtn = erow && erow.querySelector('[data-edit-apply]');
if(okBtn) okBtn.click();
});
// PIN 모달 click 핸들러
if(pinModal) pinModal.addEventListener('click', function(e){
var t = e.target;
@@ -15708,6 +15811,38 @@ class Handler(BaseHTTPRequestHandler):
_bump_panel_signal() # 미체결 취소 → 배지 제거 반영
self._send_json(200 if res.get('ok') else 400, res)
return
if self.path == '/api/orders/modify':
# 미체결 주문 가격 정정 (kt10002). ord_no + account + price 필수.
# PIN 게이트 없음 — 이미 승인된 주문의 가격만 바꾸는 것이고 수량 증가 경로가 없다.
# 수량은 의도적으로 열지 않았다(매수 수량 증가는 예수금 검증이 정정 경로에 없다).
ord_no = (params.get('ord_no') or [''])[0].strip()
account = (params.get('account') or [''])[0].strip()
price_raw = (params.get('price') or [''])[0].strip()
if not ord_no or not account or not price_raw:
self._send_json(400, {'ok': False, 'error': 'ord_no/account/price required'})
return
try:
price = int(price_raw)
except ValueError:
self._send_json(400, {'ok': False, 'error': f'invalid price: {price_raw}'})
return
try:
from orders import handler
res = handler.modify_open_order(ord_no=ord_no, account=account, price=price)
# 취소와 같은 정책 — 실주문이 바뀐 사실은 텔레그램에 남긴다.
if res.get('ok') and res.get('message'):
try:
handler.send_telegram(res['message'], parse_mode=None)
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'open order modify failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # 정정 → ord_no·가격이 바뀌므로 미체결 표시 갱신
self._send_json(200 if res.get('ok') else 400, res)
return
if self.path == '/api/orders/arm_cancel':
# 대기 예약(예약 트레일링) 취소. 키움 주문이 아니라 상태파일에서 지운다.
# 텔레그램 발송 없음 — 실주문 취소가 아니므로 토스트로 충분하다.
@@ -1006,6 +1006,15 @@ def modify_open_order(ord_no: Optional[str] = None,
return {'ok': False, 'message': resolved['message'], 'code': resolved['code']}
row = resolved['row']
# 스톱지정가(trde_tp=28) 계열은 정정 대상에서 뺀다. 두 가지가 동시에 깨진다:
# ① 여기선 mdfy_cond_uv 를 안 넘겨 조건단가가 빈 값으로 나간다(조건 소실 위험).
# ② 정정 응답 ord_no 가 신규 번호라, trailing_stops.json 의 레그 ord_no 가 어긋나
# 그 레그의 자동 손절선 상향이 이후 전부 실패한다.
# 바꾸고 싶으면 취소 후 재등록이 맞다.
if row.get('stop_price'):
return {'ok': False, 'code': 'STOP_ORDER',
'message': f"⛔ 스톱지정가·트레일링 주문은 정정 불가 (ord_no={row['ord_no']}) — "
'취소 후 재등록하세요'}
if qty is None and price is None:
return {'ok': False,
'message': '⛔ 정정 항목 누락 — qty/price 중 최소 하나는 필요합니다'}
@@ -1020,6 +1029,27 @@ def modify_open_order(ord_no: Optional[str] = None,
return {'ok': False,
'message': '⛔ 시장가 미체결은 정정 불가 (가격 0). 취소 후 신규 발주하세요.'}
# 가격제한폭·예수금 — 신규 주문과 같은 가드를 태운다. 정정도 결국 새 가격으로 주문이 나가고,
# propose 만 막아두면 정정이 그 검증을 우회하는 뒷문이 된다.
# 매도 보유수량은 태우지 않는다 — 이미 접수된 주문의 수량을 그대로 두거나 줄이는 경로라
# (웹은 price 만 보낸다) 새로 필요한 보유분이 없다.
md = datasource.collect_market_data(row['account'], row['code'], row['side'], new_qty)
r = guards.validate_price_band(row['side'], new_price,
md['upper_limit'], md['lower_limit'])
if not r.ok:
return {'ok': False, 'code': r.code,
'message': f"⛔ 정정 거부 [{r.code}] · ord_no={row['ord_no']} · {r.message}"}
if row['side'] == 'BUY':
# ⚠️ md['balance_d2'] 는 미체결 매수로 묶인 돈을 **정정 대상 주문까지 포함해** 뺀 값이다.
# 그 주문은 지금 가격을 바꾸는 당사자라, 되돌려 더하지 않으면 제 주문에 제가 막힌다
# (149,000×2 로 묶인 상태에서 149,100 으로 올리는 것조차 '예수금 부족'이 된다).
# 되더하는 식은 datasource 의 locked 계산(order_price × unfilled_qty)과 같아야 정확히 상쇄된다.
avail = md['balance_d2'] + row['order_price'] * row['unfilled_qty']
r = guards.validate_balance_for_buy(new_qty, new_price, avail)
if not r.ok:
return {'ok': False, 'code': r.code,
'message': f"⛔ 정정 거부 [{r.code}] · ord_no={row['ord_no']} · {r.message}"}
side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side'])
res = kiwoom_order.modify_order(
account_label=row['account'],