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:
291
kis_trader/strategies/range_break.py
Normal file
291
kis_trader/strategies/range_break.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
kis_trader/strategies/range_break.py — 박스권 돌파(RANGE_BREAK) 실매 전략
|
||||
====================================================================
|
||||
[SCAN] HTS momentum 조건(F·G·J) — 오늘 거래 활발 종목 풀
|
||||
[TRIGGER] 횡보 박스 → 거래량 폭발 양봉으로 박스 상단 돌파 시 진입 (10:30~15:20)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime as dt
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ..engine import range_break_engine as rbe
|
||||
from ..utils.env import get_env_bool, get_env_int
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class RangeBreakStrategy(BaseStrategy):
|
||||
"""박스권 돌파 — 오후 횡보 후 squeeze 돌파."""
|
||||
|
||||
strategy_id = "RANGE_BREAK"
|
||||
loop_min_sleep = 1.0
|
||||
loop_max_sleep = 2.0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.candle_tf = 1
|
||||
self._engine_params: Optional[Dict] = None
|
||||
self.reload_config()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
try:
|
||||
base = rbe.get_range_break_defaults_from_db()
|
||||
base.update({
|
||||
"stop_loss_pct": -abs(float(base.get("sl_pct", 0.03))),
|
||||
"take_profit_pct": abs(float(base.get("tp_pct", 0.10))),
|
||||
"trail_pct": abs(float(base.get("trail_pct", 0.015))),
|
||||
"trail_arm_pct": abs(float(base.get("trail_arm_pct", 0.015))),
|
||||
"shoulder_min_high": abs(float(base.get("shoulder_min_high", 0.03))),
|
||||
"shoulder_cut_pct": abs(float(base.get("shoulder_cut_pct", 0.005))),
|
||||
})
|
||||
self._engine_params = base
|
||||
self.min_price = float(base.get("min_price", 1000.0))
|
||||
self.stop_loss_pct = -abs(float(base.get("sl_pct", 0.03)))
|
||||
self.take_profit_pct = abs(float(base.get("tp_pct", 0.10)))
|
||||
self.slot_money = int(base.get("slot_money", 200_000))
|
||||
self.time_start_hm = int(base.get("time_start_hm", 1030))
|
||||
self.time_end_hm = int(base.get("time_end_hm", 1520))
|
||||
self.max_daily = int(base.get("max_daily", 1))
|
||||
except Exception as e:
|
||||
self.logger.debug("range_break_engine defaults 조회 실패: %s", e)
|
||||
self._engine_params = {}
|
||||
|
||||
def _in_trade_window(self) -> bool:
|
||||
now = dt.now()
|
||||
hm = now.hour * 100 + now.minute
|
||||
return self.time_start_hm <= hm < self.time_end_hm
|
||||
|
||||
def _candidate_filter(self, candidate: Dict) -> bool:
|
||||
if not candidate.get("code"):
|
||||
return False
|
||||
if not self._in_trade_window():
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_buy(self, code: str, name: str) -> Optional[Dict]:
|
||||
if not self._in_trade_window():
|
||||
return None
|
||||
|
||||
try:
|
||||
if get_env_bool("FORCE_BUY_TEST", False):
|
||||
return self._force_buy_test(code, name)
|
||||
|
||||
need_n = rbe.range_break_min_bars_required(self._engine_params or {})
|
||||
candles_raw = self.ws.get_candles(code, self.candle_tf, n=need_n + 10)
|
||||
if len(candles_raw) < need_n - 2:
|
||||
return None
|
||||
candles = [self._norm_candle(c) for c in candles_raw]
|
||||
|
||||
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", "")).upper().startswith("RANGE_BREAK")
|
||||
])
|
||||
except Exception:
|
||||
daily_cnt = 0
|
||||
state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}
|
||||
|
||||
params = dict(self._engine_params or {})
|
||||
if getattr(self.ws, "get_share_denom", None):
|
||||
params["share_denom"] = float(self.ws.get_share_denom(code))
|
||||
|
||||
reject, msg, sig = rbe.check_buy_signal_range_break_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
|
||||
|
||||
align_on = get_env_bool("RANGE_BREAK_LIVE_BACKTEST_ALIGN", True)
|
||||
entry_open = float(sig.get("entry_price", 0) or 0)
|
||||
if align_on and entry_open > 0:
|
||||
curr_price = entry_open
|
||||
else:
|
||||
curr_price = float(sig.get("close") or candles[-1]["close"])
|
||||
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 or curr_price < self.min_price:
|
||||
return None
|
||||
|
||||
hard_cap = get_env_int("RANGE_BREAK_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.stop_loss_pct)
|
||||
target_price = curr_price * (1 + self.take_profit_pct)
|
||||
box_high = float(sig.get("box_high", 0) or 0)
|
||||
|
||||
self.logger.info(
|
||||
"📦 [RANGE_BREAK] %s(%s) price=%.0f qty=%d box=%.0f~%.0f volX=%.1f",
|
||||
name, code, curr_price, qty,
|
||||
float(sig.get("box_low", 0) or 0), box_high,
|
||||
float(sig.get("vol_ratio", 0) or 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": {
|
||||
"box_high": box_high,
|
||||
"box_low": float(sig.get("box_low", 0) or 0),
|
||||
"box_width_pct": sig.get("box_width_pct", 0),
|
||||
"vol_ratio": sig.get("vol_ratio", 0),
|
||||
"box_stop_line": box_high,
|
||||
},
|
||||
}
|
||||
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.stop_loss_pct),
|
||||
"target_price": px * (1 + self.take_profit_pct),
|
||||
"atr_entry": 0.0,
|
||||
"size_class": "",
|
||||
"entry_features": {},
|
||||
}
|
||||
|
||||
def check_sell_signals(self) -> List[Dict]:
|
||||
if not self.holdings:
|
||||
return []
|
||||
|
||||
signals: List[Dict] = []
|
||||
now = dt.now()
|
||||
is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15
|
||||
params = dict(self._engine_params or rbe.get_range_break_defaults_from_db())
|
||||
params.update({
|
||||
"stop_loss_pct": self.stop_loss_pct,
|
||||
"take_profit_pct": self.take_profit_pct,
|
||||
})
|
||||
|
||||
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))
|
||||
max_price = float(holding.get("max_price", buy_price))
|
||||
if qty <= 0 or buy_price <= 0:
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
if current_price > max_price:
|
||||
max_price = current_price
|
||||
holding["max_price"] = max_price
|
||||
|
||||
ef = holding.get("entry_features") or {}
|
||||
box_stop = float(ef.get("box_stop_line", ef.get("box_high", 0)) or 0)
|
||||
|
||||
position = {
|
||||
"entry_price": buy_price,
|
||||
"entry_time": holding.get("buy_time", ""),
|
||||
"qty": qty,
|
||||
"max_price": max_price,
|
||||
"box_stop_line": box_stop,
|
||||
}
|
||||
candle = {
|
||||
"high": max_price,
|
||||
"low": current_price,
|
||||
"close": current_price,
|
||||
"candle_time": now.strftime("%Y%m%d%H%M"),
|
||||
}
|
||||
|
||||
res = rbe.check_sell_signal_range_break_live(
|
||||
position, candle, params, is_eod=is_eod,
|
||||
)
|
||||
if not res:
|
||||
continue
|
||||
reason, exit_price = res
|
||||
profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
|
||||
signals.append({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"current_price": current_price,
|
||||
"price": exit_price,
|
||||
"qty": qty,
|
||||
"reason": reason,
|
||||
"profit_pct": profit_pct,
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.debug("RANGE_BREAK 매도체크 오류 %s: %s", code, e)
|
||||
return signals
|
||||
|
||||
|
||||
def run_range_break_backtest(*args, **kwargs):
|
||||
return rbe.run_range_break_backtest(*args, **kwargs)
|
||||
|
||||
|
||||
def range_break_ui_to_engine_params(ui: Dict):
|
||||
return rbe.range_break_ui_to_engine_params(ui)
|
||||
Reference in New Issue
Block a user