Changes: - Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies. - Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests. - Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes. - Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework. Impact: - These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
380 lines
16 KiB
Python
380 lines
16 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]:
|
|
_cb = self._cb_prof_start(code)
|
|
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)
|
|
self._cb_prof_mark(_cb, "candles")
|
|
if len(candles_raw) < 6:
|
|
try:
|
|
# force: EXIT 후 _gap_filled 잔존 시에도 재채움 (봉부족 복구)
|
|
self.ws.fill_gap([code], force=True)
|
|
except Exception:
|
|
pass
|
|
self._cb_prof_mark(_cb, "fill_gap")
|
|
return None
|
|
candles = [self._norm_candle(c) for c in candles_raw]
|
|
self._cb_prof_mark(_cb, "norm")
|
|
|
|
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._get_today_trades(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
|
|
self._cb_prof_mark(_cb, "trades_db")
|
|
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)
|
|
self._cb_prof_mark(_cb, "engine")
|
|
if reject:
|
|
# 갭보정 워밍업 중 — 전일시가 없음·봉부족 시 force 재큐 (로그 스팸 전에 복구)
|
|
if reject in ("탈락-전일시가없음", "탈락-봉부족"):
|
|
try:
|
|
self.ws.fill_gap([code], force=True)
|
|
except Exception:
|
|
pass
|
|
self._cb_prof_mark(_cb, "fill_gap")
|
|
if reject == "탈락-전일시가없음" and len(candles_raw) < min_need:
|
|
return None
|
|
if reject == "탈락-봉부족":
|
|
return None
|
|
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),
|
|
self._engine_params,
|
|
)
|
|
self._cb_prof_mark(_cb, "mid_enroll")
|
|
if _defer:
|
|
self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code)
|
|
return None
|
|
|
|
align_on = get_env_bool("MOMENTUM_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
|
|
# entry_src: ws_ticks | ohlc_open — 수량/손절 계산가 출처 (시장가 체결가와 별개)
|
|
if entry_src:
|
|
sig = dict(sig)
|
|
sig["entry_src"] = entry_src
|
|
|
|
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,
|
|
)
|
|
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
|
|
|
|
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 entry_src=%s",
|
|
name, code, curr_price, qty, sig.get("rsi", 0),
|
|
str(sig.get("entry_src") or 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 = 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_base = 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 = self._resolve_sell_price(
|
|
code, is_eod=is_eod, buy_price=buy_price,
|
|
)
|
|
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"),
|
|
}
|
|
|
|
params = dict(params_base)
|
|
# 수익구간·손절호가: 실매 호가 OR 히스토리 (둘 중 하나 ON일 때만 · 기본 OFF)
|
|
from kis_trader.engine.momentum_hts_logic import (
|
|
append_live_exit_ob_or,
|
|
need_ob_or_history,
|
|
_ob_or_ma_window_for_history,
|
|
)
|
|
|
|
if need_ob_or_history(params):
|
|
hist = list(holding.get("_ob_or_history") or [])
|
|
hist = append_live_exit_ob_or(
|
|
hist,
|
|
self.ws,
|
|
code,
|
|
ma_window=_ob_or_ma_window_for_history(params),
|
|
)
|
|
holding["_ob_or_history"] = hist
|
|
params["_ob_or_history"] = list(hist)
|
|
else:
|
|
params["_ob_or_history"] = []
|
|
|
|
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),
|
|
}
|