""" kis_trader.execution.orderbook_sell — 익절(수익) 매도 시 호가 잔량 방어 ====================================================================== 손절·긴급 청산은 OrderManager 가 시장가로 처리한다. 수익 구간 매도만 매수 1~N호가 잔량을 확인한 뒤 매수 1호가 지정가를 권장한다. """ from __future__ import annotations import logging from typing import Any, Callable, Dict, List, Optional, Tuple from ..utils.env import get_env_bool, get_env_float, get_env_int logger = logging.getLogger("kis_trader.orderbook_sell") _URGENT_SELL_TOKENS = ( "손절", "손실컷", "금액손실", "stop_loss", "stop loss", "손절호가", # 손실구간 호가매도 (손절 부분문자와도 매칭되나 명시) "장마감", "eod", "emergency", "긴급", ) def is_urgent_market_sell_reason(reason: str) -> bool: """손절·장마감·긴급 — 시장가·만료 즉시 재주문 대상.""" r = (reason or "").strip().lower() if not r: return False return any(t in r for t in _URGENT_SELL_TOKENS) def is_profit_take_sell_reason(reason: str) -> bool: """수익·본전 방어 청산(지정가 후보). 손절·장마감·금액손실컷은 False.""" r = (reason or "").strip().lower() if not r: return False if any(t in r for t in _URGENT_SELL_TOKENS): return False profit_tokens = ( "익절", "어깨", "트레일", "본절", "take_profit", "trailing", "profit", ) return any(t in r for t in profit_tokens) def parse_kis_bid_levels(raw: Optional[Dict[str, Any]], levels: int = 5) -> List[Dict[str, Any]]: """KIS 호가 API output → [{'price': int, 'bid_qty': int}, ...] (비싼 매수호가 순).""" if not raw or not isinstance(raw, dict): return [] out: List[Dict[str, Any]] = [] for i in range(1, max(1, levels) + 1): px_raw = raw.get(f"bidp{i}") or raw.get(f"BIDP{i}") qty_raw = raw.get(f"bidp_rsqn{i}") or raw.get(f"BIDP_RSQN{i}") if px_raw in (None, "", "0"): continue try: price = int(abs(float(str(px_raw).replace(",", "")))) except (TypeError, ValueError): continue if price <= 0: continue try: bid_qty = int(abs(float(str(qty_raw or "0").replace(",", "")))) except (TypeError, ValueError): bid_qty = 0 out.append({"price": price, "bid_qty": bid_qty}) return out def resolve_orderbook_raw( code: str, *, ws_get=None, ob_feed_label: Optional[Callable[[str], str]] = None, rest_client=None, max_age_sec: float = 3.0, ) -> Tuple[Optional[Dict[str, Any]], str]: """ 호가 조회 — WSManager.get_orderbook(1·2·3차 체인) 우선, 없으면 KIS REST. Returns: (raw_dict, source_tag) - WS 성공: ``ob_feed_label(code)`` — 예 ``kis(1차)``, ``ls(3차,spill)`` - REST: ``kis_rest`` - 실패: ``none`` """ c = str(code or "").strip() def _ws_source_tag() -> str: if ob_feed_label is not None: try: lab = (ob_feed_label(c) or "").strip() if lab: return lab except Exception: pass return "ws" if ws_get and c: try: raw = ws_get(c, max_age_sec=max_age_sec) except TypeError: try: raw = ws_get(c) except Exception: raw = None except Exception: raw = None if raw: return raw, _ws_source_tag() if rest_client and c: try: raw = rest_client.get_orderbook(c) if raw: return raw, "kis_rest" except Exception: pass return None, "none" def evaluate_take_profit_limit_sell( holding_qty: int, orderbook_raw: Optional[Dict[str, Any]], ) -> Tuple[bool, int, str]: """ 익절용 지정가 매도 가능 여부. Returns: (실행여부, 지정가, 메시지) """ qty = int(holding_qty or 0) if qty <= 0: return False, 0, "qty<=0" levels = get_env_int("SELL_ORDERBOOK_BID_LEVELS", 2) depth_mult = get_env_float("SELL_ORDERBOOK_DEPTH_MULT", 1.5) bids = parse_kis_bid_levels(orderbook_raw, levels=max(levels, 2)) if len(bids) < 1: return False, 0, "no_bid_data" top_n = bids[:levels] total_bid = sum(int(b.get("bid_qty", 0) or 0) for b in top_n) best_price = int(top_n[0]["price"]) need = int(qty * depth_mult) if total_bid < need: logger.info( "호가 얇음 — 익절 대기: 매수1~%d호가 합 %d주 < 필요 %d주 (보유 %d)", levels, total_bid, need, qty, ) return False, 0, "orderbook_thin" return True, best_price, "TAKE_PROFIT_BEST_LIMIT"