feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
131
kis_trader/execution/orderbook_sell.py
Normal file
131
kis_trader/execution/orderbook_sell.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
kis_trader.execution.orderbook_sell — 익절(수익) 매도 시 호가 잔량 방어
|
||||
======================================================================
|
||||
손절·긴급 청산은 OrderManager 가 시장가로 처리한다.
|
||||
수익 구간 매도만 매수 1~N호가 잔량을 확인한 뒤 매수 1호가 지정가를 권장한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, 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,
|
||||
rest_client=None,
|
||||
max_age_sec: float = 3.0,
|
||||
) -> Tuple[Optional[Dict[str, Any]], str]:
|
||||
"""
|
||||
호가 조회 — 키움 WS 캐시 우선, 없으면 KIS REST.
|
||||
Returns: (raw_dict, source_tag) source_tag: kiwoom_ws | kis_rest | none
|
||||
"""
|
||||
c = str(code or "").strip()
|
||||
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, "kiwoom_ws"
|
||||
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"
|
||||
Reference in New Issue
Block a user