""" 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: from ..engine import tail_engine as te except ImportError: te = None from ..engine.limit_entry_common import ( compute_atr_limit_price, floor_limit_price_krw, is_limit_atr_entry, limit_valid_until_bar_key, resolve_limit_anchor_price, short_entry_mode, should_cancel_unfilled_limit, tail_limit_params, ) from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from .base import BaseStrategy, is_live_eod_now 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._pending_limit_orders: Dict[str, Dict] = {} self.reload_config() # ------------------------------------------------------------------ def reload_config(self) -> None: # 루프마다 호출 — 병목 시 [RELOAD_PROF] 로 단계 ms 기록 _rp_t0 = time.perf_counter() _rp_last = _rp_t0 _rp: Dict[str, float] = {} def _rp_mark(stage: str) -> None: nonlocal _rp_last now = time.perf_counter() _rp[stage] = (now - _rp_last) * 1000.0 _rp_last = now 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("TAIL_SLOT_MONEY", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000) ) _rp_mark("env_basic") if te is not None: try: # ※ get_tail_defaults_from_db → db.get_merged_env_snapshot() 직접 # (SCALP 는 get_strategy_env_dict RAM 캐시 경로 · SHORT/MOM 은 매 루프 DB) p = te.get_tail_defaults_from_db(self.db) _rp_mark("tail_defaults") p["live_backtest_align"] = get_env_bool( "SHORT_LIVE_BACKTEST_ALIGN", True, ) p["live_signal_lookback_bars"] = get_env_int( "SHORT_LIVE_SIGNAL_LOOKBACK_BARS", 1, ) p["entry_mode"] = short_entry_mode() self._engine_params = p _rp_mark("tail_flags") except Exception as e: self.logger.debug("tail_engine defaults 조회 실패: %s", e) _rp_mark("tail_err") self.eod_enabled = get_env_bool("TAIL_EOD_ENABLED", True) self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:20") _rp_mark("eod") _rp_total = (time.perf_counter() - _rp_t0) * 1000.0 # 500ms 이상만 — 장중 수 초 reload 원인 확정용 if _rp_total >= 500.0: parts = [f"[RELOAD_PROF] SHORT total={_rp_total:.1f}"] for k, v in sorted(_rp.items(), key=lambda x: -x[1]): parts.append(f"{k}={v:.1f}") line = " ".join(parts) try: self.logger.info("%s", line) except Exception: pass try: path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip() if path: import os from datetime import datetime as _dt if not os.path.isabs(path): root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) path = os.path.join(root, path) os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "a", encoding="utf-8") as f: f.write(_dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n") except Exception: pass def _candidate_filter(self, candidate: Dict) -> bool: """tail_on 이 True 인 후보만 대상 (SCALP 과 분리).""" return bool(candidate.get("tail_on", True)) def manage_pending_orders(self) -> None: """ATR 지정가 미체결 — 유효 봉 지나면 취소, 체결 시 DB 반영.""" if not self._pending_limit_orders: return from ..execution.order_manager import OrderRequest for code in list(self._pending_limit_orders.keys()): pend = self._pending_limit_orders.get(code) if not pend: continue if code in self.holdings: self._pending_limit_orders.pop(code, None) continue req = pend.get("request") ord_no = pend.get("ord_no") if req and ord_no: fin = self.order_mgr.try_finalize_limit_buy(req, ord_no) if fin.success and fin.filled_qty > 0: self._load_holdings_from_db() self._pending_limit_orders.pop(code, None) self.logger.info( "✅ [지정가체결-반영] %s ODNO=%s", code, ord_no, ) continue candles_raw = self.ws.get_candles(code, self.candle_tf, n=30) if not candles_raw: continue candles = [self._norm_candle(c) for c in candles_raw] latest_key = str(candles[-1].get("candle_time") or "")[:12] vu = str(pend.get("valid_until_bar_key") or "")[:12] if not should_cancel_unfilled_limit(latest_key, vu): continue disp = pend.get("name") or code if ord_no and self.order_mgr.client.cancel_order(str(ord_no)): self.logger.info( "🚫 [지정가취소] %s %s — 유효봉 종료(%s→%s) 미체결", disp, code, vu, latest_key, ) else: self.logger.info( "🚫 [지정가만료] %s — 유효봉 %s 지남 (취소 API 실패 시 HTS 확인)", code, vu, ) self._pending_limit_orders.pop(code, None) def on_limit_buy_submitted(self, signal: Dict, result) -> None: from ..execution.order_manager import OrderRequest code = signal["code"] self._pending_limit_orders[code] = { "ord_no": result.ord_no, "valid_until_bar_key": signal.get("valid_until_bar_key"), "signal_bar_key": signal.get("signal_bar_key"), "name": signal.get("name", code), "request": OrderRequest( strategy_id=self.strategy_id, code=code, name=signal.get("name", code), side="BUY", qty=int(signal.get("qty", 0)), price_ref=float(signal.get("price", 0)), stop_price=float(signal.get("stop_price", 0)), target_price=float(signal.get("target_price", 0)), atr_entry=float(signal.get("atr_entry", 0)), size_class=signal.get("size_class"), entry_features=signal.get("entry_features"), use_limit_buy=True, ), } # ------------------------------------------------------------------ # 매수 # ------------------------------------------------------------------ def check_buy(self, code: str, name: str) -> Optional[Dict]: if te is None: self.logger.warning("tail_engine 미탑재 → 매수 체크 스킵") return None # 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효) if self.is_dip_buy_excluded(code): self._scan_log("info", code, "🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code) return None _cb = self._cb_prof_start(code) 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) self._cb_prof_mark(_cb, "candles") if len(candles_raw) < min_len: try: self.ws.fill_gap([code], force=True) except Exception: pass self._cb_prof_mark(_cb, "fill_gap") return None candles = [self._norm_candle(c) for c in candles_raw] self._cb_prof_mark(_cb, "norm") 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._get_today_trades(today) code_trades = [ t for t in today_trades if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT") ] daily_cnt = len(code_trades) daily_pnl_krw = sum( float(t.get("realized_pnl") or 0) for t in code_trades ) except Exception: daily_cnt = 0 daily_pnl_krw = 0.0 self._cb_prof_mark(_cb, "trades_db") state = { "last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt, "daily_pnl_krw": daily_pnl_krw, } params = dict(self._engine_params or {}) params["_whipsaw_ws"] = self.ws params["_whipsaw_code"] = code params["_orderbook_ws"] = self.ws params["_orderbook_code"] = code params["_program_ws"] = self.ws params["_program_code"] = code params["slot_money"] = self.slot_money reject, msg, sig = te.check_buy_signal_live(candles, params, state) self._cb_prof_mark(_cb, "engine") if reject: self._scan_log("info", code, "🔍 [%s] %s %s: %s", reject, name, code, msg or "") return None if not sig: return None if code in self._pending_limit_orders: return None params = self._engine_params or {} eng = params if params else te.get_tail_defaults_from_db(self.db) atr_period = int(eng.get("atr_period", 14)) atr_series = te.compute_atr_series(candles, atr_period) self._cb_prof_mark(_cb, "atr") if is_limit_atr_entry(short_entry_mode(eng)): if len(candles) < 2: return None sig_i = len(candles) - 2 sig_bar = candles[sig_i] lp_cfg = tail_limit_params(eng) anchor_px = resolve_limit_anchor_price( lp_cfg["anchor"], sig_bar, candles, sig_i, ) atr_val = atr_series[sig_i] if sig_i < len(atr_series) else None limit_px = compute_atr_limit_price( anchor_px, atr_val, lp_cfg["mult"], min_price=self.min_price, ) limit_int = floor_limit_price_krw(limit_px) if limit_int <= 0: return None stop_price, target_price = te.compute_tail_atr_prices( float(limit_int), float(atr_val or limit_int * 0.01), eng, ) valid_until = limit_valid_until_bar_key( candles, sig_i, lp_cfg["valid_bars"], ) 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) qty, rej = self._resolve_buy_qty_live( float(limit_int), hard_cap=hard_cap, ) self._cb_prof_mark(_cb, "qty") if rej: self.logger.info( "🔍 [탈락-%s] %s(%s) limit=%s", rej, name, code, f"{limit_int:,}", ) return None self.logger.info( "🎯 [SHORT 지정가] %s(%s) limit=%s원 유효~%s qty=%d", name, code, f"{limit_int:,}", valid_until, qty, ) return { "code": code, "name": name, "price": float(limit_int), "qty": qty, "use_limit_buy": True, "valid_until_bar_key": valid_until, "signal_bar_key": str(sig_bar.get("candle_time") or "")[:12], "stop_price": stop_price, "target_price": target_price, "atr_entry": float(atr_val or 0), "session_low": float(limit_int), "max_price": float(limit_int), "size_class": "", "entry_features": { "rsi": sig.get("rsi_val", 50), "tail_length_pct": sig.get("tail_pct", 0) * 100, "entry_mode": "limit_atr", }, } # align — 다음 3분봉: RAM 첫 틱 → 없으면 시가 (시장가 계산용) align_on = get_env_bool("SHORT_LIVE_BACKTEST_ALIGN", True) entry_open = float(sig.get("entry_price", 0) or 0) entry_src = "ohlc_open" _ebk = str(sig.get("entry_bar_key") or "")[:12] _defer = self._defer_mid_enroll_entry( code, _ebk, int(self.candle_tf or 3), ) self._cb_prof_mark(_cb, "mid_enroll") if _defer: self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code) return None if align_on and entry_open > 0: from kis_trader.engine.tail_tick_replay import live_align_entry_price curr_price, entry_src = live_align_entry_price( self.ws, code, entry_open, entry_bar_key=_ebk, tf_min=int(self.candle_tf or 3), ) else: curr_price = float(candles[-1]["close"]) wsd = self._ws_last_quote(code) if wsd: try: curr_price = abs( float(str(wsd.get("stck_prpr", curr_price)).replace(",", "")) ) or curr_price except Exception: pass self._cb_prof_mark(_cb, "align") if curr_price <= 0 or curr_price < self.min_price: return None 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) qty, rej = self._resolve_buy_qty_live( curr_price, hard_cap=hard_cap, ) self._cb_prof_mark(_cb, "qty") if rej: self.logger.info( "🔍 [탈락-%s] %s(%s) price=%.0f", rej, name, code, curr_price, ) return None stop_price = curr_price * (1 + self.stop_loss_pct) target_price = curr_price * (1 + self.take_profit_pct) atr_entry = 0.0 try: atr_val = atr_series[-1] if atr_series else None if atr_val is not None and float(atr_val) > 0: atr_entry = float(atr_val) stop_price, target_price = te.compute_tail_atr_prices( curr_price, atr_entry, eng, ) self.logger.info( "📊 [SHORT ATR] %s(%s) ATR=%.0f 손절=%.0f 목표=%.0f", name, code, atr_entry, stop_price, target_price, ) except Exception as e: self.logger.debug("SHORT ATR 손절/목표 계산 스킵(%s): %s", code, e) if atr_entry <= 0: atr_entry = curr_price * 0.01 self.logger.info( "🎯 [SHORT 시그널] %s(%s) price=%.0f qty=%d pat=%s tail=%.2f rec=%.0f%% RSI=%.1f entry_src=%s", name, code, curr_price, qty, sig.get("pattern", "hammer"), sig.get("tail_ratio", 0), sig.get("recovery_pos", 0) * 100, sig.get("rsi_val", 0), entry_src, ) return { "code": code, "name": name, "price": curr_price, "qty": qty, "stop_price": stop_price, "target_price": target_price, "atr_entry": atr_entry if atr_entry > 0 else float(sig.get("atr_calc_val") or 0.0), "session_low": curr_price, "max_price": curr_price, "size_class": "", "entry_features": { "rsi": sig.get("rsi_val", 50), "tail_length_pct": sig.get("tail_pct", 0) * 100, "pattern": sig.get("pattern", "hammer"), }, } except Exception as e: self._scan_log("info", code, "🔍 [탈락-예외] %s %s: %s", name, code, e) return None finally: self._cb_prof_finish(_cb) def _force_buy_test(self, code: str, name: str) -> Optional[Dict]: wsd = self._ws_last_quote(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, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings or te is None: return [] now = dt.now() is_eod = is_live_eod_now( getattr(self, "eod_enabled", True), getattr(self, "eod_hm", "15:20"), now, default_hm="15:20", ) try: params = te.get_tail_defaults_from_db(self.db) except Exception: params = {} signals: List[Dict] = [] for code, holding in list(self.holdings.items()): if only_code and code != only_code: continue 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 = self._resolve_sell_price( code, is_eod=is_eod, buy_price=buy_price, ) if current_price <= 0: continue max_price = float(holding.get("max_price") or buy_price) if current_price > max_price: max_price = current_price holding["max_price"] = max_price session_low = float(holding.get("session_low") or buy_price) if current_price < session_low: session_low = current_price holding["session_low"] = session_low 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": session_low, "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)), }