Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""
|
|
kis_trader/strategies/momentum.py — 모멘텀 전략 (1분봉 추세추격)
|
|
================================================================
|
|
스캘핑 reversal(SCALP)과 완전 분리 — ``momentum_engine`` 전용.
|
|
|
|
[SCAN vs TRIGGER]
|
|
- SCAN: HTS/KIS ``scalp`` 조건검색 → target_candidates_history
|
|
- TRIGGER: 양봉, RSI 강세, 거래량 spike, 방어필터 (고점추격·급등·시가위치)
|
|
|
|
[엔진]
|
|
- 진입: ``momentum_engine.check_buy_signal_momentum_live``
|
|
- 청산: ``momentum_engine.check_sell_signal_momentum_live`` (어깨·트레일 선행, tp_max 상한 익절 마지막)
|
|
- 백테: ``momentum_engine.run_momentum_backtest`` / ``check_sell_signal_momentum_backtest_bar``
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime as dt
|
|
from typing import Dict, List, Optional
|
|
|
|
from ..engine import momentum_engine as me
|
|
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
|
|
from ..utils.position_sizing import invest_qty_for_price
|
|
from .base import BaseStrategy, is_live_eod_now
|
|
|
|
|
|
class MomentumStrategy(BaseStrategy):
|
|
"""1분봉 단기 추세추격 — SCALP reversal 과 청산·진입 모두 분리."""
|
|
|
|
strategy_id = "MOMENTUM"
|
|
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:
|
|
"""env_config → momentum_engine params."""
|
|
try:
|
|
base = me.get_momentum_defaults_from_db()
|
|
base.update({
|
|
"fee_rate": get_env_float("FEE_RATE_PCT", 0.015) / 100,
|
|
"sell_tax": get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100,
|
|
})
|
|
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.015)))
|
|
self.take_profit_pct = abs(float(base.get("tp_pct", 0.025)))
|
|
self.tp_max_pct = abs(float(base.get("tp_max_pct", 0.02)))
|
|
self.slot_money = int(base.get("slot_money", 3_000_000))
|
|
self.mom_rsi_min = float(base.get("mom_rsi_min", 50.0))
|
|
self.mom_rsi_max = float(base.get("mom_rsi_max", 80.0))
|
|
self.max_daily = int(base.get("max_daily", 5))
|
|
self.eod_enabled = get_env_bool("MOMENTUM_EOD_ENABLED", True)
|
|
self.eod_hm = get_env_from_db("MOMENTUM_EOD_HM", "15:25")
|
|
except Exception as e:
|
|
self.logger.debug("momentum_engine defaults 조회 실패: %s", e)
|
|
self._engine_params = {}
|
|
|
|
def _candidate_filter(self, candidate: Dict) -> bool:
|
|
return bool(candidate.get("scalp_on", True))
|
|
|
|
def check_buy(self, code: str, name: str) -> Optional[Dict]:
|
|
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]
|
|
|
|
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() == "MOMENTUM"
|
|
])
|
|
except Exception:
|
|
daily_cnt = 0
|
|
state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}
|
|
|
|
params = dict(self._engine_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 = me.check_buy_signal_momentum_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("MOMENTUM_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:
|
|
latest = candles[-1]
|
|
curr_price = float(latest["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("MOMENTUM_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
|
|
|
|
eff_tp = me.resolve_effective_tp_pct(self.take_profit_pct, self.tp_max_pct)
|
|
stop_price = curr_price * (1 + self.stop_loss_pct)
|
|
target_price = curr_price * (1 + eff_tp)
|
|
|
|
self.logger.info(
|
|
"🎯 [MOMENTUM 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f",
|
|
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 = invest_qty_for_price(px, float(self.slot_money))
|
|
if qty < 1:
|
|
return None
|
|
eff_tp = me.resolve_effective_tp_pct(self.take_profit_pct, self.tp_max_pct)
|
|
return {
|
|
"code": code,
|
|
"name": name,
|
|
"price": px,
|
|
"qty": qty,
|
|
"stop_price": px * (1 + self.stop_loss_pct),
|
|
"target_price": px * (1 + eff_tp),
|
|
"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 = is_live_eod_now(
|
|
getattr(self, "eod_enabled", True),
|
|
getattr(self, "eod_hm", "15:25"),
|
|
now,
|
|
default_hm="15:25",
|
|
)
|
|
params = dict(self._engine_params or me.get_momentum_defaults_from_db())
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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 = me.check_sell_signal_momentum_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)),
|
|
"is_confirmed": c.get("is_confirmed", 1),
|
|
}
|