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
@@ -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()