브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
333
kis_trader/strategies/breakout.py
Normal file
333
kis_trader/strategies/breakout.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user