Files
kis_bot/kis_trader/execution/orderbook_sell.py
Your Name 5e44b86f8b fix(시세): LS spill-only·토큰 파일캐시·호가 snap_time 3초컷
- _feed_fallback 미러 OFF, LS cap/grace/hold RAM을 KIS·키움 spill과 정합
- LS 접근토큰 .ls_token_cache_*.json (재시작 재사용, revoke 루프 없음)
- 호가 RAM을 틱과 동일 LIVE_FEED_FALLBACK(snap_time)로 컷, 필터 max_age=0은 유지
- 익절 지정가 로그에 실제 호가 벤더(kis/kiwoom/ls 1·2·3차) 표기

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 15:21:21 +09:00

149 lines
4.8 KiB
Python

"""
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"