Files
kis_bot/kis_trader/strategies/scalping.py
Your Name bc2b1b642c feat(execution): AccountOrderWorker로 매수·매도 주문 직렬화
전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다.
틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:45:26 +09:00

452 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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: 백테스트와 동일 — 신호봉(T1) 충족 시 진입봉(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)),
}