Files
kis_bot/kis_trader/strategies/tail_catch.py
Your Name 0780b2cdd0 feat: Enhance Optuna integration and logging for backtesting framework
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.
2026-08-21 19:05:23 +09:00

549 lines
23 KiB
Python

"""
kis_trader/strategies/tail_catch.py — 꼬리잡기 전략 (3분봉 망치형 되돌림)
==========================================================================
- 신호 생성: ``tail_engine.check_buy_signal_live`` / ``check_sell_signal_live``
- 주문 실행: ``OrderManager.place(OrderRequest(strategy_id="SHORT", ...))``
"""
from __future__ import annotations
import time
from datetime import datetime as dt
from typing import Dict, List, Optional
try:
from ..engine import tail_engine as te
except ImportError:
te = None
from ..engine.limit_entry_common import (
compute_atr_limit_price,
floor_limit_price_krw,
is_limit_atr_entry,
limit_valid_until_bar_key,
resolve_limit_anchor_price,
short_entry_mode,
should_cancel_unfilled_limit,
tail_limit_params,
)
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from .base import BaseStrategy, is_live_eod_now
class TailCatchStrategy(BaseStrategy):
strategy_id = "SHORT"
loop_min_sleep = 1.5
loop_max_sleep = 2.5
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.candle_tf = 3 # 3분봉
self._engine_params: Optional[Dict] = None
self._pending_limit_orders: Dict[str, Dict] = {}
self.reload_config()
# ------------------------------------------------------------------
def reload_config(self) -> None:
# 루프마다 호출 — 병목 시 [RELOAD_PROF] 로 단계 ms 기록
_rp_t0 = time.perf_counter()
_rp_last = _rp_t0
_rp: Dict[str, float] = {}
def _rp_mark(stage: str) -> None:
nonlocal _rp_last
now = time.perf_counter()
_rp[stage] = (now - _rp_last) * 1000.0
_rp_last = now
self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04)
self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
self.slot_money = (
get_env_int("TAIL_SLOT_MONEY", 0)
or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
)
_rp_mark("env_basic")
if te is not None:
try:
# ※ get_tail_defaults_from_db → db.get_merged_env_snapshot() 직접
# (SCALP 는 get_strategy_env_dict RAM 캐시 경로 · SHORT/MOM 은 매 루프 DB)
p = te.get_tail_defaults_from_db(self.db)
_rp_mark("tail_defaults")
p["live_backtest_align"] = get_env_bool(
"SHORT_LIVE_BACKTEST_ALIGN", True,
)
p["live_signal_lookback_bars"] = get_env_int(
"SHORT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
)
p["entry_mode"] = short_entry_mode()
self._engine_params = p
_rp_mark("tail_flags")
except Exception as e:
self.logger.debug("tail_engine defaults 조회 실패: %s", e)
_rp_mark("tail_err")
self.eod_enabled = get_env_bool("TAIL_EOD_ENABLED", True)
self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:20")
_rp_mark("eod")
_rp_total = (time.perf_counter() - _rp_t0) * 1000.0
# 500ms 이상만 — 장중 수 초 reload 원인 확정용
if _rp_total >= 500.0:
parts = [f"[RELOAD_PROF] SHORT total={_rp_total:.1f}"]
for k, v in sorted(_rp.items(), key=lambda x: -x[1]):
parts.append(f"{k}={v:.1f}")
line = " ".join(parts)
try:
self.logger.info("%s", line)
except Exception:
pass
try:
path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip()
if path:
import os
from datetime import datetime as _dt
if not os.path.isabs(path):
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
path = os.path.join(root, path)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "a", encoding="utf-8") as f:
f.write(_dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n")
except Exception:
pass
def _candidate_filter(self, candidate: Dict) -> bool:
"""tail_on 이 True 인 후보만 대상 (SCALP 과 분리)."""
return bool(candidate.get("tail_on", True))
def manage_pending_orders(self) -> None:
"""ATR 지정가 미체결 — 유효 봉 지나면 취소, 체결 시 DB 반영."""
if not self._pending_limit_orders:
return
from ..execution.order_manager import OrderRequest
for code in list(self._pending_limit_orders.keys()):
pend = self._pending_limit_orders.get(code)
if not pend:
continue
if code in self.holdings:
self._pending_limit_orders.pop(code, None)
continue
req = pend.get("request")
ord_no = pend.get("ord_no")
if req and ord_no:
fin = self.order_mgr.try_finalize_limit_buy(req, ord_no)
if fin.success and fin.filled_qty > 0:
self._load_holdings_from_db()
self._pending_limit_orders.pop(code, None)
self.logger.info(
"✅ [지정가체결-반영] %s ODNO=%s", code, ord_no,
)
continue
candles_raw = self.ws.get_candles(code, self.candle_tf, n=30)
if not candles_raw:
continue
candles = [self._norm_candle(c) for c in candles_raw]
latest_key = str(candles[-1].get("candle_time") or "")[:12]
vu = str(pend.get("valid_until_bar_key") or "")[:12]
if not should_cancel_unfilled_limit(latest_key, vu):
continue
disp = pend.get("name") or code
if ord_no and self.order_mgr.client.cancel_order(str(ord_no)):
self.logger.info(
"🚫 [지정가취소] %s %s — 유효봉 종료(%s%s) 미체결",
disp, code, vu, latest_key,
)
else:
self.logger.info(
"🚫 [지정가만료] %s — 유효봉 %s 지남 (취소 API 실패 시 HTS 확인)",
code, vu,
)
self._pending_limit_orders.pop(code, None)
def on_limit_buy_submitted(self, signal: Dict, result) -> None:
from ..execution.order_manager import OrderRequest
code = signal["code"]
self._pending_limit_orders[code] = {
"ord_no": result.ord_no,
"valid_until_bar_key": signal.get("valid_until_bar_key"),
"signal_bar_key": signal.get("signal_bar_key"),
"name": signal.get("name", code),
"request": OrderRequest(
strategy_id=self.strategy_id,
code=code,
name=signal.get("name", code),
side="BUY",
qty=int(signal.get("qty", 0)),
price_ref=float(signal.get("price", 0)),
stop_price=float(signal.get("stop_price", 0)),
target_price=float(signal.get("target_price", 0)),
atr_entry=float(signal.get("atr_entry", 0)),
size_class=signal.get("size_class"),
entry_features=signal.get("entry_features"),
use_limit_buy=True,
),
}
# ------------------------------------------------------------------
# 매수
# ------------------------------------------------------------------
def check_buy(self, code: str, name: str) -> Optional[Dict]:
if te is None:
self.logger.warning("tail_engine 미탑재 → 매수 체크 스킵")
return None
# 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효)
if self.is_dip_buy_excluded(code):
self._scan_log("info", code, "🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code)
return None
_cb = self._cb_prof_start(code)
try:
if get_env_bool("FORCE_BUY_TEST", False):
return self._force_buy_test(code, name)
min_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14)
candles_raw = self.ws.get_candles(code, self.candle_tf, n=50)
self._cb_prof_mark(_cb, "candles")
if len(candles_raw) < min_len:
try:
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")
if len(candles) < 10:
return None
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._get_today_trades(today)
code_trades = [
t for t in today_trades
if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT")
]
daily_cnt = len(code_trades)
daily_pnl_krw = sum(
float(t.get("realized_pnl") or 0) for t in code_trades
)
except Exception:
daily_cnt = 0
daily_pnl_krw = 0.0
self._cb_prof_mark(_cb, "trades_db")
state = {
"last_exit_dt": last_exit_dt,
"daily_cnt": daily_cnt,
"daily_pnl_krw": daily_pnl_krw,
}
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 = te.check_buy_signal_live(candles, params, state)
self._cb_prof_mark(_cb, "engine")
if reject:
self._scan_log("info", code, "🔍 [%s] %s %s: %s", reject, name, code, msg or "")
return None
if not sig:
return None
if code in self._pending_limit_orders:
return None
params = self._engine_params or {}
eng = params if params else te.get_tail_defaults_from_db(self.db)
atr_period = int(eng.get("atr_period", 14))
atr_series = te.compute_atr_series(candles, atr_period)
self._cb_prof_mark(_cb, "atr")
if is_limit_atr_entry(short_entry_mode(eng)):
if len(candles) < 2:
return None
sig_i = len(candles) - 2
sig_bar = candles[sig_i]
lp_cfg = tail_limit_params(eng)
anchor_px = resolve_limit_anchor_price(
lp_cfg["anchor"], sig_bar, candles, sig_i,
)
atr_val = atr_series[sig_i] if sig_i < len(atr_series) else None
limit_px = compute_atr_limit_price(
anchor_px, atr_val, lp_cfg["mult"], min_price=self.min_price,
)
limit_int = floor_limit_price_krw(limit_px)
if limit_int <= 0:
return None
stop_price, target_price = te.compute_tail_atr_prices(
float(limit_int), float(atr_val or limit_int * 0.01), eng,
)
valid_until = limit_valid_until_bar_key(
candles, sig_i, lp_cfg["valid_bars"],
)
hard_cap = get_env_int("SHORT_MAX_BUY_AMOUNT", 0) \
or get_env_int("TAIL_MAX_BUY_AMOUNT", 0) \
or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
qty, rej = self._resolve_buy_qty_live(
float(limit_int), hard_cap=hard_cap,
)
self._cb_prof_mark(_cb, "qty")
if rej:
self.logger.info(
"🔍 [탈락-%s] %s(%s) limit=%s",
rej, name, code, f"{limit_int:,}",
)
return None
self.logger.info(
"🎯 [SHORT 지정가] %s(%s) limit=%s원 유효~%s qty=%d",
name, code, f"{limit_int:,}", valid_until, qty,
)
return {
"code": code,
"name": name,
"price": float(limit_int),
"qty": qty,
"use_limit_buy": True,
"valid_until_bar_key": valid_until,
"signal_bar_key": str(sig_bar.get("candle_time") or "")[:12],
"stop_price": stop_price,
"target_price": target_price,
"atr_entry": float(atr_val or 0),
"session_low": float(limit_int),
"max_price": float(limit_int),
"size_class": "",
"entry_features": {
"rsi": sig.get("rsi_val", 50),
"tail_length_pct": sig.get("tail_pct", 0) * 100,
"entry_mode": "limit_atr",
},
}
# align — 다음 3분봉: RAM 첫 틱 → 없으면 시가 (시장가 계산용)
align_on = get_env_bool("SHORT_LIVE_BACKTEST_ALIGN", True)
entry_open = float(sig.get("entry_price", 0) or 0)
entry_src = "ohlc_open"
_ebk = str(sig.get("entry_bar_key") or "")[:12]
_defer = self._defer_mid_enroll_entry(
code, _ebk, int(self.candle_tf or 3),
)
self._cb_prof_mark(_cb, "mid_enroll")
if _defer:
self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code)
return None
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=_ebk,
tf_min=int(self.candle_tf or 3),
)
else:
curr_price = float(candles[-1]["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
hard_cap = get_env_int("SHORT_MAX_BUY_AMOUNT", 0) \
or get_env_int("TAIL_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.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)
atr_entry = 0.0
try:
atr_val = atr_series[-1] if atr_series else None
if atr_val is not None and float(atr_val) > 0:
atr_entry = float(atr_val)
stop_price, target_price = te.compute_tail_atr_prices(
curr_price, atr_entry, eng,
)
self.logger.info(
"📊 [SHORT ATR] %s(%s) ATR=%.0f 손절=%.0f 목표=%.0f",
name, code, atr_entry, stop_price, target_price,
)
except Exception as e:
self.logger.debug("SHORT ATR 손절/목표 계산 스킵(%s): %s", code, e)
if atr_entry <= 0:
atr_entry = curr_price * 0.01
self.logger.info(
"🎯 [SHORT 시그널] %s(%s) price=%.0f qty=%d pat=%s tail=%.2f rec=%.0f%% RSI=%.1f entry_src=%s",
name, code, curr_price, qty,
sig.get("pattern", "hammer"),
sig.get("tail_ratio", 0), sig.get("recovery_pos", 0) * 100, sig.get("rsi_val", 0),
entry_src,
)
return {
"code": code,
"name": name,
"price": curr_price,
"qty": qty,
"stop_price": stop_price,
"target_price": target_price,
"atr_entry": atr_entry if atr_entry > 0 else float(sig.get("atr_calc_val") or 0.0),
"session_low": curr_price,
"max_price": curr_price,
"size_class": "",
"entry_features": {
"rsi": sig.get("rsi_val", 50),
"tail_length_pct": sig.get("tail_pct", 0) * 100,
"pattern": sig.get("pattern", "hammer"),
},
}
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 = 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 or te is None:
return []
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",
)
try:
params = te.get_tail_defaults_from_db(self.db)
except Exception:
params = {}
signals: List[Dict] = []
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))
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
max_price = float(holding.get("max_price") or buy_price)
if current_price > max_price:
max_price = current_price
holding["max_price"] = max_price
session_low = float(holding.get("session_low") or buy_price)
if current_price < session_low:
session_low = current_price
holding["session_low"] = session_low
position = {
"entry_price": buy_price,
"entry_time": holding.get("buy_time", ""),
"qty": qty,
"stop": float(holding.get("stop_price", buy_price * (1 + self.stop_loss_pct))),
"target": float(holding.get("target_price", buy_price * (1 + self.take_profit_pct))),
"max_price": max_price,
}
candle = {
"high": max_price,
"low": session_low,
"close": current_price,
"candle_time": now.strftime("%Y%m%d%H%M"),
}
res = te.check_sell_signal_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,
"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)),
}