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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user