브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
275
kis_trader/strategies/tail_catch.py
Normal file
275
kis_trader/strategies/tail_catch.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
kis_trader/strategies/tail_catch.py — 꼬리잡기 전략 (3분봉 망치형 되돌림)
|
||||
==========================================================================
|
||||
- 신호 생성: ``tail_engine.check_buy_signal_live`` / ``check_sell_signal_live``
|
||||
- 주문 실행: ``OrderManager.place(OrderRequest(strategy_id="SHORT", ...))``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime as dt
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
try:
|
||||
import tail_engine as te
|
||||
except ImportError:
|
||||
te = None
|
||||
|
||||
from ..utils.env import get_env_bool, get_env_float, get_env_int
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class TailCatchStrategy(BaseStrategy):
|
||||
strategy_id = "SHORT"
|
||||
loop_min_sleep = 1.5
|
||||
loop_max_sleep = 2.5
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.candle_tf = 3 # 3분봉
|
||||
self._engine_params: Optional[Dict] = None
|
||||
self.reload_config()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def reload_config(self) -> None:
|
||||
self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
|
||||
self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04)
|
||||
self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
|
||||
self.slot_money = get_env_int("SLOT_MONEY_DEFAULT", 3000000)
|
||||
if te is not None:
|
||||
try:
|
||||
self._engine_params = te.get_tail_defaults_from_db(self.db)
|
||||
except Exception as e:
|
||||
self.logger.debug("tail_engine defaults 조회 실패: %s", e)
|
||||
|
||||
def _candidate_filter(self, candidate: Dict) -> bool:
|
||||
"""tail_on 이 True 인 후보만 대상 (SCALP 과 분리)."""
|
||||
return bool(candidate.get("tail_on", True))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 매수
|
||||
# ------------------------------------------------------------------
|
||||
def check_buy(self, code: str, name: str) -> Optional[Dict]:
|
||||
if te is None:
|
||||
self.logger.warning("tail_engine 미탑재 → 매수 체크 스킵")
|
||||
return None
|
||||
try:
|
||||
if get_env_bool("FORCE_BUY_TEST", False):
|
||||
return self._force_buy_test(code, name)
|
||||
|
||||
min_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14)
|
||||
candles_raw = self.ws.get_candles(code, self.candle_tf, n=50)
|
||||
if len(candles_raw) < min_len:
|
||||
return None
|
||||
candles = [self._norm_candle(c) for c in candles_raw]
|
||||
if len(candles) < 10:
|
||||
return None
|
||||
|
||||
today = dt.now().strftime("%Y%m%d")
|
||||
last_exit_dt = None
|
||||
if code in self.recently_sold:
|
||||
try:
|
||||
last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
|
||||
if last_exit_dt.strftime("%Y%m%d") != today:
|
||||
last_exit_dt = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
today_trades = self.db.get_trades_by_date(today)
|
||||
daily_cnt = len([
|
||||
t for t in today_trades
|
||||
if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT")
|
||||
])
|
||||
except Exception:
|
||||
daily_cnt = 0
|
||||
state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}
|
||||
|
||||
params = self._engine_params or {}
|
||||
reject, msg, sig = te.check_buy_signal_live(candles, params, state)
|
||||
if reject:
|
||||
self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
|
||||
return None
|
||||
if not sig:
|
||||
return None
|
||||
|
||||
curr_price = float(candles[-1]["close"])
|
||||
wsd = self.ws.get_price(code)
|
||||
if wsd:
|
||||
try:
|
||||
curr_price = abs(float(str(wsd.get("stck_prpr", curr_price)).replace(",", ""))) or curr_price
|
||||
except Exception:
|
||||
pass
|
||||
if curr_price <= 0 or curr_price < self.min_price:
|
||||
return None
|
||||
|
||||
# 포지션 크기: 손실허용액 / 손절비율 (꼬리잡기는 손절폭이 스캘핑보다 큼)
|
||||
max_loss_krw = get_env_int("TAIL_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_limit = max_loss_krw / sl_pct
|
||||
invest_amount = min(invest_limit, self.slot_money)
|
||||
else:
|
||||
invest_amount = self.slot_money
|
||||
|
||||
# ── [하드캡] 종목당 최대 매수금액 상한 ────────────────────────
|
||||
# 우선순위: SHORT 전용(=TAIL) > 공용 > 미설정(=무시)
|
||||
hard_cap = get_env_int("SHORT_MAX_BUY_AMOUNT", 0) \
|
||||
or get_env_int("TAIL_MAX_BUY_AMOUNT", 0) \
|
||||
or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
|
||||
if hard_cap > 0 and invest_amount > hard_cap:
|
||||
self.logger.info(
|
||||
"💰 [투자금 상한 적용] %s: %s원 → %s원 (cap=%s)",
|
||||
code, f"{int(invest_amount):,}", f"{hard_cap:,}", f"{hard_cap:,}",
|
||||
)
|
||||
invest_amount = hard_cap
|
||||
|
||||
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)
|
||||
|
||||
self.logger.info(
|
||||
"🎯 [SHORT 시그널] %s(%s) price=%.0f qty=%d tail=%.2f rec=%.0f%% RSI=%.1f",
|
||||
name, code, curr_price, qty,
|
||||
sig.get("tail_ratio", 0), sig.get("recovery_pos", 0) * 100, sig.get("rsi_val", 0),
|
||||
)
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"price": curr_price,
|
||||
"qty": qty,
|
||||
"stop_price": stop_price,
|
||||
"target_price": target_price,
|
||||
"atr_entry": float(sig.get("atr_calc_val") or 0.0),
|
||||
"size_class": "",
|
||||
"entry_features": {
|
||||
"rsi": sig.get("rsi_val", 50),
|
||||
"tail_length_pct": sig.get("tail_pct", 0) * 100,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
|
||||
return None
|
||||
|
||||
def _force_buy_test(self, code: str, name: str) -> Optional[Dict]:
|
||||
wsd = self.ws.get_price(code)
|
||||
px = 0.0
|
||||
if wsd:
|
||||
try:
|
||||
px = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
|
||||
except Exception:
|
||||
px = 0.0
|
||||
if px <= 0:
|
||||
pd_ = self.client.inquire_price(code)
|
||||
if pd_:
|
||||
try:
|
||||
px = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
|
||||
except Exception:
|
||||
px = 0.0
|
||||
if px <= 0:
|
||||
return None
|
||||
qty = max(1, int(self.slot_money / px))
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"price": px,
|
||||
"qty": qty,
|
||||
"stop_price": px * (1 + self.stop_loss_pct),
|
||||
"target_price": px * (1 + self.take_profit_pct),
|
||||
"atr_entry": 0.0,
|
||||
"size_class": "",
|
||||
"entry_features": {},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 매도
|
||||
# ------------------------------------------------------------------
|
||||
def check_sell_signals(self) -> List[Dict]:
|
||||
if not self.holdings or te is None:
|
||||
return []
|
||||
|
||||
now = dt.now()
|
||||
is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15
|
||||
try:
|
||||
params = te.get_tail_defaults_from_db(self.db)
|
||||
except Exception:
|
||||
params = {}
|
||||
|
||||
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))
|
||||
if qty <= 0 or buy_price <= 0:
|
||||
continue
|
||||
|
||||
current_price = 0.0
|
||||
wsd = self.ws.get_price(code)
|
||||
if wsd:
|
||||
try:
|
||||
current_price = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
|
||||
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(",", "")))
|
||||
except Exception:
|
||||
current_price = 0.0
|
||||
if current_price <= 0:
|
||||
continue
|
||||
|
||||
max_price = float(holding.get("max_price", buy_price))
|
||||
if current_price > max_price:
|
||||
max_price = current_price
|
||||
holding["max_price"] = max_price
|
||||
|
||||
position = {
|
||||
"entry_price": buy_price,
|
||||
"entry_time": holding.get("buy_time", ""),
|
||||
"qty": qty,
|
||||
"stop": float(holding.get("stop_price", buy_price * (1 + self.stop_loss_pct))),
|
||||
"target": float(holding.get("target_price", buy_price * (1 + self.take_profit_pct))),
|
||||
"max_price": max_price,
|
||||
}
|
||||
candle = {
|
||||
"high": max_price,
|
||||
"low": current_price,
|
||||
"close": current_price,
|
||||
"candle_time": now.strftime("%Y%m%d%H%M"),
|
||||
}
|
||||
res = te.check_sell_signal_live(position, candle, params, is_eod=is_eod)
|
||||
if not res:
|
||||
continue
|
||||
reason, exit_price = res
|
||||
profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
|
||||
signals.append({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"current_price": current_price,
|
||||
"price": exit_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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _norm_candle(self, c: dict) -> dict:
|
||||
ct = c.get("candle_time") or c.get("candle_time_str", "")
|
||||
if isinstance(ct, str) and len(ct) == 19 and " " in ct:
|
||||
ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12]
|
||||
return {
|
||||
"candle_time": ct,
|
||||
"open": float(c.get("open", 0)),
|
||||
"high": float(c.get("high", 0)),
|
||||
"low": float(c.get("low", 0)),
|
||||
"close": float(c.get("close", 0)),
|
||||
"volume": float(c.get("volume", 0)),
|
||||
}
|
||||
Reference in New Issue
Block a user