feat: 매도 시 같은 그룹 2계좌 동시 매도 (카드 1장+PIN 1개, 선택 팝업)
- orders/handler.py: propose_trade_multi (레그별 독립 검증, 같은 소유자 그룹만, SELL+LIMIT/MARKET 한정) + submit_with_pin multi 분기 _submit_multi_legs (레그 독립 제출·독립 fill_watcher, 한 레그 실패해도 나머지 시도) - orders/card.py: format_card_multi (레그별 계좌·수량·예상회수 + 합계) - behive_web.py: POST /api/order/propose_multi (PIN은 iMessage '매도(2계좌)'), sell-choice-modal (두 계좌 모두 / 선택 계좌만 / 취소), doPropose SELL 분기 (accStatus로 sibling 보유 감지 → 팝업, sibling 수량은 보유 전량) - fix: /api/order/cancel 응답이 dict를 PendingCard로 취급해 매번 500 나던 기존 버그 (취소 자체는 동작, 응답만 깨짐) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6290,6 +6290,29 @@ def _render_pin_modal() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_sell_choice_modal() -> str:
|
||||
"""매도 시 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유 중일 때 띄우는 선택 팝업.
|
||||
|
||||
[두 계좌 모두 매도] / [선택 계좌만 매도] / [취소] 3버튼. order-modal 위에 겹침(modal-top).
|
||||
"""
|
||||
return (
|
||||
'<div id="sell-choice-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="sell-choice-modal-title">'
|
||||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||||
'<div class="modal-box pin-box" role="document">'
|
||||
'<div class="modal-head">'
|
||||
'<div class="modal-title" id="sell-choice-modal-title">📌 두 계좌 모두 보유 중</div>'
|
||||
'</div>'
|
||||
'<div class="order-modal-msg info" data-sell-choice-summary>—</div>'
|
||||
'<div class="order-actions" style="flex-direction:column;gap:8px;">'
|
||||
'<button type="button" class="btn-confirm" data-sell-choice-both style="width:100%;">두 계좌 모두 매도</button>'
|
||||
'<button type="button" class="btn-add" data-sell-choice-single style="width:100%;">선택 계좌만 매도</button>'
|
||||
'<button type="button" class="btn-cancel" data-modal-close="1" style="width:100%;">취소</button>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _render_interests_modal() -> str:
|
||||
"""shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
|
||||
자동 갱신 중에도 DOM·입력값이 보존된다."""
|
||||
@@ -7676,6 +7699,7 @@ def render_html() -> str:
|
||||
info_desc_modal_html = _render_info_desc_modal()
|
||||
order_modal_html = _render_order_modal()
|
||||
pin_modal_html = _render_pin_modal()
|
||||
sell_choice_modal_html = _render_sell_choice_modal()
|
||||
open_orders_modal_html = _render_open_orders_modal()
|
||||
stock_name_modal_html = _render_stock_name_modal()
|
||||
|
||||
@@ -8485,6 +8509,7 @@ def render_html() -> str:
|
||||
order_modal_script = r'''<script>(function(){
|
||||
var modal = document.getElementById('order-modal');
|
||||
var pinModal = document.getElementById('pin-modal');
|
||||
var sellChoiceModal = document.getElementById('sell-choice-modal');
|
||||
var openOrdersModal = document.getElementById('open-orders-modal');
|
||||
if(!modal) return;
|
||||
var ACCOUNTS = [
|
||||
@@ -8493,7 +8518,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, accStatus:null };
|
||||
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, sellChoice: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; }
|
||||
@@ -9128,6 +9153,25 @@ function doPropose(){
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 매도인데 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유 → 선택 팝업 (두 계좌 모두 / 선택 계좌만 / 취소)
|
||||
if(state.side === 'SELL'){
|
||||
var sib = siblingAccount(account);
|
||||
var sibInfo = (sib && state.accStatus && state.accStatus.byLabel) ? state.accStatus.byLabel[sib] : null;
|
||||
var sibQty = sibInfo ? (sibInfo.trde_able_qty || 0) : 0;
|
||||
if(sibQty > 0){
|
||||
openSellChoice({account: account, qty: qty, sibling: sib, siblingQty: sibQty, orderType: orderType, price: price});
|
||||
return;
|
||||
}
|
||||
}
|
||||
proposeSingle(account, orderType, qty, price);
|
||||
}
|
||||
function siblingAccount(label){
|
||||
var me = ACCOUNTS.find(function(a){ return a.label === label; });
|
||||
if(!me) return null;
|
||||
var sib = ACCOUNTS.find(function(a){ return a.owner === me.owner && a.label !== label; });
|
||||
return sib ? sib.label : null;
|
||||
}
|
||||
function proposeSingle(account, orderType, qty, price){
|
||||
var body = new URLSearchParams();
|
||||
body.set('account', account);
|
||||
body.set('side', state.side);
|
||||
@@ -9136,13 +9180,27 @@ function doPropose(){
|
||||
body.set('qty', String(qty));
|
||||
body.set('order_type', orderType);
|
||||
if(orderType === 'LIMIT') body.set('price', String(price));
|
||||
proposeAndOpenPin('/api/order/propose', body);
|
||||
}
|
||||
function doProposeMulti(opts){
|
||||
var body = new URLSearchParams();
|
||||
body.set('side', 'SELL');
|
||||
body.set('symbol', state.code);
|
||||
body.set('symbol_name', state.name);
|
||||
body.set('accounts', opts.account + ',' + opts.sibling);
|
||||
body.set('qtys', String(opts.qty) + ',' + String(opts.siblingQty));
|
||||
body.set('order_type', opts.orderType);
|
||||
if(opts.orderType === 'LIMIT') body.set('price', String(opts.price));
|
||||
proposeAndOpenPin('/api/order/propose_multi', body);
|
||||
}
|
||||
function proposeAndOpenPin(url, body){
|
||||
setMsg('주문 검증 중…', 'info');
|
||||
var btn = $('[data-order-submit]'); if(btn) btn.disabled = true;
|
||||
// 이전 활성 카드 자동 정리 후 새 propose — "이전 카드가 아직 활성" 거부 방지
|
||||
fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''})
|
||||
.catch(function(){})
|
||||
.then(function(){
|
||||
return fetch('/api/order/propose', {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
|
||||
body: body.toString()
|
||||
@@ -9164,6 +9222,33 @@ function doPropose(){
|
||||
setMsg('네트워크 오류: ' + e, 'error');
|
||||
});
|
||||
}
|
||||
// ── 매도 계좌 선택 모달 (두 계좌 모두 / 선택 계좌만 / 취소) ──
|
||||
function $sc(sel){ return sellChoiceModal ? sellChoiceModal.querySelector(sel) : null; }
|
||||
function openSellChoice(opts){
|
||||
if(!sellChoiceModal){ proposeSingle(opts.account, opts.orderType, opts.qty, opts.price); return; }
|
||||
state.sellChoice = opts;
|
||||
var disp = {}; ACCOUNTS.forEach(function(a){ disp[a.label] = a.display; });
|
||||
var priceStr = (opts.orderType === 'MARKET') ? '시장가' : (fmt(opts.price) + '원');
|
||||
var sum = $sc('[data-sell-choice-summary]');
|
||||
if(sum){
|
||||
sum.innerHTML = '<b>' + (state.name || state.code) + '</b> 종목을 <b>' + (disp[opts.account]||opts.account) +
|
||||
'</b> · <b>' + (disp[opts.sibling]||opts.sibling) + '</b> 두 계좌 모두 보유하고 있습니다.<br><br>' +
|
||||
'· ' + (disp[opts.account]||opts.account) + ': <b>' + fmt(opts.qty) + '주</b> 매도 (입력 수량)<br>' +
|
||||
'· ' + (disp[opts.sibling]||opts.sibling) + ': <b>' + fmt(opts.siblingQty) + '주</b> 매도 (보유 전량)<br>' +
|
||||
'· 단가: ' + priceStr + ' (두 계좌 동일)';
|
||||
}
|
||||
var sb = $sc('[data-sell-choice-single]');
|
||||
if(sb) sb.textContent = (disp[opts.account]||opts.account) + '만 매도';
|
||||
sellChoiceModal.classList.remove('hidden');
|
||||
sellChoiceModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
function closeSellChoice(){
|
||||
if(!sellChoiceModal) return;
|
||||
sellChoiceModal.classList.add('hidden');
|
||||
sellChoiceModal.setAttribute('aria-hidden', 'true');
|
||||
if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
|
||||
}
|
||||
function doVerify(){
|
||||
var pinInp = $p('[data-pin-input]');
|
||||
var pin = (pinInp && pinInp.value || '').trim();
|
||||
@@ -9244,6 +9329,22 @@ if(pinModal) pinModal.addEventListener('click', function(e){
|
||||
if(t.classList && t.classList.contains('modal-close')){ closePinModal(); return; }
|
||||
if(t.matches && t.matches('[data-pin-verify]')){ doVerify(); return; }
|
||||
});
|
||||
// 매도 계좌 선택 모달 click 핸들러
|
||||
if(sellChoiceModal) sellChoiceModal.addEventListener('click', function(e){
|
||||
var t = e.target;
|
||||
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeSellChoice(); return; }
|
||||
var opts = state.sellChoice;
|
||||
if(t.matches && t.matches('[data-sell-choice-both]')){
|
||||
closeSellChoice();
|
||||
if(opts) doProposeMulti(opts);
|
||||
return;
|
||||
}
|
||||
if(t.matches && t.matches('[data-sell-choice-single]')){
|
||||
closeSellChoice();
|
||||
if(opts) proposeSingle(opts.account, opts.orderType, opts.qty, opts.price);
|
||||
return;
|
||||
}
|
||||
});
|
||||
var accSel = $('[data-order-account]'); if(accSel) accSel.addEventListener('change', updateCheck);
|
||||
var symSel = $('[data-order-symbol-select]'); if(symSel) symSel.addEventListener('change', onSymbolChange);
|
||||
var otSel = $('[data-order-type]');
|
||||
@@ -9572,6 +9673,7 @@ window.openPinModal = openPinModal;
|
||||
{info_desc_modal_html}
|
||||
{order_modal_html}
|
||||
{pin_modal_html}
|
||||
{sell_choice_modal_html}
|
||||
{open_orders_modal_html}
|
||||
{stock_name_modal_html}
|
||||
{ptr_script}
|
||||
@@ -10595,6 +10697,69 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send_json(status, res)
|
||||
return
|
||||
|
||||
if self.path == '/api/order/propose_multi':
|
||||
# 거래 모달 매도 — 같은 소유자 그룹 2계좌 동시 매도. 카드 1장 + PIN 1개가 두 레그 승인.
|
||||
# accounts/qtys 는 콤마 구분 동수 리스트 (예: accounts=일반,ISA & qtys=5,12).
|
||||
side = (params.get('side') or ['SELL'])[0].strip().upper()
|
||||
symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum())
|
||||
symbol_name = (params.get('symbol_name') or [''])[0].strip()
|
||||
order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper()
|
||||
accounts = [a.strip() for a in (params.get('accounts') or [''])[0].split(',') if a.strip()]
|
||||
try:
|
||||
qtys = [int(q.strip()) for q in (params.get('qtys') or [''])[0].split(',') if q.strip()]
|
||||
except ValueError:
|
||||
qtys = []
|
||||
price_raw = (params.get('price') or [''])[0].strip()
|
||||
try:
|
||||
price = int(price_raw) if price_raw else None
|
||||
except ValueError:
|
||||
price = None
|
||||
if side != 'SELL' or not symbol or len(accounts) < 2 \
|
||||
or len(accounts) != len(qtys) or any(q <= 0 for q in qtys):
|
||||
self._send_json(400, {'ok': False, 'error': 'side=SELL + accounts/qtys(2개 이상, 동수) 필요'})
|
||||
return
|
||||
if order_type not in ('LIMIT', 'MARKET'):
|
||||
self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET'})
|
||||
return
|
||||
if order_type == 'LIMIT' and (price is None or price <= 0):
|
||||
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
|
||||
return
|
||||
try:
|
||||
if not symbol_name:
|
||||
import kiwoom_client as kc
|
||||
try:
|
||||
meta = kc.resolve_stock_code(symbol)
|
||||
symbol_name = meta.get('name') or symbol
|
||||
except Exception:
|
||||
symbol_name = symbol
|
||||
from orders import handler
|
||||
# 단일 propose 와 동일 정책 — 거부는 web 토스트만, PIN 은 iMessage.
|
||||
res = handler.propose_trade_multi(
|
||||
legs=[{'account': a, 'qty': q} for a, q in zip(accounts, qtys)],
|
||||
side='SELL', symbol=symbol, symbol_name=symbol_name,
|
||||
order_type=order_type,
|
||||
price=(price if order_type == 'LIMIT' else None),
|
||||
)
|
||||
if res.get('ok'):
|
||||
try:
|
||||
from orders.pin import PinStore as _PinStore
|
||||
_pending = _PinStore().peek()
|
||||
if _pending and not _pending.consumed:
|
||||
handler.send_imessage_pin(
|
||||
pin=_pending.pin,
|
||||
card_id=_pending.card_id,
|
||||
side_label='매도(2계좌)',
|
||||
symbol_name=symbol_name,
|
||||
)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': f'propose_multi failed: {e}'})
|
||||
return
|
||||
self._send_json(200 if res.get('ok') else 400, res)
|
||||
return
|
||||
|
||||
if self.path == '/api/order/verify':
|
||||
# 거래 모달 2단계: PIN 검증 → 키움 실주문 (dry_run=False).
|
||||
pin = (params.get('pin') or [''])[0].strip()
|
||||
@@ -10645,12 +10810,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님.
|
||||
try:
|
||||
from orders import handler
|
||||
# cancel_active_card 반환은 dict {'ok','message'} — 활성 카드 없어도 베스트에포트라 200.
|
||||
res = handler.cancel_active_card()
|
||||
# cancel_active_card 반환은 PendingCard | None. dict로 변환.
|
||||
if res is None:
|
||||
self._send_json(200, {'ok': True, 'message': '활성 카드 없음'})
|
||||
return
|
||||
self._send_json(200, {'ok': True, 'message': '카드 취소됨', 'card_id': res.card_id})
|
||||
self._send_json(200, {'ok': True, 'message': res.get('message') or '카드 취소됨'})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': f'cancel failed: {e}'})
|
||||
|
||||
Reference in New Issue
Block a user