""" kis_trader/strategies/momentum.py — MOMENTUM A안 (HTS momentum E∧F∧H∧I 돌파·주도주 추격) ================================================================ 스캘핑 reversal(SCALP)과 완전 분리 — ``momentum_engine`` 전용. [SCAN vs TRIGGER vs 청산] - SCAN: 키움 ``momentum`` 조건검색 → target_candidates_history - TRIGGER: ``MOMENTUM_SKIP_HTS_SCAN_DUPES=true`` (kiwoom 기본) 시 E·양봉·거래량 중복 생략, 진입 타이밍만 - 청산: 래칫·어깨·트레일·손절·시간컷 (``momentum_hts_logic``) [엔진] - 진입: ``momentum_engine.check_buy_signal_momentum_live`` - 청산: ``momentum_engine.check_sell_signal_momentum_live`` - 백테: ``momentum_engine.run_momentum_backtest`` / ``check_sell_signal_momentum_backtest_bar`` """ from __future__ import annotations from datetime import datetime as dt from typing import Dict, List, Optional from ..engine import momentum_engine as me from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.position_sizing import invest_qty_for_price from .base import BaseStrategy, is_live_eod_now class MomentumStrategy(BaseStrategy): """1분봉 단기 추세추격 — SCALP reversal 과 청산·진입 모두 분리.""" strategy_id = "MOMENTUM" loop_min_sleep = 1.0 loop_max_sleep = 2.0 def __init__(self, **kwargs): super().__init__(**kwargs) self.candle_tf = 1 self._engine_params: Optional[Dict] = None self.reload_config() def reload_config(self) -> None: """env_config → momentum_engine params.""" try: base = me.get_momentum_defaults_from_db() base.update({ "fee_rate": get_env_float("FEE_RATE_PCT", 0.015) / 100, "sell_tax": get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100, }) self._engine_params = base self.min_price = float(base.get("min_price", 1000.0)) self.stop_loss_pct = -abs(float(base.get("sl_pct", 0.015))) self.take_profit_pct = abs(float(base.get("tp_pct", 0.025))) self.tp_max_pct = abs(float(base.get("tp_max_pct", 0.02))) self.slot_money = int(base.get("slot_money", 3_000_000)) self.mom_rsi_min = float(base.get("mom_rsi_min", 50.0)) self.mom_rsi_max = float(base.get("mom_rsi_max", 80.0)) self.max_daily = int(base.get("max_daily", 5)) self.eod_enabled = get_env_bool("MOMENTUM_EOD_ENABLED", True) self.eod_hm = get_env_from_db("MOMENTUM_EOD_HM", "15:20") except Exception as e: self.logger.debug("momentum_engine defaults 조회 실패: %s", e) self._engine_params = {} def _candidate_filter(self, candidate: Dict) -> bool: return bool(candidate.get("scalp_on", True)) def _reentry_cooldown_sec(self) -> int: # wall-clock 이중 게이트 제거 — 엔진 cooldown_min(신호봉 시계)만 사용 (BT 정합) if bool((self._engine_params or {}).get("cooldown_engine_only", True)): return 0 return super()._reentry_cooldown_sec() def check_buy(self, code: str, name: str) -> Optional[Dict]: try: if get_env_bool("FORCE_BUY_TEST", False): return self._force_buy_test(code, name) # E조건(전일시가) — 당일 50봉만으로는 불가 → 키움 REST 갭보정 RAM (DB 구데이터 미사용) min_need = get_env_int("MOMENTUM_LIVE_MIN_CANDLES", 500) candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=min_need) or []) # ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio)와 동일 시점 use_forming = bool((self._engine_params or {}).get("live_align_use_forming_bar", True)) if (self._engine_params or {}).get("live_backtest_align", True) and use_forming: try: cur = self.ws.get_current_candle(code, self.candle_tf) except Exception: cur = None if cur and float(cur.get("open", 0) or 0) > 0: ct = str(cur.get("candle_time") or "")[:12] last_ct = "" if candles_raw: last_ct = str(candles_raw[-1].get("candle_time") or "")[:12] if ct and ct != last_ct: cur_d = dict(cur) cur_d["is_confirmed"] = 0 candles_raw.append(cur_d) if len(candles_raw) < 6: try: # force: EXIT 후 _gap_filled 잔존 시에도 재채움 (봉부족 복구) self.ws.fill_gap([code], force=True) except Exception: pass return None candles = [self._norm_candle(c) for c in candles_raw] 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 # 분 단위 floor — 엔진 쿨다운이 신호봉 candle_time 과 같은 시계를 쓰도록 elif bool((self._engine_params or {}).get("cooldown_use_candle_floor", True)): last_exit_dt = last_exit_dt.replace(second=0, microsecond=0) 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", "")).upper() == "MOMENTUM" ]) except Exception: daily_cnt = 0 state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt} 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 = me.check_buy_signal_momentum_live(candles, params, state) if reject: # 갭보정 워밍업 중 — 전일시가 없음·봉부족 시 force 재큐 (로그 스팸 전에 복구) if reject in ("탈락-전일시가없음", "탈락-봉부족"): try: self.ws.fill_gap([code], force=True) except Exception: pass if reject == "탈락-전일시가없음" and len(candles_raw) < min_need: return None if reject == "탈락-봉부족": return None self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "") return None if not sig: return None align_on = get_env_bool("MOMENTUM_LIVE_BACKTEST_ALIGN", True) entry_open = float(sig.get("entry_price", 0) or 0) if align_on and entry_open > 0: curr_price = entry_open else: latest = candles[-1] curr_price = float(latest["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 hard_cap = get_env_int("MOMENTUM_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, ) if rej: self.logger.info( "🔍 [탈락-%s] %s(%s) price=%.0f", rej, name, code, curr_price, ) return None eff_tp = me.resolve_effective_tp_pct(self.take_profit_pct, self.tp_max_pct) stop_price = curr_price * (1 + self.stop_loss_pct) target_price = curr_price * (1 + eff_tp) self.logger.info( "🎯 [MOMENTUM 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f", name, code, curr_price, qty, sig.get("rsi", 0), ) 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": {"rsi": sig.get("rsi", 0)}, } 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 = invest_qty_for_price(px, float(self.slot_money)) if qty < 1: return None eff_tp = me.resolve_effective_tp_pct(self.take_profit_pct, self.tp_max_pct) return { "code": code, "name": name, "price": px, "qty": qty, "stop_price": px * (1 + self.stop_loss_pct), "target_price": px * (1 + eff_tp), "atr_entry": 0.0, "size_class": "", "entry_features": {}, } def check_sell_signals(self) -> List[Dict]: if not self.holdings: return [] signals: List[Dict] = [] 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", ) params = dict(self._engine_params or me.get_momentum_defaults_from_db()) 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)) stop = float(holding.get("stop_price", 0)) target = float(holding.get("target_price", 0)) max_price = float(holding.get("max_price", buy_price)) 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 if current_price > max_price: max_price = current_price holding["max_price"] = max_price profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0 position = { "entry_price": buy_price, "entry_time": holding.get("buy_time", ""), "qty": qty, "stop": stop, "target": target, "max_price": max_price, } candle = { "high": max_price, "low": current_price, "close": current_price, "candle_time": now.strftime("%Y%m%d%H%M"), } res = me.check_sell_signal_momentum_live(position, candle, params, is_eod=is_eod) if not res: continue reason, exit_price = res 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)), "is_confirmed": c.get("is_confirmed", 1), }