""" 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)), }