345 lines
15 KiB
Python
345 lines
15 KiB
Python
"""
|
|
kis_trader/strategies/scalping.py — 스캘핑 전략 (1분봉 RSI 과매도 되돌림)
|
|
==========================================================================
|
|
- 신호 생성: ``scalping_engine.check_buy_signal_live`` / ``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:
|
|
import scalping_engine as se
|
|
except ImportError:
|
|
se = None
|
|
|
|
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, 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_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)
|
|
|
|
# ── 진입 모드 토글 ───────────────────────────────────────────
|
|
# reversal: 기존 RSI 과매도 V자 반전 (오리지널)
|
|
# momentum: 키움 'scalp' 조건검색(갭상승+신고가) 종목군 따라붙기
|
|
self.scalp_mode = (get_env_from_db("SCALP_MODE", "reversal") or "reversal").strip().lower()
|
|
if self.scalp_mode not in ("reversal", "momentum"):
|
|
self.logger.warning("SCALP_MODE='%s' 알 수 없음 → reversal 로 fallback", self.scalp_mode)
|
|
self.scalp_mode = "reversal"
|
|
|
|
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),
|
|
# 모멘텀 모드 전용 파라미터
|
|
"mom_rsi_min": get_env_float("SCALP_MOM_RSI_MIN", 50.0),
|
|
"mom_rsi_max": get_env_float("SCALP_MOM_RSI_MAX", 80.0),
|
|
"mom_vol_mult": get_env_float("SCALP_MOM_VOL_MULT", 1.5),
|
|
"mom_vol_win": get_env_int("SCALP_MOM_VOL_WIN", 5),
|
|
"mom_time_end_hm": get_env_int("SCALP_MOM_TIME_END_HM", 1430),
|
|
# 모멘텀 진입은 횟수 더 많이 (조건검색이 종목 거르므로 회전 빠름)
|
|
"max_daily": get_env_int("SCALP_MAX_DAILY",
|
|
_d.get("max_daily", 3) if self.scalp_mode != "momentum" else 5),
|
|
}
|
|
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 {}
|
|
if self.scalp_mode == "momentum":
|
|
reject, msg, sig = se.check_buy_signal_momentum_live(candles, params, state)
|
|
else:
|
|
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
|
|
|
|
# 포지션 크기 = 손실허용액 / 손절비율
|
|
max_loss_krw = get_env_int("SCALP_MAX_LOSS_PER_TRADE_KRW", 0) \
|
|
or get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000)
|
|
sl_pct = abs(self.scalp_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
|
|
|
|
# ── [하드캡] 종목당 최대 매수금액 상한 ────────────────────────
|
|
# 우선순위: SCALP 전용 > 공용 > 미설정(=무시)
|
|
# 현장에서 "손절%가 작으면 포지션이 비정상적으로 커지는" 문제 방지용.
|
|
hard_cap = get_env_int("SCALP_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.scalp_stop_loss_pct)
|
|
target_price = curr_price * (1 + self.scalp_take_profit_pct)
|
|
|
|
self.logger.info(
|
|
"🎯 [SCALP-%s 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f",
|
|
self.scalp_mode.upper(), 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 + self.scalp_take_profit_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,
|
|
})
|
|
|
|
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)),
|
|
}
|