ㅇ Changes: - Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework. - Updated the database schema to include DART-specific tables for disclosures and watchlists. - Enhanced the backtesting and parameter search functionalities to support the DART strategy. - Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy. Impact: - These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""
|
|
kis_trader/strategies/momentum.py — MOMENTUM A안 (HTS momentum E∧F∧H∧I 돌파·주도주 추격)
|
|
================================================================
|
|
스캘핑 reversal(SCALP)과 완전 분리 — ``momentum_engine`` 전용.
|
|
|
|
[SCAN vs TRIGGER vs 청산]
|
|
- SCAN: 키움 ``momentum`` 조건검색 → target_candidates_history
|
|
- TRIGGER: ``MOMENTUM_SKIP_HTS_SCAN_DUPES=true`` (kiwoom 기본) 시 E·양봉·거래량 중복 생략, 진입 타이밍만
|
|
- 청산: 래칫·어깨·트레일·손절·시간컷 (``momentum_hts_logic``)
|
|
|
|
[엔진]
|
|
- 진입: ``momentum_engine.check_buy_signal_momentum_live``
|
|
- 청산: ``momentum_engine.check_sell_signal_momentum_live``
|
|
- 백테: ``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:20")
|
|
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 _reentry_cooldown_sec(self) -> int:
|
|
# wall-clock 이중 게이트 제거 — 엔진 cooldown_min(신호봉 시계)만 사용 (BT 정합)
|
|
if bool((self._engine_params or {}).get("cooldown_engine_only", True)):
|
|
return 0
|
|
return super()._reentry_cooldown_sec()
|
|
|
|
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)
|
|
|
|
# E조건(전일시가) — 당일 50봉만으로는 불가 → 키움 REST 갭보정 RAM (DB 구데이터 미사용)
|
|
min_need = get_env_int("MOMENTUM_LIVE_MIN_CANDLES", 500)
|
|
candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=min_need) or [])
|
|
# ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio)와 동일 시점
|
|
use_forming = bool((self._engine_params or {}).get("live_align_use_forming_bar", True))
|
|
if (self._engine_params or {}).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)
|
|
if len(candles_raw) < 6:
|
|
try:
|
|
# force: EXIT 후 _gap_filled 잔존 시에도 재채움 (봉부족 복구)
|
|
self.ws.fill_gap([code], force=True)
|
|
except Exception:
|
|
pass
|
|
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
|
|
# 분 단위 floor — 엔진 쿨다운이 신호봉 candle_time 과 같은 시계를 쓰도록
|
|
elif bool((self._engine_params or {}).get("cooldown_use_candle_floor", True)):
|
|
last_exit_dt = last_exit_dt.replace(second=0, microsecond=0)
|
|
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:
|
|
# 갭보정 워밍업 중 — 전일시가 없음·봉부족 시 force 재큐 (로그 스팸 전에 복구)
|
|
if reject in ("탈락-전일시가없음", "탈락-봉부족"):
|
|
try:
|
|
self.ws.fill_gap([code], force=True)
|
|
except Exception:
|
|
pass
|
|
if reject == "탈락-전일시가없음" and len(candles_raw) < min_need:
|
|
return None
|
|
if reject == "탈락-봉부족":
|
|
return None
|
|
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:20"),
|
|
now,
|
|
default_hm="15:20",
|
|
)
|
|
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),
|
|
}
|