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:
hyowons
2026-06-12 15:05:35 +09:00
parent 72b98409f2
commit e95e2b3f3a
3 changed files with 371 additions and 7 deletions
+169 -7
View File
@@ -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}'})
@@ -153,6 +153,47 @@ def format_card(request: dict, market_data: dict, card_id: str,
return '\n'.join(lines)
def format_card_multi(legs: list, side: str, symbol: str, symbol_name: str,
order_type: str, price: Optional[int], card_id: str,
state_warning: Optional[str] = None) -> str:
"""복수 계좌 동시 주문 카드 — 레그별 계좌·수량·예상회수 나열.
legs: [{'account': str, 'qty': int, 'position_qty': int?}, ...]
"""
cfg = _limits()['card']
side_word = '매수' if side == 'BUY' else '매도'
side_emoji = cfg['buy_emoji'] if side == 'BUY' else cfg['sell_emoji']
marker = cfg['highlight_marker']
spouse_marker = ' 🔐 가희 계좌' if any(_is_spouse(l['account']) for l in legs) else ''
type_marker = f' {cfg["warning_emoji"]} 시장가' if order_type == 'MARKET' else ''
lines = [f'{side_emoji} {_md_bold(side_word + " 미리보기")} [#{card_id}]{type_marker}{spouse_marker}'
f' · 계좌 {len(legs)}개 동시', '']
lines.append(f'{marker} {_md_bold(side_word)}')
lines.append(f'{marker} 종목: {_md_bold(symbol_name)} ({symbol})')
if state_warning:
lines.append(state_warning)
if order_type == 'LIMIT':
lines.append(f'{marker} 가격: {_md_bold(_money(price))}')
else:
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
lines.append('')
total = 0
for l in legs:
part = f'{marker} {_md_bold(_account_display(l["account"]))}: {l["qty"]:,}'
if l.get('position_qty'):
part += f' (보유 {l["position_qty"]:,}주)'
if order_type == 'LIMIT' and price:
part += f'{_money(price * l["qty"])}'
total += price * l['qty']
lines.append(part)
if order_type == 'LIMIT' and price:
money_label = '예상 금액 합계' if side == 'BUY' else '예상 회수 합계'
lines.append(f'{money_label}: {_money(total)}')
lines.append(f'{_limits()["pin"]["expiry_seconds"]}초 후 만료')
return '\n'.join(lines)
def format_pin_message(pin: str) -> str:
return pin
@@ -280,6 +280,117 @@ def propose_trade(
}
def propose_trade_multi(
legs: list,
side: str,
symbol: str,
symbol_name: str,
order_type: str,
price: Optional[int] = None,
) -> dict:
"""복수 계좌 동시 주문 — 같은 종목·방향·가격을 여러 계좌에서 카드 1장 + PIN 1개로.
legs: [{'account': str, 'qty': int}, ...] (2개 이상)
웹 거래 모달 '두 계좌 모두 매도' 진입점. SELL + LIMIT/MARKET 만 지원.
레그별 검증은 propose_trade 와 동일 경로(collect_market_data + validate_request),
하나라도 거부면 전체 거부. PIN 형식 일관성을 위해 같은 소유자 그룹 계좌만 허용.
"""
sidecar.guard_or_raise()
_sweep_expired_and_notify()
if side != 'SELL':
return {'ok': False,
'message': card.format_rejected('INVALID_SIDE', '복수 계좌 동시 주문은 매도만 지원')}
if order_type not in ('LIMIT', 'MARKET'):
return {'ok': False,
'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
if not legs or len(legs) < 2:
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '계좌 2개 이상 필요')}
accounts = [str(l.get('account') or '') for l in legs]
if len(set(accounts)) != len(accounts):
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '계좌 중복')}
limits = _limits()
owners, spouses = set(limits['owner_accounts']), set(limits['spouse_accounts'])
if not (all(a in owners for a in accounts) or all(a in spouses for a in accounts)):
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '같은 소유자 그룹 계좌만 동시 주문 가능')}
validated = []
state_warning = None
suffix = None
for leg in legs:
account = leg['account']
try:
qty = int(leg.get('qty') or 0)
except (TypeError, ValueError):
qty = 0
if qty <= 0:
return {'ok': False,
'message': card.format_rejected('MISSING_QTY', f'[{account}] 수량 필요')}
md = datasource.collect_market_data(account, symbol, side, qty)
request = {'account': account, 'side': side, 'symbol': symbol,
'symbol_name': symbol_name, 'qty': qty, 'order_type': order_type}
if order_type == 'LIMIT':
request['price'] = price
r = guards.validate_request(request, md)
if not r.ok:
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': r.code,
'message': r.message, 'multi': True})
return {'ok': False, 'message': card.format_rejected(r.code, f'[{account}] {r.message}')}
state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
if not state_eval['result'].ok:
sr = state_eval['result']
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': sr.code,
'message': sr.message, 'multi': True,
'stock_state': state_eval['state'],
'order_warning': state_eval['order_warning']})
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
if state_eval['warning']:
state_warning = state_eval['warning']
if suffix is None:
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], None)
validated.append({'account': account, 'qty': qty,
'position_qty': md.get('position_qty', 0)})
payload = {
'multi': True,
'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
'order_type': order_type, 'price': price, 'routing_suffix': suffix,
'legs': [{'account': v['account'], 'qty': v['qty']} for v in validated],
# 단일 payload 필드(account/qty)를 읽는 경로(format_card_locked 등) 호환용 요약
'account': '+'.join(accounts),
'qty': sum(v['qty'] for v in validated),
}
try:
pending = _pin_store.issue(accounts[0], payload)
except RuntimeError:
active = _pin_store.peek()
info = _active_card_info(active) if active else None
return {'ok': False, 'message': card.format_card_locked(info)}
card_msg = card.format_card_multi(validated, side, symbol, symbol_name, order_type,
price, pending.card_id, state_warning)
pin_msg = card.format_pin_message(pending.pin)
ledger.append('card_issued', {'card_id': pending.card_id, **payload})
ledger.append('pin_issued', {'card_id': pending.card_id, 'account': accounts[0],
'pin_length': len(pending.pin)})
return {
'ok': True,
'card_id': pending.card_id,
'card_message': card_msg,
'pin_message': pin_msg,
'expiry_seconds': pending.expiry_seconds,
'is_spouse': pending.account_label in spouses,
}
def propose_and_send(account, side, symbol, symbol_name, qty, order_type,
price=None, routing_force=None, budget=None) -> dict:
"""propose_trade 후 카드+PIN 을 텔레그램에 분리 발송. skill / launchd 진입점용."""
@@ -325,6 +436,9 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
p = card_obj.payload
ledger.append('approved', {'card_id': card_obj.card_id, **p})
if p.get('multi'):
return _submit_multi_legs(card_obj, dry_run)
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
res = kiwoom_order.submit(
account_label=p['account'],
@@ -361,6 +475,53 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
'detail': res}
def _submit_multi_legs(card_obj, dry_run: bool) -> dict:
"""multi 카드(propose_trade_multi)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.
한 레그가 실패해도 나머지 레그는 계속 시도. ok = 한 레그라도 접수 성공.
접수된 레그는 각각 fill_watcher 추적 (큐 기반이라 동시 다건 OK).
"""
p = card_obj.payload
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
msgs = []
details = []
any_ok = False
for leg in p.get('legs') or []:
res = kiwoom_order.submit(
account_label=leg['account'],
side=p['side'],
symbol=p['symbol'],
qty=leg['qty'],
price=p.get('price'),
order_type=submit_order_type,
routing_suffix=p['routing_suffix'],
dry_run=dry_run,
card_id=card_obj.card_id,
)
details.append(res)
acc_disp = card.ACCOUNT_DISPLAY.get(leg['account'], leg['account'])
if res['ok']:
any_ok = True
if dry_run:
msgs.append(f'[{acc_disp}] ' + card.format_dryrun(res.get('payload', {})))
continue
ord_no = res.get('ord_no', '')
msgs.append(f'[{acc_disp}] ' + card.format_submitted(
card_obj.card_id, p['side'], p.get('symbol_name', p['symbol']),
leg['qty'], p.get('price'), ord_no))
if ord_no:
fill_watcher.watch(
ord_no=ord_no, account=leg['account'], side=p['side'],
symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),
order_qty=leg['qty'], price=p.get('price'),
order_type=p['order_type'], card_id=card_obj.card_id,
)
else:
msgs.append(f'[{acc_disp}] ' + card.format_rejected(
res.get('reason', 'UNKNOWN'), str(res.get('error', ''))))
return {'ok': any_ok, 'message': '\n'.join(msgs), 'detail': details}
def cancel_active_card() -> dict:
sidecar.guard_or_raise()
card_obj = _pin_store.cancel()