feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가

변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -1,10 +1,26 @@
"""
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, 실 잔고 검증까지 한 번에 처리
kis_trader/strategies/scalping.py — 스캘핑 전략 (1분봉 RSI 과매도 되돌림 / Reversal 고정)
==================================================================================
[전략 컨셉 — SCAN vs TRIGGER]
- **SCAN (HTS/랭킹, 널넬하게)**: K/L/제외 또는 거래량 랭킹 → 감시 대상만 선정.
- **TRIGGER (코드, 엄격하게)** — ``SCALP_USE_MACD_CROSS`` 로 선택:
- **false (기본)**: RSI(3) 과매도(<25) → 양봉 전환 V자 반등 ("바닥잡기").
- **true**: HTS C — MACD+Stochastic [12,26,5,3,3] 골든크로스 (0봉전 상향돌파).
- 청산: 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`` (백테스트 동일 로직)
[Deprecated]
- 과거 ``SCALP_MODE`` 토글(reversal/momentum) 은 폐기되었습니다.
``SCALP_MODE=momentum`` 이 DB 에 남아있으면 시작 시 1회 경고 + reversal 폴백.
추세추종(모멘텀) 전략은 ``STRATEGY_MOMENTUM_ENABLED=true`` 로 별도 켜세요.
[주문 실행]
- ``OrderManager.place(OrderRequest(strategy_id="SCALP", ...))``
→ ODNO 저장, 종목 Lock, 실 잔고 검증까지 한 번에 처리.
"""
from __future__ import annotations
@@ -13,7 +29,7 @@ from datetime import datetime as dt
from typing import Dict, List, Optional
try:
import scalping_engine as se
from ..engine import scalping_engine as se
except ImportError:
se = None
@@ -30,6 +46,8 @@ class ScalpingStrategy(BaseStrategy):
super().__init__(**kwargs)
self.candle_tf = 1 # 1분봉
self._scan_engine_params: Optional[Dict] = None
# SCALP_MODE deprecate 경고는 시작 시 1회만 (루프마다 reload_config 호출돼 스팸 방지)
self._deprecation_warned: bool = False
self.reload_config()
# ------------------------------------------------------------------
@@ -44,6 +62,7 @@ class ScalpingStrategy(BaseStrategy):
# (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)
@@ -51,13 +70,18 @@ class ScalpingStrategy(BaseStrategy):
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"
# ── SCALP_MODE deprecate 가드 ────────────────────────────────
# 과거에 reversal/momentum 토글이 있었지만, 모멘텀은 MomentumStrategy 로 분리됨.
# SCALP 는 항상 reversal 로 동작. DB 에 'momentum' 이 남아있으면 시작 시 1회 경고.
legacy_mode = (get_env_from_db("SCALP_MODE", "reversal") or "reversal").strip().lower()
if legacy_mode == "momentum" and not self._deprecation_warned:
self.logger.warning(
"⚠️ [DEPRECATED] SCALP_MODE=momentum 은 더 이상 지원되지 않습니다. "
"SCALP 는 reversal 로 고정 동작합니다. "
"추세추종을 원하시면 STRATEGY_MOMENTUM_ENABLED=true 로 MomentumStrategy 를 켜주세요."
)
self._deprecation_warned = True
self.scalp_mode = "reversal" # 고정
if se is not None:
try:
@@ -70,16 +94,27 @@ class ScalpingStrategy(BaseStrategy):
"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),
"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),
}
except Exception as e:
self.logger.debug("scalping_engine defaults 조회 실패: %s", e)
@@ -125,10 +160,8 @@ class ScalpingStrategy(BaseStrategy):
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)
# 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
@@ -150,36 +183,27 @@ class ScalpingStrategy(BaseStrategy):
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:
qty, rej = self._resolve_buy_qty_live(
curr_price, hard_cap=hard_cap,
)
if rej:
self.logger.info(
"💰 [투자금 상한 적용] %s: %s원 → %s원 (cap=%s)",
code, f"{int(invest_amount):,}", f"{hard_cap:,}", f"{hard_cap:,}",
"🔍 [탈락-%s] %s(%s) price=%.0f",
rej, name, code, curr_price,
)
invest_amount = hard_cap
qty = max(1, int(invest_amount / curr_price))
return None
stop_price = curr_price * (1 + self.scalp_stop_loss_pct)
target_price = curr_price * (1 + self.scalp_take_profit_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-%s 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f",
self.scalp_mode.upper(), name, code, curr_price, qty, sig.get("rsi", 0),
"🎯 [SCALP-REVERSAL 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f",
name, code, curr_price, qty, sig.get("rsi", 0),
)
return {
"code": code,
@@ -220,7 +244,9 @@ class ScalpingStrategy(BaseStrategy):
"price": px,
"qty": qty,
"stop_price": px * (1 + self.scalp_stop_loss_pct),
"target_price": px * (1 + self.scalp_take_profit_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": {},
@@ -255,6 +281,9 @@ class ScalpingStrategy(BaseStrategy):
"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()):