""" kis_trader/strategies/range_break.py — 박스권 돌파(RANGE_BREAK) 실매 전략 ==================================================================== [SCAN] HTS momentum 조건(F·G·J) — 오늘 거래 활발 종목 풀 [TRIGGER] 횡보 박스 → 거래량 폭발 양봉으로 박스 상단 돌파 시 진입 (10:30~15:20) """ from __future__ import annotations from datetime import datetime as dt from typing import Dict, List, Optional from ..engine import range_break_engine as rbe from ..utils.env import get_env_bool, get_env_int from .base import BaseStrategy class RangeBreakStrategy(BaseStrategy): """박스권 돌파 — 오후 횡보 후 squeeze 돌파.""" strategy_id = "RANGE_BREAK" 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: try: base = rbe.get_range_break_defaults_from_db() base.update({ "stop_loss_pct": -abs(float(base.get("sl_pct", 0.03))), "take_profit_pct": abs(float(base.get("tp_pct", 0.10))), "trail_pct": abs(float(base.get("trail_pct", 0.015))), "trail_arm_pct": abs(float(base.get("trail_arm_pct", 0.015))), "shoulder_min_high": abs(float(base.get("shoulder_min_high", 0.03))), "shoulder_cut_pct": abs(float(base.get("shoulder_cut_pct", 0.005))), }) 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.03))) self.take_profit_pct = abs(float(base.get("tp_pct", 0.10))) self.slot_money = int(base.get("slot_money", 200_000)) self.time_start_hm = int(base.get("time_start_hm", 1030)) self.time_end_hm = int(base.get("time_end_hm", 1520)) self.max_daily = int(base.get("max_daily", 1)) except Exception as e: self.logger.debug("range_break_engine defaults 조회 실패: %s", e) self._engine_params = {} def _in_trade_window(self) -> bool: now = dt.now() hm = now.hour * 100 + now.minute return self.time_start_hm <= hm < self.time_end_hm def _candidate_filter(self, candidate: Dict) -> bool: if not candidate.get("code"): return False if not self._in_trade_window(): return False return True def check_buy(self, code: str, name: str) -> Optional[Dict]: if not self._in_trade_window(): return None try: if get_env_bool("FORCE_BUY_TEST", False): return self._force_buy_test(code, name) need_n = rbe.range_break_min_bars_required(self._engine_params or {}) candles_raw = self.ws.get_candles(code, self.candle_tf, n=need_n + 10) if len(candles_raw) < need_n - 2: try: 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 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().startswith("RANGE_BREAK") ]) except Exception: daily_cnt = 0 state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt} params = dict(self._engine_params or {}) if getattr(self.ws, "get_share_denom", None): params["share_denom"] = float(self.ws.get_share_denom(code)) reject, msg, sig = rbe.check_buy_signal_range_break_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 _ebk = str(sig.get("entry_bar_key") or "")[:12] _defer = self._defer_mid_enroll_entry( code, _ebk, int(getattr(self, "candle_tf", 1) or 1), params, ) if _defer: self.logger.info("🔍 [%s] %s(%s)", _defer, name, code) return None align_on = get_env_bool("RANGE_BREAK_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: curr_price = float(sig.get("close") or 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 if curr_price <= 0 or curr_price < self.min_price: return None hard_cap = get_env_int("RANGE_BREAK_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 stop_price = curr_price * (1 + self.stop_loss_pct) target_price = curr_price * (1 + self.take_profit_pct) box_high = float(sig.get("box_high", 0) or 0) self.logger.info( "📦 [RANGE_BREAK] %s(%s) price=%.0f qty=%d box=%.0f~%.0f volX=%.1f", name, code, curr_price, qty, float(sig.get("box_low", 0) or 0), box_high, float(sig.get("vol_ratio", 0) or 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": { "box_high": box_high, "box_low": float(sig.get("box_low", 0) or 0), "box_width_pct": sig.get("box_width_pct", 0), "vol_ratio": sig.get("vol_ratio", 0), "box_stop_line": box_high, }, } 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_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: return [] signals: List[Dict] = [] now = dt.now() is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15 params = dict(self._engine_params or rbe.get_range_break_defaults_from_db()) params.update({ "stop_loss_pct": self.stop_loss_pct, "take_profit_pct": self.take_profit_pct, }) 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: continue current_price = self._resolve_sell_price( code, is_eod=is_eod, buy_price=buy_price, ) if current_price <= 0: continue if current_price > max_price: max_price = current_price holding["max_price"] = max_price ef = holding.get("entry_features") or {} box_stop = float(ef.get("box_stop_line", ef.get("box_high", 0)) or 0) position = { "entry_price": buy_price, "entry_time": holding.get("buy_time", ""), "qty": qty, "max_price": max_price, "box_stop_line": box_stop, } candle = { "high": max_price, "low": current_price, "close": current_price, "candle_time": now.strftime("%Y%m%d%H%M"), } res = rbe.check_sell_signal_range_break_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, "reason": reason, "profit_pct": profit_pct, }) except Exception as e: self.logger.debug("RANGE_BREAK 매도체크 오류 %s: %s", code, e) return signals def run_range_break_backtest(*args, **kwargs): return rbe.run_range_break_backtest(*args, **kwargs) def range_break_ui_to_engine_params(ui: Dict): return rbe.range_break_ui_to_engine_params(ui)