auto: 일일 백업 2026-09-12 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,'"');
|
||||
var safeAcc = String(r.account || '').replace(/"/g,'"');
|
||||
// 정정은 지정가 일반 주문만. 스톱·트레일링은 조건단가가 빠지고 정정 시 바뀌는 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'],
|
||||
|
||||
Reference in New Issue
Block a user