""" kis_trader/strategies/scalping.py — 스캘핑 전략 (1분봉 RSI 과매도 되돌림 / Reversal 고정) ================================================================================== [전략 컨셉 — SCAN vs TRIGGER] - **SCAN (HTS ``CONDITION_SCALP_KIWOOM_NAME``=scalp_re)**: 낙폭+회복+거래대금 → ``kiwoom_condition`` WS. - **TRIGGER (코드)** — ``SCALP_SKIP_HTS_SCAN_DUPES=true`` (kiwoom_condition 기본): - HTS scalp_re SCAN 통과 후 **진입 타이밍만** (낙폭·RSI·되돌림 중복 생략). - ``SCALP_SKIP_HTS_SCAN_DUPES=false`` + reversal: RSI(3) 과매도 V자 + 되돌림. - ``SCALP_USE_MACD_CROSS=true``: MACD+Stochastic 골든크로스 (방어필터는 skip_hts 시 생략). - 청산: TP/SL/EOD/이동평균 이탈 등 (scalping_engine.check_sell_signal_live). - ⚠️ 모멘텀 추종은 MomentumStrategy 분리 (MACD 골든크로스 HTS 는 SCALP TRIGGER 로 이전). [엔진 함수 의존] - 진입 시그널: ``scalping_engine.check_buy_signal_live`` (백테스트 동일 로직) - 청산 시그널: ``scalping_engine.check_sell_signal_live`` (백테스트 동일 로직) [주문 실행] - ``OrderManager.place(OrderRequest(strategy_id="SCALP", ...))`` → ODNO 저장, 종목 Lock, 실 잔고 검증까지 한 번에 처리. """ from __future__ import annotations import time from datetime import datetime as dt from typing import Dict, List, Optional try: from ..engine import scalping_engine as se except ImportError: se = None from ..utils.env import get_env_bool, get_env_float, get_env_int from .base import BaseStrategy class ScalpingStrategy(BaseStrategy): strategy_id = "SCALP" loop_min_sleep = 1.0 loop_max_sleep = 2.0 def __init__(self, **kwargs): super().__init__(**kwargs) self.candle_tf = 1 # 1분봉 self._scan_engine_params: Optional[Dict] = None self.reload_config() # ------------------------------------------------------------------ def reload_config(self) -> None: """env_config 기반 파라미터 리로드 (루프 1회 당 1번).""" self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0) self.high_chase_thr = get_env_float("HIGH_CHASE_THR", 0.96) self.max_daily_chg = get_env_float("MAX_DAILY_CHG", 20.0) self.vol_multiplier = get_env_float("VOL_MULTIPLIER", 0.0) # 부호 무관하게 항상 손절은 음수, 익절은 양수로 정규화 # (DB에 0.012/-0.012 어느 쪽이 들어와도 stop_price 가 매수가 아래로 잡히도록.) self.scalp_stop_loss_pct = -abs(get_env_float("SCALP_STOP_LOSS_PCT", 0.015)) self.scalp_take_profit_pct = abs(get_env_float("SCALP_TAKE_PROFIT_PCT", 0.015)) self.scalp_tp_max_pct = abs(get_env_float("SCALP_TP_MAX_PCT", 0.02)) self.scalp_min_drop_rate = get_env_float("SCALP_MIN_DROP_RATE", 0.015) self.atr_down_mult = get_env_float("ATR_DOWN_MULT", 1.5) self.rsi_oversold = get_env_float("SCALP_RSI_OVERSOLD", 25.0) self.rsi_overbought = get_env_float("SCALP_RSI_OVERBOUGHT", 75.0) self.slot_money = get_env_int("SLOT_MONEY_DEFAULT", 3000000) if se is not None: try: _d = se.get_scalping_defaults_from_db() self._scan_engine_params = { **_d, "rsi_oversold": self.rsi_oversold, "rsi_overbought": self.rsi_overbought, "sl_pct": abs(self.scalp_stop_loss_pct), "tp_pct": self.scalp_take_profit_pct, "drop_rate": self.scalp_min_drop_rate, "vol_mult": self.vol_multiplier if self.vol_multiplier > 0 else 0, "require_reversal_candle": get_env_bool( "SCALP_REQUIRE_REVERSAL_CANDLE", True, ), # True: 백테스트와 동일 — 신호봉(직전 확정봉) 조건 충족 시 현재봉에서 진입 "live_backtest_align": get_env_bool( "SCALP_LIVE_BACKTEST_ALIGN", True, ), "live_signal_lookback_bars": get_env_int( "SCALP_LIVE_SIGNAL_LOOKBACK_BARS", 1, ), # 일일 진입 횟수 (reversal 기본 3회) "max_daily": get_env_int( "SCALP_MAX_DAILY", _d.get("max_daily", 3), ), "use_macd_cross": get_env_bool("SCALP_USE_MACD_CROSS", False), "macd_fast": get_env_int("SCALP_MACD_FAST", 12), "macd_slow": get_env_int("SCALP_MACD_SLOW", 26), "macd_signal": get_env_int("SCALP_MACD_SIGNAL", 5), "stoch_k_period": get_env_int("SCALP_STOCH_K_PERIOD", 5), "stoch_d_period": get_env_int("SCALP_STOCH_D_PERIOD", 3), "stoch_slow": get_env_int("SCALP_STOCH_SLOW", 3), # get_scalping_defaults_from_db() 가 이미 DB SCALP_SKIP_HTS_SCAN_DUPES 를 # skip_hts_scan_dupes bool 로 해석함. resolve(_d) 금지: # _d 에는 SCALP_SKIP_* env 키가 없어 universe fallback → 항상 True 가 됨. "skip_hts_scan_dupes": bool( _d.get( "skip_hts_scan_dupes", se.resolve_scalp_skip_hts_scan_dupes(), ), ), } except Exception as e: self.logger.debug("scalping_engine defaults 조회 실패: %s", e) def _candidate_filter(self, candidate: Dict) -> bool: """scalp_on 이 True 인 후보만 대상.""" return bool(candidate.get("scalp_on", True)) # ------------------------------------------------------------------ # 매수 # ------------------------------------------------------------------ def check_buy(self, code: str, name: str) -> Optional[Dict]: if se is None: self.logger.warning("scalping_engine 미탑재 → 매수 체크 스킵") return None try: if get_env_bool("FORCE_BUY_TEST", False): return self._force_buy_test(code, name) candles_raw = self.ws.get_candles(code, self.candle_tf, n=50) if len(candles_raw) < 5: return None candles = [self._norm_candle(c) for c in candles_raw] # 엔진 state 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("SCALP") ]) except Exception: daily_cnt = 0 state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt} params = self._scan_engine_params or {} # SCALP 는 reversal 단일 모드 (momentum 은 MomentumStrategy 로 분리됨) reject, msg, sig = se.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 latest = candles[-1] curr_price = float(latest["close"]) if curr_price < self.min_price: return None # 현재가 보정 (WS → 없으면 REST) 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: return None hard_cap = get_env_int("SCALP_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.scalp_stop_loss_pct) eff_tp = se.resolve_effective_tp_pct( self.scalp_take_profit_pct, self.scalp_tp_max_pct, ) target_price = curr_price * (1 + eff_tp) self.logger.info( "🎯 [SCALP-REVERSAL 시그널] %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 = max(1, int(self.slot_money / px)) return { "code": code, "name": name, "price": px, "qty": qty, "stop_price": px * (1 + self.scalp_stop_loss_pct), "target_price": px * (1 + se.resolve_effective_tp_pct( self.scalp_take_profit_pct, self.scalp_tp_max_pct, )), "atr_entry": 0.0, "size_class": "", "entry_features": {}, } # ------------------------------------------------------------------ # 매도 # ------------------------------------------------------------------ def check_sell_signals(self) -> List[Dict]: """엔진 check_sell_signal_live 사용 (백테스트 동일).""" if not self.holdings: return [] if se is None: return [] signals: List[Dict] = [] now = dt.now() is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15 try: params = se.get_scalping_defaults_from_db() except Exception: params = {} params.update({ "max_loss_krw": float( get_env_int("SCALP_MAX_LOSS_PER_TRADE_KRW", 0) or get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000) ), "min_drop_pct_for_loss_cut": get_env_float( "SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", 0.015 ), "fee_rate": get_env_float("FEE_RATE_PCT", 0.015) / 100, "sell_tax": get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100, "min_margin": get_env_float("SCALP_MIN_PROFIT_PCT", 0.2) / 100, "shoulder_min_high": float(params.get("shoulder_min_high", 0.005)), "shoulder_cut_pct": float(params.get("shoulder_cut_pct", 0.003)), "min_hold_sec": float(get_env_int("SCALP_MIN_HOLD_SEC", 30)), }) 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 # 현재가 (WS → REST) 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 업데이트 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 = se.check_sell_signal_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)), }