""" 매매 흐름 핸들러. 자연어/단축어 → 카드+PIN 발행, PIN echo → 실주문, 만료/취소 알림. LLM 결정 금지 원칙: 페이로드 추출은 LLM 가능, 실행은 사람의 PIN echo 가 게이트. 이 모듈은 채널 독립적이다 — 텔레그램 어댑터(launchd polling 또는 OpenClaw 도구)가 호출. CLI: python3 -m orders.handler propose [price] [routing] python3 -m orders.handler pin [--live] python3 -m orders.handler amend [--qty N] [--price W] [--order-type T] [--budget W] [--routing R] [--send] python3 -m orders.handler cancel [--send] python3 -m orders.handler open-orders [--account A] python3 -m orders.handler cancel-order [--ord-no N] [--account A] [--live] [--send] python3 -m orders.handler modify-order [--ord-no N] [--account A] [--qty Q] [--price P] [--live] [--send] python3 -m orders.handler status python3 -m orders.handler cmd """ from __future__ import annotations import json import sys import threading import time import urllib.parse import urllib.request from pathlib import Path from typing import Optional _PARENT = Path(__file__).resolve().parent.parent if str(_PARENT) not in sys.path: sys.path.insert(0, str(_PARENT)) import kiwoom_client as kc # noqa: E402 from . import card, datasource, fill_watcher, guards, kiwoom_order, ledger, pin, sidecar # noqa: E402 LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json' OPENCLAW_CONFIG = Path.home() / '.openclaw' / 'openclaw.json' TELEGRAM_ACCOUNT = 'stock' _pin_store = pin.PinStore() _expiry_thread: Optional[threading.Thread] = None _expiry_send = None def _limits() -> dict: return json.loads(LIMITS_FILE.read_text(encoding='utf-8')) # ---- 텔레그램 발송 ---- def send_telegram(text: str, parse_mode: str = 'Markdown') -> bool: try: cfg = json.loads(OPENCLAW_CONFIG.read_text(encoding='utf-8')) acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT] token = acct['botToken'] chat_ids = acct.get('allowFrom') or [] except Exception as e: print(f'[send_telegram] config error: {e}', file=sys.stderr) return False if not chat_ids: print('[send_telegram] no chat_ids configured', file=sys.stderr) return False url = f'https://api.telegram.org/bot{token}/sendMessage' ok = True for chat_id in chat_ids: data = urllib.parse.urlencode({ 'chat_id': chat_id, 'text': text[:4000], 'parse_mode': parse_mode, 'disable_web_page_preview': 'true', }).encode() try: req = urllib.request.Request(url, data=data, method='POST') with urllib.request.urlopen(req, timeout=15) as r: if r.status != 200: ok = False except Exception as e: print(f'[send_telegram] error: {e}', file=sys.stderr) ok = False return ok ADMIN_IMSG_CRED = Path.home() / '.openclaw' / 'credentials' / 'admin_imessage.json' IMSG_TIMEOUT_SEC = 60 def _load_admin_imsg_handle() -> Optional[str]: """본인 iCloud handle (전화번호 또는 이메일) — admin_imessage.json. 없으면 None.""" if not ADMIN_IMSG_CRED.exists(): return None try: return (json.loads(ADMIN_IMSG_CRED.read_text(encoding='utf-8')) or {}).get('handle') except Exception: return None def send_imessage_pin(pin: str, card_id: str, side_label: str, symbol_name: str, domain: str = 'stock.hyowons.net') -> bool: """PIN을 본인 iMessage로 발송 — iOS Safari OTP 자동입력 대상. Apple 도메인 바인딩 형식: 마지막 줄 `@ #` → iOS가 해당 도메인 페이지에서만 자동입력 제안. credential `admin_imessage.json` 미설정 시 skip, 텔레그램 발송에는 영향 X. fire-and-forget: AppleScript ↔ Messages.app AppleEvent 응답이 timeout(-1712) 떨어져도 실제 메시지는 큐에 들어가 발송됨. 응답 기다리지 않고 Popen background로 던지고 즉시 반환 — propose 응답 지연 방지. """ import subprocess handle = _load_admin_imsg_handle() if not handle: print('[send_imessage_pin] admin_imessage.json 없음 — iMessage 발송 skip', file=sys.stderr) return False text = ( f"[{domain}] {side_label} 인증\n" f"종목: {symbol_name}\n" f"카드: {card_id}\n" f"코드: {pin}\n\n" f"@{domain} #{pin}" ) try: subprocess.Popen( ['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) return True except Exception as e: print(f'[send_imessage_pin] error: {e}', file=sys.stderr) return False # ---- 매매 흐름 ---- def _sweep_expired_and_notify() -> None: """진입점에서 호출 — 만료된 카드 정리 + 텔레그램 알림.""" try: expired = _pin_store.sweep_expired() except Exception as e: print(f'[sweep_expired] {e}', file=sys.stderr) return if expired: try: ledger.append('expired', {'card_id': expired.card_id, **expired.payload}) except Exception: pass try: send_telegram(card.format_expired(expired.card_id, expired.payload.get('side', 'BUY')), parse_mode=None) except Exception: pass def propose_trade( account: str, side: str, symbol: str, symbol_name: str, qty: Optional[int], order_type: str, price: Optional[int] = None, routing_force: Optional[str] = None, budget: Optional[int] = None, ) -> dict: sidecar.guard_or_raise() _sweep_expired_and_notify() if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'): return {'ok': False, 'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')} if side not in ('BUY', 'SELL'): return {'ok': False, 'message': card.format_rejected('INVALID_SIDE', f'잘못된 방향: {side}')} if budget is not None and qty is not None: return {'ok': False, 'message': card.format_rejected('AMBIGUOUS_INPUT', 'qty와 budget을 동시에 지정할 수 없음 — 하나만 입력')} if budget is None and qty is None: return {'ok': False, 'message': card.format_rejected('MISSING_QTY', '수량(qty) 또는 예산(budget)을 입력해야 함')} # budget 환산은 호가창 필요 — qty 결정 전 호가 한번 받기 위해 임시 qty=0 으로 datasource 호출. # collect_market_data 의 잔고/보유 조회는 qty 와 무관. md = datasource.collect_market_data(account, symbol, side, qty or 0) budget_conversion = None if budget is not None: conv = guards.convert_budget_to_qty(side, budget, md.get('orderbook'), fallback_price=md.get('current_price')) if not conv['ok']: ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol, 'budget': budget, 'reason': conv['code'], 'message': conv['message']}) return {'ok': False, 'message': card.format_rejected(conv['code'], conv['message'])} qty = conv['qty'] budget_conversion = { 'budget': budget, 'qty': conv['qty'], 'ref_price': conv['ref_price'], 'remainder': conv['remainder'], 'source': conv.get('source', 'orderbook'), 'bumped': conv.get('bumped', False), } 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}) return {'ok': False, 'message': card.format_rejected(r.code, r.message)} # 종목 상태 평가 (ka10099 캐시 기반) — 정리매매·투자위험·거래정지 사전 거부, 그 외 위험은 경고 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, 'stock_state': state_eval['state'], 'order_warning': state_eval['order_warning']}) return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)} state_warning = state_eval['warning'] # 카드에 표시할 경고 (None이면 정상) suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force) estimate = None final_price = price if order_type == 'MARKET': if md.get('orderbook'): estimate = guards.estimate_market_fill(side, qty, md['orderbook']) elif order_type == 'AGGRESSIVE_LIMIT': agg_res = guards.aggressive_limit_price(side, md.get('orderbook'), fallback_price=md.get('current_price')) if not agg_res['ok']: ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol, 'qty': qty, 'price': price, 'reason': agg_res['code'], 'message': agg_res['message']}) return {'ok': False, 'message': card.format_rejected(agg_res['code'], agg_res['message'])} estimate = {'aggressive_price': agg_res['price'], 'source': agg_res['source'], 'ref_price': agg_res['ref_price']} final_price = agg_res['price'] payload = { 'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name, 'qty': qty, 'price': final_price, 'order_type': order_type, 'routing_suffix': suffix, } try: pending = _pin_store.issue(account, 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(request, md, pending.card_id, estimate, budget_conversion, 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': account, '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 _limits()['spouse_accounts'], } 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 진입점용.""" res = propose_trade(account, side, symbol, symbol_name, qty, order_type, price, routing_force, budget=budget) if not res['ok']: send_telegram(res['message'], parse_mode=None) return res send_telegram(res['card_message'], parse_mode='Markdown') send_telegram(res['pin_message'], parse_mode=None) return res def submit_with_pin_and_send(pin_input: str, dry_run: bool = False) -> dict: """submit_with_pin 후 결과 메시지를 텔레그램에 발송.""" res = submit_with_pin(pin_input, dry_run=dry_run) msg = res.get('message') if msg: send_telegram(msg, parse_mode='Markdown' if res['ok'] and not dry_run else None) return res def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict: sidecar.guard_or_raise() _sweep_expired_and_notify() # PIN 자리에 "0" 단독 → 취소로 해석 (amend 의 0=취소와 일관성). # 본인 PIN 4자리 / 가희 8자리라 "0" 한 자리는 어차피 PIN 길이 미달. if (pin_input or '').strip() == '0': return cancel_active_card() ok, reason, card_obj = _pin_store.verify(pin_input) if not ok: if card_obj is None: return {'ok': False, 'message': '활성 카드 없음'} if reason == '만료': ledger.append('expired', {'card_id': card_obj.card_id, **card_obj.payload}) return {'ok': False, 'message': card.format_expired(card_obj.card_id, card_obj.payload['side'])} if 'PIN 불일치' in reason: ledger.append('rejected', {'card_id': card_obj.card_id, 'reason': 'PIN_MISMATCH'}) return {'ok': False, 'message': card.format_pin_mismatch(card_obj.card_id)} return {'ok': False, 'message': reason} p = card_obj.payload ledger.append('approved', {'card_id': card_obj.card_id, **p}) submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type'] res = kiwoom_order.submit( account_label=p['account'], side=p['side'], symbol=p['symbol'], qty=p['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, ) if res['ok']: if dry_run: msg = card.format_dryrun(res.get('payload', {})) else: ord_no = res.get('ord_no', '') msg = card.format_submitted(card_obj.card_id, p['side'], p.get('symbol_name', p['symbol']), p['qty'], p.get('price'), ord_no) # 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링). # 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림. if ord_no: fill_watcher.watch( ord_no=ord_no, account=p['account'], side=p['side'], symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']), order_qty=p['qty'], price=p.get('price'), order_type=p['order_type'], card_id=card_obj.card_id, ) return {'ok': True, 'message': msg, 'detail': res} return {'ok': False, 'message': card.format_rejected(res.get('reason', 'UNKNOWN'), str(res.get('error', ''))), 'detail': res} def cancel_active_card() -> dict: sidecar.guard_or_raise() card_obj = _pin_store.cancel() if not card_obj: return {'ok': False, 'message': '활성 카드 없음'} ledger.append('canceled', {'card_id': card_obj.card_id, **card_obj.payload, 'reason': 'user_cancel'}) return {'ok': True, 'message': card.format_canceled(card_obj.card_id, card_obj.payload['side'])} def cancel_active_card_and_send() -> dict: res = cancel_active_card() if res.get('message'): send_telegram(res['message'], parse_mode=None) return res # ---- 키움 접수 후 주문 정정·취소 (kt10002/kt10003) ---- def _list_open_orders(account: Optional[str] = None, ord_no: Optional[str] = None) -> list[dict]: """ka10075 미체결 조회 — account 명시 시 그 계좌만, 아니면 전체. ord_no 명시 시 해당 ord_no 만 필터링. """ if account: rows = kc.get_open_orders(account) else: rows = kc.get_open_orders_all() if ord_no: rows = [r for r in rows if r.get('ord_no') == ord_no] return rows def _format_open_order_list(rows: list[dict]) -> str: lines = [] for r in rows: side_word = '매수' if r['side'] == 'BUY' else ('매도' if r['side'] == 'SELL' else r['side']) price = f"{r['order_price']:,}원" if r['order_price'] else r['order_type'] lines.append( f"ord_no={r['ord_no']} · [{r['account']}] {r['name']} ({r['code']}) " f"{side_word} {r['order_qty']}주 @ {price} · 미체결 {r['unfilled_qty']}주 · " f"{r['exchange']} · {r['status']} · {r['order_time']}" ) return '\n'.join(lines) if lines else '(미체결 없음)' def _resolve_open_order(ord_no: Optional[str], account: Optional[str]) -> dict: """미체결 주문 1건을 확정. 명시·자동 선택·모호 케이스를 단일 흐름으로. 반환: {ok: True, row: {...}} — 1건 확정 {ok: False, code: NO_OPEN_ORDERS, message: ...} — 미체결 없음 {ok: False, code: NOT_FOUND, message: ...} — ord_no 매칭 실패 {ok: False, code: AMBIGUOUS, message: ...} — ord_no 미명시 + 다수 후보 """ rows = _list_open_orders(account=account, ord_no=ord_no) if not rows: if ord_no: return {'ok': False, 'code': 'NOT_FOUND', 'message': f'⛔ 미체결 주문 ord_no={ord_no} 을(를) 찾을 수 없습니다' + (f' (계좌: {account})' if account else ' (전 계좌)')} return {'ok': False, 'code': 'NO_OPEN_ORDERS', 'message': '⛔ 미체결 주문이 없습니다'} if len(rows) > 1: listing = _format_open_order_list(rows) hint = ('대상 ord_no 를 명시해 주세요:\n' + listing) if not ord_no \ else f'⛔ 동일 ord_no 가 여러 건입니다 (예외 상황):\n{listing}' return {'ok': False, 'code': 'AMBIGUOUS', 'message': hint} return {'ok': True, 'row': rows[0]} def cancel_open_order(ord_no: Optional[str] = None, account: Optional[str] = None, dry_run: bool = False) -> dict: """키움에 접수된 미체결 주문 취소 (kt10003). PIN 게이트 없음 (취소는 안전 방향). ord_no 명시 시 즉시 취소. 미명시 + 미체결 1건이면 자동, 2건 이상이면 모호 응답. cancel_qty=0 — 잔량 전부 취소. """ sidecar.guard_or_raise() resolved = _resolve_open_order(ord_no, account) if not resolved['ok']: return {'ok': False, 'message': resolved['message'], 'code': resolved['code']} row = resolved['row'] res = kiwoom_order.cancel_order( account_label=row['account'], orig_ord_no=row['ord_no'], symbol=row['code'], cancel_qty=0, routing_suffix=row.get('routing_suffix', ''), dry_run=dry_run, ) side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side']) if not res['ok']: reason = res.get('reason', 'UNKNOWN') detail = '' if reason == 'BROKER_REJECT': detail = str(res.get('response', {}).get('return_msg') or '') elif reason == 'NETWORK': detail = str(res.get('error', '')) msg = (f"⛔ 취소 실패 [{reason}] · ord_no={row['ord_no']} · " f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주" + (f"\n사유: {detail}" if detail else '')) return {'ok': False, 'message': msg, 'detail': res} if res.get('dry_run'): msg = (f"🧪 [DRY-RUN] 취소 시뮬레이션 · ord_no={row['ord_no']} · " f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주 " f"({row['exchange']})") else: new_ord = res.get('new_ord_no', '') msg = (f"✅ 취소 접수 · 원주문 ord_no={row['ord_no']} → 취소 ord_no={new_ord} · " f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주 " f"({row['exchange']})") # 키움이 취소 ord_no 발급하면 ka10075 폴링으로 broker 확정 추적 — 확정/30분 타임아웃 시 텔레그램 알림 if new_ord: fill_watcher.watch_cancel( new_ord_no=new_ord, orig_ord_no=row['ord_no'], account=row['account'], side=row['side'], symbol=row['code'], symbol_name=row['name'], cancel_qty=row['unfilled_qty'], ) return {'ok': True, 'message': msg, 'detail': res} def cancel_open_order_and_send(ord_no: Optional[str] = None, account: Optional[str] = None, dry_run: bool = False) -> dict: res = cancel_open_order(ord_no=ord_no, account=account, dry_run=dry_run) if res.get('message'): send_telegram(res['message'], parse_mode=None) return res def modify_open_order(ord_no: Optional[str] = None, account: Optional[str] = None, qty: Optional[int] = None, price: Optional[int] = None, dry_run: bool = False) -> dict: """키움에 접수된 미체결 주문 정정 (kt10002). PIN 게이트 없음. qty/price 둘 다 키움 명세상 Required. 일부만 주면 기존 값 그대로 유지. 시장가 미체결은 정정 불가 (가격 0 차단) — 취소 후 신규 발주 안내. """ sidecar.guard_or_raise() resolved = _resolve_open_order(ord_no, account) if not resolved['ok']: return {'ok': False, 'message': resolved['message'], 'code': resolved['code']} row = resolved['row'] if qty is None and price is None: return {'ok': False, 'message': '⛔ 정정 항목 누락 — qty/price 중 최소 하나는 필요합니다'} new_qty = qty if qty is not None else row['unfilled_qty'] new_price = price if price is not None else row['order_price'] if new_qty <= 0: return {'ok': False, 'message': f'⛔ 정정수량 {new_qty} — 0 이하 정정은 취소를 사용하세요'} if new_price <= 0: return {'ok': False, 'message': '⛔ 시장가 미체결은 정정 불가 (가격 0). 취소 후 신규 발주하세요.'} side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side']) res = kiwoom_order.modify_order( account_label=row['account'], orig_ord_no=row['ord_no'], symbol=row['code'], modify_qty=new_qty, modify_price=new_price, routing_suffix=row.get('routing_suffix', ''), dry_run=dry_run, ) if not res['ok']: reason = res.get('reason', 'UNKNOWN') detail = '' if reason == 'BROKER_REJECT': detail = str(res.get('response', {}).get('return_msg') or '') elif reason == 'NETWORK': detail = str(res.get('error', '')) msg = (f"⛔ 정정 실패 [{reason}] · ord_no={row['ord_no']} · " f"[{row['account']}] {row['name']} {side_word} {new_qty}주 @ {new_price:,}원" + (f"\n사유: {detail}" if detail else '')) return {'ok': False, 'message': msg, 'detail': res} if res.get('dry_run'): msg = (f"🧪 [DRY-RUN] 정정 시뮬레이션 · ord_no={row['ord_no']} · " f"[{row['account']}] {row['name']} {side_word} " f"{row['order_qty']}주 @ {row['order_price']:,}원 " f"→ {new_qty}주 @ {new_price:,}원 ({row['exchange']})") else: new_ord = res.get('new_ord_no', '') msg = (f"✏️ 정정 접수 · 원주문 ord_no={row['ord_no']} → 정정 ord_no={new_ord} · " f"[{row['account']}] {row['name']} {side_word} " f"{row['order_qty']}주 @ {row['order_price']:,}원 " f"→ {new_qty}주 @ {new_price:,}원 ({row['exchange']})") return {'ok': True, 'message': msg, 'detail': res} def modify_open_order_and_send(ord_no: Optional[str] = None, account: Optional[str] = None, qty: Optional[int] = None, price: Optional[int] = None, dry_run: bool = False) -> dict: res = modify_open_order(ord_no=ord_no, account=account, qty=qty, price=price, dry_run=dry_run) if res.get('message'): send_telegram(res['message'], parse_mode=None) return res def list_open_orders_text(account: Optional[str] = None) -> dict: """미체결 주문 리스트 — 단순 조회. 텔레그램 응답용.""" sidecar.guard_or_raise() rows = _list_open_orders(account=account) if not rows: return {'ok': True, 'message': '미체결 주문 없음', 'rows': rows} return {'ok': True, 'message': _format_open_order_list(rows), 'rows': rows} def amend_trade(qty: Optional[int] = None, price: Optional[int] = None, order_type: Optional[str] = None, budget: Optional[int] = None, routing_force: Optional[str] = None, symbol: Optional[str] = None, symbol_name: Optional[str] = None, account: Optional[str] = None) -> dict: """활성 카드의 수량/가격/주문방식/예산/라우팅/종목/계좌를 수정. 수정 가능: qty, price, order_type, budget, routing_force, symbol, symbol_name, account. 수정 불가 (새 카드로): side (매수↔매도). qty/price/budget 중 하나라도 0 이면 → 카드 취소 위임 (다른 비0 값과 동시 입력 시 거부). symbol 변경 시 symbol_name 도 같이 입력 필요. account 변경 시 본인↔가희 PIN 길이도 재조정. 가드 12개 전부 재실행. 통과 시 PIN 재발급 + 만료 리셋, card_id 유지. """ sidecar.guard_or_raise() _sweep_expired_and_notify() active = _pin_store.peek() if active is None or active.is_expired() or active.consumed: return {'ok': False, 'message': card.format_rejected('NO_ACTIVE_CARD', '수정할 활성 카드가 없습니다')} # 0 입력 → 취소. 0 + non-zero 동시 입력은 거부 (의도 모호) numeric_inputs = {'qty': qty, 'price': price, 'budget': budget} has_zero = any(v == 0 for v in numeric_inputs.values() if v is not None) has_nonzero = any(v is not None and v != 0 for v in numeric_inputs.values()) other_amend = (order_type is not None or routing_force is not None or symbol is not None or symbol_name is not None or account is not None) if has_zero and (has_nonzero or other_amend): return {'ok': False, 'message': card.format_rejected('AMBIGUOUS_AMEND', '0(취소)과 다른 수정값을 동시에 줄 수 없습니다')} if has_zero: return cancel_active_card() if budget is not None and qty is not None: return {'ok': False, 'message': card.format_rejected('AMBIGUOUS_INPUT', 'qty와 budget을 동시에 지정할 수 없음 — 하나만 입력')} p = active.payload new_account = account if account is not None else p['account'] new_symbol = symbol if symbol is not None else p['symbol'] new_symbol_name = (symbol_name if symbol_name is not None else p.get('symbol_name', new_symbol)) # account whitelist 검증 whitelist = _limits()['accounts_whitelist'] if new_account not in whitelist: return {'ok': False, 'message': card.format_rejected('INVALID_ACCOUNT', f'허용되지 않은 계좌: {new_account}. 가능: {whitelist}')} # symbol 6자리 숫자 형식 검증 if not (isinstance(new_symbol, str) and len(new_symbol) == 6 and new_symbol.isdigit()): return {'ok': False, 'message': card.format_rejected('INVALID_SYMBOL', f'종목코드는 6자리 숫자여야 합니다 (입력: {new_symbol!r})')} # symbol 변경 시 symbol_name 동반 필수 if symbol is not None and symbol != p['symbol'] and symbol_name is None: return {'ok': False, 'message': card.format_rejected('MISSING_SYMBOL_NAME', '종목 변경 시 symbol_name 도 같이 입력해야 합니다')} # 반대로 symbol_name 단독 변경 금지 — 카드 표시(이름)와 발주(코드) 불일치 차단 if symbol_name is not None and symbol is None and symbol_name != p.get('symbol_name'): return {'ok': False, 'message': card.format_rejected('ORPHAN_SYMBOL_NAME', '종목명만 단독으로 바꿀 수 없습니다 — symbol(6자리 코드) 도 같이 입력')} new_qty = qty if qty is not None else p.get('qty') new_price = price if price is not None else p.get('price') new_order_type = order_type if order_type is not None else p.get('order_type') md = datasource.collect_market_data(new_account, new_symbol, p['side'], new_qty or 0) budget_conversion = None if budget is not None: conv = guards.convert_budget_to_qty(p['side'], budget, md.get('orderbook'), fallback_price=md.get('current_price')) if not conv['ok']: ledger.append('rejected', {'card_id': active.card_id, 'amend': True, 'budget': budget, 'reason': conv['code'], 'message': conv['message']}) return {'ok': False, 'message': card.format_rejected(conv['code'], conv['message'])} new_qty = conv['qty'] new_order_type = 'MARKET' new_price = None budget_conversion = { 'budget': budget, 'qty': conv['qty'], 'ref_price': conv['ref_price'], 'remainder': conv['remainder'], 'source': conv.get('source', 'orderbook'), 'bumped': conv.get('bumped', False), } if new_order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'): return {'ok': False, 'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {new_order_type}')} # LIMIT 으로 전환할 땐 price 필수 (MARKET→LIMIT 또는 AGG→LIMIT 시 누락 차단) if new_order_type == 'LIMIT' and not new_price: return {'ok': False, 'message': card.format_rejected('MISSING_LIMIT_PRICE', 'LIMIT 으로 변경하려면 price 도 같이 입력해야 합니다')} request = { 'account': new_account, 'side': p['side'], 'symbol': new_symbol, 'symbol_name': new_symbol_name, 'qty': new_qty, 'order_type': new_order_type, } if new_order_type == 'LIMIT': request['price'] = new_price r = guards.validate_request(request, md) if not r.ok: ledger.append('rejected', {'card_id': active.card_id, 'amend': True, 'account': new_account, 'symbol': new_symbol, 'qty': new_qty, 'price': new_price, 'reason': r.code, 'message': r.message}) return {'ok': False, 'message': card.format_rejected(r.code, 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', {'card_id': active.card_id, 'amend': True, 'qty': new_qty, 'price': new_price, 'reason': sr.code, 'message': sr.message}) return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)} state_warning = state_eval['warning'] suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force) estimate = None final_price = new_price if new_order_type == 'MARKET': if md.get('orderbook'): estimate = guards.estimate_market_fill(p['side'], new_qty, md['orderbook']) elif new_order_type == 'AGGRESSIVE_LIMIT': agg_res = guards.aggressive_limit_price(p['side'], md.get('orderbook'), fallback_price=md.get('current_price')) if not agg_res['ok']: ledger.append('rejected', {'card_id': active.card_id, 'amend': True, 'qty': new_qty, 'reason': agg_res['code'], 'message': agg_res['message']}) return {'ok': False, 'message': card.format_rejected(agg_res['code'], agg_res['message'])} estimate = {'aggressive_price': agg_res['price'], 'source': agg_res['source'], 'ref_price': agg_res['ref_price']} final_price = agg_res['price'] new_payload = { 'account': new_account, 'side': p['side'], 'symbol': new_symbol, 'symbol_name': new_symbol_name, 'qty': new_qty, 'price': final_price, 'order_type': new_order_type, 'routing_suffix': suffix, } pending = _pin_store.amend(new_payload, account_label=new_account) if pending is None: return {'ok': False, 'message': card.format_rejected('NO_ACTIVE_CARD', '수정할 활성 카드가 없습니다 (만료/소비)')} card_msg = card.format_card(request, md, pending.card_id, estimate, budget_conversion, state_warning, amended=True) pin_msg = card.format_pin_message(pending.pin) ledger.append('amended', {'card_id': pending.card_id, **new_payload}) return {'ok': True, 'card_message': card_msg, 'pin_message': pin_msg, 'card_id': pending.card_id, 'expiry_seconds': pending.expiry_seconds} def amend_and_send(qty: Optional[int] = None, price: Optional[int] = None, order_type: Optional[str] = None, budget: Optional[int] = None, routing_force: Optional[str] = None, symbol: Optional[str] = None, symbol_name: Optional[str] = None, account: Optional[str] = None) -> dict: """amend_trade 후 카드+PIN 텔레그램 분리 발송. 0 입력 시 cancel + 텔레그램 발송.""" res = amend_trade(qty, price, order_type, budget, routing_force, symbol=symbol, symbol_name=symbol_name, account=account) if not res['ok']: send_telegram(res['message'], parse_mode=None) return res if 'card_message' in res: send_telegram(res['card_message'], parse_mode='Markdown') send_telegram(res['pin_message'], parse_mode=None) elif res.get('message'): # cancel 위임 결과 (card_message 없음) send_telegram(res['message'], parse_mode=None) return res def expire_check() -> Optional[dict]: pending = _pin_store.peek() if pending is None: return None if pending.is_expired(): cancelled = _pin_store.cancel() if cancelled: ledger.append('expired', {'card_id': cancelled.card_id, **cancelled.payload}) return {'card_id': cancelled.card_id, 'message': card.format_expired(cancelled.card_id, cancelled.payload['side'])} return None def start_expiry_watcher(send_func=None) -> None: """별도 스레드 — 1초마다 expire_check + 만료 시 send_func 으로 알림.""" global _expiry_thread, _expiry_send _expiry_send = send_func or send_telegram if _expiry_thread and _expiry_thread.is_alive(): return def _loop(): while True: try: exp = expire_check() if exp and _expiry_send: _expiry_send(exp['message']) except Exception as e: print(f'[expiry_watcher] {e}', file=sys.stderr) time.sleep(1) _expiry_thread = threading.Thread(target=_loop, name='order-expiry', daemon=True) _expiry_thread.start() # ---- 단축어 / 명령 ---- def handle_command(command: str) -> dict: parts = command.strip().split() head = parts[0] if parts else '' if head == '/orders_off': sidecar.disable(' '.join(parts[1:]) or 'user command') return {'ok': True, 'message': '🚫 매매 비활성화. /orders_on 으로 재개.'} if head == '/orders_on': ok = sidecar.enable() return {'ok': True, 'message': '✅ 매매 활성화' if ok else '⚠️ 이미 활성화 상태'} if head == '/cancel': return cancel_active_card() if head == '/orders_status': return {'ok': True, 'message': json.dumps(status_text(), ensure_ascii=False, indent=2)} if head == '/positions': return {'ok': False, 'message': '⚙️ /positions 는 stock_portfolio_report 모듈로 통합 예정'} if head == '/balance': return {'ok': False, 'message': '⚙️ /balance 는 stock_portfolio_report 모듈로 통합 예정'} return {'ok': False, 'message': f'알 수 없는 명령: {head}'} def _active_card_info(pending) -> dict: return { 'card_id': pending.card_id, 'account': pending.account_label, 'side': pending.payload.get('side'), 'symbol': pending.payload.get('symbol'), 'symbol_name': pending.payload.get('symbol_name'), 'qty': pending.payload.get('qty'), 'price': pending.payload.get('price'), 'order_type': pending.payload.get('order_type'), 'expires_in': max(0, pending.expiry_seconds - int(time.time() - pending.issued_at)), } def status_text() -> dict: sc = sidecar.status() pending = _pin_store.peek() out = { 'sidecar_disabled': sc.get('disabled'), 'sidecar_path': sc.get('path'), 'agent_env_blocked': sc.get('agent_env_blocked'), 'active_card': None, } if pending: out['active_card'] = _active_card_info(pending) return out # ---- CLI ---- def _main(argv: list[str]) -> int: if not argv or argv[0] == 'status': print(json.dumps(status_text(), ensure_ascii=False, indent=2)) return 0 cmd = argv[0] if cmd == 'propose': if len(argv) < 7: print('usage: propose [price] [routing] [--budget ] [--send]') return 2 account, side, symbol, name, qty_s, otype = argv[1:7] price = None routing = None budget = None send = '--send' in argv[7:] extras = argv[7:] i = 0 while i < len(extras): extra = extras[i] if extra == '--send': i += 1 continue if extra == '--budget': if i + 1 >= len(extras): print('--budget requires a value', file=sys.stderr) return 2 budget = int(extras[i + 1]) i += 2 continue if extra.isdigit(): price = int(extra) else: routing = extra i += 1 qty = None if qty_s == '-' else int(qty_s) fn = propose_and_send if send else propose_trade res = fn(account, side, symbol, name, qty, otype, price, routing, budget=budget) print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 if cmd == 'pin': if len(argv) < 2: print('usage: pin [--live] [--send]') return 2 dry_run = '--live' not in argv[2:] send = '--send' in argv[2:] fn = submit_with_pin_and_send if send else submit_with_pin res = fn(argv[1], dry_run=dry_run) print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 if cmd == 'cancel': send = '--send' in argv[1:] fn = cancel_active_card_and_send if send else cancel_active_card res = fn() print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 if cmd == 'amend': qty = None price = None order_type = None budget = None routing = None symbol = None symbol_name = None account = None send = '--send' in argv[1:] extras = argv[1:] i = 0 while i < len(extras): extra = extras[i] if extra == '--send': i += 1; continue if extra == '--qty': qty = int(extras[i + 1]); i += 2; continue if extra == '--price': price = int(extras[i + 1]); i += 2; continue if extra == '--order-type': order_type = extras[i + 1]; i += 2; continue if extra == '--budget': budget = int(extras[i + 1]); i += 2; continue if extra == '--routing': routing = extras[i + 1]; i += 2; continue if extra == '--symbol': symbol = extras[i + 1]; i += 2; continue if extra == '--symbol-name': symbol_name = extras[i + 1]; i += 2; continue if extra == '--account': account = extras[i + 1]; i += 2; continue print(f'unknown amend arg: {extra}', file=sys.stderr) return 2 if all(v is None for v in (qty, price, order_type, budget, routing, symbol, symbol_name, account)): print('usage: amend [--qty N] [--price W] [--order-type LIMIT|MARKET|AGGRESSIVE_LIMIT] [--budget W] [--routing AL|NX|KRX] [--symbol CODE] [--symbol-name NAME] [--account 일반|ISA|가희_일반|가희_ISA] [--send]\n qty/price/budget 중 하나라도 0 → 취소', file=sys.stderr) return 2 fn = amend_and_send if send else amend_trade res = fn(qty=qty, price=price, order_type=order_type, budget=budget, routing_force=routing, symbol=symbol, symbol_name=symbol_name, account=account) print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 if cmd == 'cmd': if len(argv) < 2: print('usage: cmd ') return 2 res = handle_command(' '.join(argv[1:])) print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 if cmd == 'open-orders': # open-orders [--account A] account = None extras = argv[1:] i = 0 while i < len(extras): if extras[i] == '--account': account = extras[i + 1]; i += 2; continue print(f'unknown open-orders arg: {extras[i]}', file=sys.stderr) return 2 res = list_open_orders_text(account=account) print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 if cmd == 'cancel-order': # cancel-order [--ord-no N] [--account A] [--live] [--send] ord_no = None account = None send = '--send' in argv[1:] live = '--live' in argv[1:] extras = argv[1:] i = 0 while i < len(extras): if extras[i] in ('--send', '--live'): i += 1; continue if extras[i] == '--ord-no': ord_no = extras[i + 1]; i += 2; continue if extras[i] == '--account': account = extras[i + 1]; i += 2; continue print(f'unknown cancel-order arg: {extras[i]}', file=sys.stderr) return 2 fn = cancel_open_order_and_send if send else cancel_open_order res = fn(ord_no=ord_no, account=account, dry_run=not live) print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 if cmd == 'modify-order': # modify-order [--ord-no N] [--account A] [--qty Q] [--price P] [--live] [--send] ord_no = None account = None qty = None price = None send = '--send' in argv[1:] live = '--live' in argv[1:] extras = argv[1:] i = 0 while i < len(extras): if extras[i] in ('--send', '--live'): i += 1; continue if extras[i] == '--ord-no': ord_no = extras[i + 1]; i += 2; continue if extras[i] == '--account': account = extras[i + 1]; i += 2; continue if extras[i] == '--qty': qty = int(extras[i + 1]); i += 2; continue if extras[i] == '--price': price = int(extras[i + 1]); i += 2; continue print(f'unknown modify-order arg: {extras[i]}', file=sys.stderr) return 2 if qty is None and price is None: print('usage: modify-order [--ord-no N] [--account A] --qty Q | --price P [--live] [--send]', file=sys.stderr) return 2 fn = modify_open_order_and_send if send else modify_open_order res = fn(ord_no=ord_no, account=account, qty=qty, price=price, dry_run=not live) print(json.dumps(res, ensure_ascii=False, indent=2)) return 0 if res['ok'] else 1 print(f'unknown CLI command: {cmd}', file=sys.stderr) return 2 if __name__ == '__main__': sys.exit(_main(sys.argv[1:]))