전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다. 틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다. Co-authored-by: Cursor <cursoragent@cursor.com>
452 lines
20 KiB
Python
452 lines
20 KiB
Python
"""
|
||
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_from_db, get_env_int
|
||
from .base import BaseStrategy, is_live_eod_now
|
||
|
||
|
||
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)
|
||
# 실매↔BT 공통 EOD (기존 하드코딩 15:25 → env)
|
||
self.eod_enabled = get_env_bool("SCALP_EOD_ENABLED", True)
|
||
self.eod_hm = get_env_from_db("SCALP_EOD_HM", "15:25")
|
||
|
||
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: 백테스트와 동일 — 신호봉(T−1) 충족 시 진입봉(T) 시가/첫 틱
|
||
# DB 빈문자("") 는 get_env_bool 이 False 로 읽히므로 defaults 우선
|
||
"live_backtest_align": bool(_d.get("live_backtest_align", True)),
|
||
"live_signal_lookback_bars": int(
|
||
_d.get("live_signal_lookback_bars", 1) or 1,
|
||
),
|
||
# 형성 중 봉(T)을 진입봉으로 — 확정봉만 쓰면 1봉 지연 (모멘텀과 동일)
|
||
"live_align_use_forming_bar": bool(
|
||
_d.get("live_align_use_forming_bar", True),
|
||
),
|
||
# 일일 진입 횟수 (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
|
||
_cb = self._cb_prof_start(code)
|
||
try:
|
||
if get_env_bool("FORCE_BUY_TEST", False):
|
||
return self._force_buy_test(code, name)
|
||
|
||
candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=50) or [])
|
||
# ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio next open/첫 틱)와 동일 시점
|
||
params = self._scan_engine_params or {}
|
||
use_forming = bool(params.get("live_align_use_forming_bar", True))
|
||
if params.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)
|
||
self._cb_prof_mark(_cb, "candles")
|
||
if len(candles_raw) < 5:
|
||
force_sec = max(30, get_env_int("SCALP_CANDLE_GAP_FORCE_SEC", 120))
|
||
now_g = time.time()
|
||
if not hasattr(self, "_candle_gap_force_ts"):
|
||
self._candle_gap_force_ts = {}
|
||
last_g = float(self._candle_gap_force_ts.get(code, 0) or 0)
|
||
do_force = (now_g - last_g) >= float(force_sec)
|
||
if do_force:
|
||
self._candle_gap_force_ts[code] = now_g
|
||
try:
|
||
self.ws.fill_gap([code], force=do_force)
|
||
except Exception:
|
||
pass
|
||
self._cb_prof_mark(_cb, "fill_gap")
|
||
log_sec = max(15, get_env_int("SCALP_CANDLE_SHORT_LOG_SEC", 60))
|
||
if not hasattr(self, "_candle_short_log_ts"):
|
||
self._candle_short_log_ts = {}
|
||
last_l = float(self._candle_short_log_ts.get(code, 0) or 0)
|
||
if now_g - last_l >= float(log_sec):
|
||
self._candle_short_log_ts[code] = now_g
|
||
self.logger.info(
|
||
"🔍 [캔들부족] %s(%s) need>=5 have=%d",
|
||
name, code, len(candles_raw),
|
||
)
|
||
return None
|
||
candles = [self._norm_candle(c) for c in candles_raw]
|
||
self._cb_prof_mark(_cb, "norm")
|
||
|
||
# 엔진 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._get_today_trades(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
|
||
self._cb_prof_mark(_cb, "trades_db")
|
||
state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}
|
||
|
||
# SCALP 는 reversal 단일 모드 (momentum 은 MomentumStrategy 로 분리됨)
|
||
# 호가/프로그램/휩쏘 — 모멘텀·꼬리·돌파와 동일하게 WS 주입
|
||
# (미주입 시 orderbook_reject 가 스냅 None → 수집·필터 둘 다 스킵)
|
||
params = dict(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 = se.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
|
||
|
||
# 중분 편입 → 같은 진입봉 시가 매수 보류 (다음 분부터)
|
||
_defer = self._defer_mid_enroll_entry(
|
||
code, sig.get("entry_bar_key"), int(self.candle_tf or 1), params,
|
||
)
|
||
self._cb_prof_mark(_cb, "mid_enroll")
|
||
if _defer:
|
||
self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code)
|
||
return None
|
||
|
||
# 진입 계산가: align 시 T봉 첫 틱(RAM) → 없으면 시가. 폴백만 WS 현재가
|
||
align_on = bool(params.get("live_backtest_align", True))
|
||
entry_open = float(sig.get("entry_price", 0) or 0)
|
||
entry_src = "ohlc_open"
|
||
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=str(sig.get("entry_bar_key") or "")[:12],
|
||
tf_min=int(self.candle_tf or 1),
|
||
)
|
||
else:
|
||
latest = candles[-1]
|
||
curr_price = float(latest["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("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,
|
||
)
|
||
self._cb_prof_mark(_cb, "qty")
|
||
if rej:
|
||
self._scan_log(
|
||
"info", code,
|
||
"🔍 [탈락-%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 entry_src=%s",
|
||
name, code, curr_price, qty, sig.get("rsi", 0), entry_src,
|
||
)
|
||
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._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.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, only_code: Optional[str] = None) -> List[Dict]:
|
||
"""엔진 check_sell_signal_live 사용 (백테스트 동일)."""
|
||
if not self.holdings:
|
||
return []
|
||
if se is None:
|
||
return []
|
||
|
||
signals: List[Dict] = []
|
||
now = dt.now()
|
||
is_eod = is_live_eod_now(
|
||
getattr(self, "eod_enabled", True),
|
||
getattr(self, "eod_hm", "15:25"),
|
||
now,
|
||
default_hm="15:25",
|
||
)
|
||
|
||
try:
|
||
cached = getattr(self, "_scan_engine_params", None) or {}
|
||
if cached:
|
||
params = dict(cached)
|
||
else:
|
||
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)),
|
||
"eod_enabled": getattr(self, "eod_enabled", True),
|
||
"eod_hm": getattr(self, "eod_hm", "15:25"),
|
||
})
|
||
|
||
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))
|
||
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 · EOD는 매수가 폴백)
|
||
current_price = self._resolve_sell_price(
|
||
code, is_eod=is_eod, buy_price=buy_price,
|
||
)
|
||
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)),
|
||
}
|