Files
kis_bot/kis_trader/strategies/breakout.py
Hwang f61c471aac 브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
2026-05-05 21:04:17 +09:00

334 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/strategies/breakout.py — 돌파 매매 (Breakout)
=============================================================
전제:
* 유니버스는 HTS 에 저장된 "우상향 돌파" 조건식에서 REST 로 폴링
(``ConditionSearchManager``). 즉 "절대 바닥 잡기" 가 아니라 이미 강세인
종목들 사이에서 돌파의 질(Q) 만 검증한다.
* 진입은 '장 초반 골든타임' 한정 (09:00 ~ BREAKOUT_GOLDEN_END_HM).
오후장 돌파는 거의 속임수(휩쏘) → 봇이 쫓지 않는다.
매수 필터 (네 가지 모두 만족):
1) 저항선 돌파 : 최근 ``BREAKOUT_LOOKBACK_MIN`` 분봉의 고가 > 최대고가(전고점)
2) 거래량 폭발 : 돌파 봉 거래량 ≥ 최근 ``BREAKOUT_VOL_WIN`` 봉 평균 × ``BREAKOUT_VOL_MULT``
3) 상승 확증 : 직전 봉 종가 대비 현재가 상승 & 종가 > 시가
4) 이격 과열 X : 당일 상승률 ≤ BREAKOUT_MAX_DAILY_CHG (기본 15%)
매도:
* 손절 : 진입가 × (1 + BREAKOUT_STOP_LOSS_PCT) (기본 -2%)
* 익절 : 진입가 × (1 + BREAKOUT_TAKE_PROFIT_PCT) (기본 +5%)
* 트레일링 스탑 : 최고가 대비 BREAKOUT_TRAIL_PCT 하락 (기본 1.5%)
* EOD 강제청산 (15:15 이후 전량)
주문 집행은 모두 ``OrderManager.place()`` 경유 → ODNO·실잔고검증·종목락 공유.
"""
from __future__ import annotations
import time
from datetime import datetime as dt
from typing import Dict, List, Optional
from ..utils.env import get_env_float, get_env_from_db, get_env_int
from .base import BaseStrategy
class BreakoutStrategy(BaseStrategy):
strategy_id = "BREAKOUT"
loop_min_sleep = 0.8
loop_max_sleep = 1.8
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.candle_tf = 1 # 1분봉 기준
self.reload_config()
# ------------------------------------------------------------------
def reload_config(self) -> None:
self.golden_end_hm = get_env_from_db("BREAKOUT_GOLDEN_END_HM", "10:30")
self.lookback_min = get_env_int("BREAKOUT_LOOKBACK_MIN", 30)
self.vol_window = get_env_int("BREAKOUT_VOL_WIN", 20)
self.vol_mult = get_env_float("BREAKOUT_VOL_MULT", 3.0)
self.max_daily_chg = get_env_float("BREAKOUT_MAX_DAILY_CHG", 15.0)
self.min_price = get_env_float("BREAKOUT_MIN_PRICE", 1000.0)
self.slot_money = get_env_int("BREAKOUT_SLOT_MONEY", 2000000)
self.stop_loss_pct = get_env_float("BREAKOUT_STOP_LOSS_PCT", -0.02)
self.take_profit_pct = get_env_float("BREAKOUT_TAKE_PROFIT_PCT", 0.05)
self.trail_pct = get_env_float("BREAKOUT_TRAIL_PCT", 0.015)
self.eod_hm = get_env_from_db("BREAKOUT_EOD_HM", "15:15")
# 유니버스 로드는 BaseStrategy._load_candidates 가 이미 조건검색 우선 처리.
def _candidate_filter(self, candidate: Dict) -> bool:
if not candidate.get("code"):
return False
# 골든타임 외에는 check_buy 호출 자체를 차단 → 탈락 로그 노이즈 방지
if not self._is_golden_time():
return False
return True
# ------------------------------------------------------------------
# 매수
# ------------------------------------------------------------------
def _is_golden_time(self) -> bool:
try:
hh, mm = [int(x) for x in self.golden_end_hm.split(":")]
except Exception:
hh, mm = 10, 30
now = dt.now()
if now.hour < 9:
return False
if now.hour > hh:
return False
if now.hour == hh and now.minute > mm:
return False
return True
def check_buy(self, code: str, name: str) -> Optional[Dict]:
# 골든타임 가드는 _candidate_filter 에서 이미 처리됨 (방어용 재검사)
if not self._is_golden_time():
return None
need_n = max(self.lookback_min, self.vol_window) + 1
candles = self.ws.get_candles(code, self.candle_tf, n=need_n + 5)
if len(candles) < need_n:
self.logger.info(
"🔍 [캔들부족] %s(%s) need=%d have=%d",
name, code, need_n, len(candles),
)
return None
try:
closes = [float(c.get("close", 0)) for c in candles]
highs = [float(c.get("high", 0)) for c in candles]
opens = [float(c.get("open", 0)) for c in candles]
vols = [float(c.get("volume", 0)) for c in candles]
except Exception as e:
self.logger.info("🔍 [캔들파싱] %s(%s): %s", name, code, e)
return None
curr_close = closes[-1]
curr_open = opens[-1]
curr_vol = vols[-1]
if curr_close < self.min_price:
self.logger.info(
"🔍 [최소가미달] %s(%s) close=%.0f < min=%.0f",
name, code, curr_close, self.min_price,
)
return None
# 1) 저항선 돌파 — 직전 N봉 중 최고가 < 현재 종가
window_highs = highs[-(self.lookback_min + 1):-1]
if not window_highs:
self.logger.info("🔍 [저항창없음] %s(%s)", name, code)
return None
resistance = max(window_highs)
if curr_close <= resistance:
gap_pct = (curr_close - resistance) / resistance * 100.0 if resistance > 0 else 0.0
self.logger.info(
"🔍 [저항미돌파] %s(%s) close=%.0f ≤ 저항=%.0f (gap=%.2f%%)",
name, code, curr_close, resistance, gap_pct,
)
return None
# 2) 거래량 폭발
vol_window = vols[-(self.vol_window + 1):-1]
if not vol_window or sum(vol_window) == 0:
self.logger.info("🔍 [거래량창없음] %s(%s)", name, code)
return None
avg_vol = sum(vol_window) / len(vol_window)
if avg_vol <= 0 or curr_vol < avg_vol * self.vol_mult:
ratio = (curr_vol / avg_vol) if avg_vol > 0 else 0.0
self.logger.info(
"🔍 [거래량부족] %s(%s) volX=%.2f < %.1f (curr=%.0f avg=%.0f)",
name, code, ratio, self.vol_mult, curr_vol, avg_vol,
)
return None
# 3) 상승 확증: 양봉
if curr_close <= curr_open:
self.logger.info(
"🔍 [음봉/도지] %s(%s) close=%.0f ≤ open=%.0f",
name, code, curr_close, curr_open,
)
return None
# 3) 직전 종가 대비 상승
if curr_close <= closes[-2]:
chg = (curr_close - closes[-2]) / closes[-2] * 100.0 if closes[-2] > 0 else 0.0
self.logger.info(
"🔍 [전봉대비하락] %s(%s) close=%.0f ≤ prev=%.0f (%.2f%%)",
name, code, curr_close, closes[-2], chg,
)
return None
# 4) 이격 과열 — 당일 시가 대비 상승률 체크
day_open = opens[0] if opens else curr_open
if day_open > 0:
daily_chg = (curr_close - day_open) / day_open * 100.0
if daily_chg > self.max_daily_chg:
self.logger.info(
"🔍 [이격과열] %s(%s) 일중 %.2f%% > %.1f%%",
name, code, daily_chg, self.max_daily_chg,
)
return None
# 현재가 재확인 (WS)
wsd = self.ws.get_price(code)
curr_price = curr_close
if wsd:
try:
p = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
if p > 0:
curr_price = p
except Exception:
pass
# 포지션 크기 = 손실허용액 / |손절비율|
max_loss_krw = get_env_int("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", 0) \
or get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000)
sl_pct = abs(self.stop_loss_pct)
if max_loss_krw > 0 and sl_pct > 0:
invest_amount = min(max_loss_krw / sl_pct, self.slot_money)
else:
invest_amount = self.slot_money
qty = max(1, int(invest_amount / curr_price))
stop_price = curr_price * (1 + self.stop_loss_pct)
target_price = curr_price * (1 + self.take_profit_pct)
vol_ratio = curr_vol / avg_vol if avg_vol > 0 else 0.0
self.logger.info(
"🚀 [BREAKOUT 시그널] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f",
name, code, curr_price, qty, resistance, vol_ratio,
)
return {
"code": code,
"name": name,
"price": curr_price,
"qty": qty,
"stop_price": stop_price,
"target_price": target_price,
"atr_entry": 0.0,
"size_class": "",
"entry_features": {
"resistance": resistance,
"vol_ratio": vol_ratio,
},
}
# ------------------------------------------------------------------
# 매도
# ------------------------------------------------------------------
def check_sell_signals(self) -> List[Dict]:
if not self.holdings:
return []
try:
eod_hh, eod_mm = [int(x) for x in self.eod_hm.split(":")]
except Exception:
eod_hh, eod_mm = 15, 15
now = dt.now()
is_eod = (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm)
signals: List[Dict] = []
for code, holding in list(self.holdings.items()):
try:
name = holding.get("name", code)
buy_price = float(holding.get("buy_price", 0))
qty = int(holding.get("qty", 0))
max_price = float(holding.get("max_price", buy_price))
if qty <= 0 or buy_price <= 0:
self.logger.info(
"🔍 [매도-잘못된보유] %s(%s) qty=%d buy=%.0f",
name, code, qty, buy_price,
)
continue
current_price = 0.0
price_src = ""
wsd = self.ws.get_price(code)
if wsd:
try:
current_price = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
price_src = "WS"
except Exception:
current_price = 0.0
if current_price <= 0:
pd_ = self.client.inquire_price(code)
if pd_:
try:
current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
price_src = "REST"
except Exception:
current_price = 0.0
if current_price <= 0:
# 가격 소스 둘 다 실패 — 매도 판단 불가. 분당 1회만 경고.
last_warn = getattr(self, "_sell_no_price_log", {}).get(code, 0)
if time.time() - last_warn >= 60:
if not hasattr(self, "_sell_no_price_log"):
self._sell_no_price_log = {}
self._sell_no_price_log[code] = time.time()
self.logger.warning(
"⚠️ [매도-가격없음] %s(%s) WS+REST 둘 다 실패 → 매도 판단 보류",
name, code,
)
continue
# 최고가 갱신
if current_price > max_price:
max_price = current_price
holding["max_price"] = max_price
profit_pct = (current_price - buy_price) / buy_price
reason = None
# EOD 강제청산
if is_eod:
reason = "eod"
# 손절
elif current_price <= buy_price * (1 + self.stop_loss_pct):
reason = "stop_loss"
# 익절
elif current_price >= buy_price * (1 + self.take_profit_pct):
reason = "take_profit"
# 트레일링
elif (
max_price > buy_price
and current_price <= max_price * (1 - self.trail_pct)
):
reason = "trailing"
if not reason:
# 보유 중이지만 매도 조건 미충족 — 종목별 60초 1회 상태 로그
if not hasattr(self, "_sell_state_log"):
self._sell_state_log = {}
last = self._sell_state_log.get(code, 0)
if time.time() - last >= 60:
self._sell_state_log[code] = time.time()
sl_line = buy_price * (1 + self.stop_loss_pct)
tp_line = buy_price * (1 + self.take_profit_pct)
trail_line = max_price * (1 - self.trail_pct) if max_price > buy_price else 0.0
self.logger.info(
"🔍 [보유중] %s(%s) 현재=%.0f 매수=%.0f (%.2f%%) "
"손절=%.0f 익절=%.0f 트레일=%.0f [%s]",
name, code, current_price, buy_price, profit_pct * 100.0,
sl_line, tp_line, trail_line, price_src,
)
continue
signals.append({
"code": code,
"name": name,
"current_price": current_price,
"price": current_price,
"qty": qty,
"buy_price": buy_price,
"profit_pct": profit_pct,
"reason": reason,
})
except Exception as e:
self.logger.error("매도 시그널 체크 오류(%s): %s", code, e)
return signals