Files
kis_bot/kis_trader/backtest/optuna_orderbook_recommend.py
Your Name 2c7ad867f4 거래 빠르게 안티에서 병신만든거 커서로
feat: Implement backtest source management and enhance candle data handling

Changes:
- Introduced a new function `_apply_backtest_source_env_from_request` to manage the environment variables for candle, tick, and order book sources based on incoming requests.
- Added a teardown function `_teardown_backtest_source_env` to ensure that environment variables do not persist between requests, enhancing the stability of the backtesting environment.
- Refactored existing code to utilize the new source management functions, improving code readability and maintainability.
- Added new utility functions in `bt_candle_source.py` for fetching and managing candle data, ensuring consistency with live trading data sources.

Impact:
- These changes improve the flexibility and reliability of the backtesting framework, allowing for better management of data sources and reducing the risk of cross-request contamination.
2026-08-13 16:03:40 +09:00

549 lines
20 KiB
Python

"""
kis_trader/backtest/optuna_orderbook_recommend.py
=================================================
Optuna 차트 캔들 최적화(Stage 1)가 완료된 후, 후처리(Stage 2)로 1,000회 고속 호가 탐색을 수행하여
전략(모멘텀/돌파 등)별 최적의 진입 호가필터 & 수익구간 호가매도 합의 수치(Consensus)를 도출하고
Optuna out_data 및 Apply 패치에 자동으로 결합하는 핵심 모듈입니다.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
import optuna
from database import TradeDB
logger = logging.getLogger("OptunaOBRecommend")
optuna.logging.set_verbosity(optuna.logging.WARNING)
@dataclass
class Snap:
t: datetime
total_bid: int
total_ask: int
best_bid: int
best_ask: int
@dataclass
class TradeInfo:
code: str
name: str
buy_dt: datetime
buy_price: float
sell_price: float
qty: int
actual_pnl: float
actual_profit_rate: float
entry_snaps: List[Snap]
holding_snaps: List[Snap]
orig_spread_pct: float
orig_bid_ask_ratio: float
passed_current: bool
def _parse_dt(v: Any) -> datetime:
if isinstance(v, datetime):
return v
return datetime.strptime(str(v).strip()[:19], "%Y-%m-%d %H:%M:%S")
def _snap_to_dt(snap_time: str) -> Optional[datetime]:
s = str(snap_time or "").strip()
if not s or len(s) < 14 or s == "None":
return None
try:
return datetime(int(s[:4]), int(s[4:6]), int(s[6:8]), int(s[8:10]), int(s[10:12]), int(s[12:14]))
except ValueError:
return None
def _parse_ratchet_tiers(val: str) -> List[Tuple[int, float]]:
t = []
for p in str(val or "").strip().split(","):
if ":" in p:
parts = p.split(":")
try:
t.append((int(parts[0].strip()), float(parts[1].strip())))
except ValueError:
pass
t.sort(key=lambda x: x[0])
if not t:
t = [(10, 2.6), (13, 2.2)]
return t
def resolve_orderbook_recommend_table(
*,
ob_table: Optional[str] = None,
history_source: Optional[str] = None,
ob_source: Optional[str] = None,
) -> Tuple[str, Tuple[str, ...]]:
"""후처리 호가 테이블 — 전략명 하드코딩 금지.
우선순위 (백테 ``trigger_snapshot_loader`` / ``OB_SOURCE`` 와 동일 축):
1) 명시 ``ob_table``
2) ``ob_source`` 또는 env ``OB_SOURCE`` (kis|kiwoom|kiwoom_0d|ls)
3) ``history_source`` 또는 ``BACKTEST_UNIVERSE_HISTORY_SOURCE`` (ls → ls_ws_orderbook)
Returns:
(table_name, source_filter) — source_filter 비어 있으면 source 조건 없음.
"""
import os
if ob_table and str(ob_table).strip():
t = str(ob_table).strip()
if t == "kis_ws_orderbook":
return t, tuple()
if t == "ls_ws_orderbook":
return t, ("ls_uh1", "ls_h1", "ls_ha", "ls_nh1")
return t, ("kiwoom_0d",)
raw_ob = (ob_source if ob_source is not None else os.environ.get("OB_SOURCE", "")).strip().lower()
if raw_ob in ("kis", "kis_ws"):
return "kis_ws_orderbook", tuple()
if raw_ob in ("ls", "ls_condition", "ls_ws", "ls_afr"):
return "ls_ws_orderbook", ("ls_uh1", "ls_h1", "ls_ha", "ls_nh1")
if raw_ob in ("kiwoom", "kiwoom_0d", "0d"):
return "ws_orderbook", ("kiwoom_0d",)
try:
from kis_trader.backtest.universe_history_source import (
resolve_backtest_universe_history_source,
)
hs = resolve_backtest_universe_history_source(history_source)
except Exception:
hs = str(history_source or "kiwoom").strip().lower()
if hs in ("ls", "ls_condition", "ls_afr", "ls_ws"):
hs = "ls"
else:
hs = "kiwoom"
if hs == "ls":
return "ls_ws_orderbook", ("ls_uh1", "ls_h1", "ls_ha", "ls_nh1")
# 기본(키움 이력) — 실수집 본체
return "ws_orderbook", ("kiwoom_0d",)
def get_orderbook_table_for_strategy(strategy: str) -> str:
"""호환용 — 전략명으로 LS 강제하지 않음. history/ob_source 해석."""
table, _src = resolve_orderbook_recommend_table()
return table
def _strategy_config_table_and_prefix(strat_upper: str) -> Tuple[str, str]:
s = (strat_upper or "").strip().upper()
if "BREAKOUT" in s:
return "config_breakout", "BREAKOUT_"
if "SCALP" in s:
return "config_scalp", "SCALP_"
if s in ("SHORT", "TAIL") or "TAIL" in s:
return "config_short", "TAIL_"
if "US_MOMENTUM" in s or s.startswith("US"):
return "config_us_momentum", "US_MOMENTUM_"
return "config_momentum", "MOMENTUM_"
def recommend_orderbook_parameters(
strategy: str = "MOMENTUM",
n_trials: int = 1000,
ob_table: Optional[str] = None,
history_source: Optional[str] = None,
ob_source: Optional[str] = None,
log: Optional[logging.Logger] = None,
) -> Dict[str, Any]:
lg = log or logger
strat_upper = strategy.upper()
table, source_filter = resolve_orderbook_recommend_table(
ob_table=ob_table,
history_source=history_source,
ob_source=ob_source,
)
lg.info(
"📌 [호가 후처리] strategy=%s table=%s source_filter=%s",
strat_upper, table, source_filter or "(all)",
)
db = TradeDB()
# 1. 테이블 존재 여부 및 컬럼 검사
try:
cols = [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {table}").fetchall()]
need = {"code", "snap_time", "total_bid_qty", "total_ask_qty", "best_bid", "best_ask"}
if need - set(cols):
lg.warning("⚠️ [%s] 호가 테이블 필수 컬럼 부족. 추천 생략.", table)
return {"ok": False, "reason": "insufficient_columns", "table": table}
except Exception as exc:
lg.warning("⚠️ [%s] 테이블 조회 실패: %s. 추천 생략.", table, exc)
return {"ok": False, "reason": "table_not_found", "table": table}
date_sql = f"SELECT DISTINCT SUBSTR(snap_time, 1, 8) as dt FROM {table}"
date_params: Tuple[Any, ...] = ()
if source_filter and "source" in cols:
ph = ",".join(["%s"] * len(source_filter))
date_sql += f" WHERE source IN ({ph})"
date_params = tuple(source_filter)
date_sql += " ORDER BY dt"
date_rows = db.conn.execute(date_sql, date_params).fetchall()
avail_dates = [str(r["dt"]) for r in date_rows if r["dt"] and str(r["dt"]) != "None"]
# source 필터에 안 걸린 구행만 있을 때 — 필터 없이 1회 재시도
if not avail_dates and source_filter and "source" in cols:
date_rows = db.conn.execute(
f"SELECT DISTINCT SUBSTR(snap_time, 1, 8) as dt FROM {table} ORDER BY dt"
).fetchall()
avail_dates = [str(r["dt"]) for r in date_rows if r["dt"] and str(r["dt"]) != "None"]
if avail_dates:
source_filter = tuple()
lg.info("📌 [호가 후처리] source 필터 미스 → 전체 source 사용 table=%s", table)
if not avail_dates:
return {
"ok": False,
"reason": "no_orderbook_snapshots",
"table": table,
"source_filter": list(source_filter),
}
# 2. 전략별 config (래칫/어깨) 로딩
cfg_table, pfx = _strategy_config_table_and_prefix(strat_upper)
try:
row_cfg = db.conn.execute(
f"SELECT {pfx}RATCHET_TIERS, {pfx}SHOULDER_MIN_HIGH_PCT, {pfx}SHOULDER_CUT_PCT FROM {cfg_table} ORDER BY id DESC LIMIT 1"
).fetchone()
cfg_dict = dict(row_cfg) if row_cfg else {}
except Exception:
cfg_dict = {}
tiers = _parse_ratchet_tiers(str(cfg_dict.get(f"{pfx}RATCHET_TIERS") or "10:2.6,13:2.2"))
smh = float(cfg_dict.get(f"{pfx}SHOULDER_MIN_HIGH_PCT") or 0.05)
sc = float(cfg_dict.get(f"{pfx}SHOULDER_CUT_PCT") or 0.0055)
if smh > 1.0:
smh /= 100.0
if sc > 1.0:
sc /= 100.0
trades: List[TradeInfo] = []
for dt_str in avail_dates:
day_hyphen = f"{dt_str[:4]}-{dt_str[4:6]}-{dt_str[6:]}"
buys = db.conn.execute(
"""
SELECT id, code, name, buy_date, buy_price, sell_price, qty, profit_rate, realized_pnl
FROM trade_history
WHERE strategy=%s AND DATE(buy_date)=%s
ORDER BY buy_date
""",
(strat_upper, day_hyphen),
).fetchall()
for b in buys:
try:
buy_dt = _parse_dt(b["buy_date"])
except Exception:
continue
buy_price = float(b["buy_price"] or 0)
code = str(b["code"])
qty = int(b["qty"] or 0)
actual_pnl = float(b["realized_pnl"] or 0)
actual_profit_rate = float(b["profit_rate"] or 0)
lookback = timedelta(minutes=10)
horizon = timedelta(minutes=6)
snap_sql = (
f"SELECT snap_time, total_bid_qty, total_ask_qty, best_bid, best_ask FROM {table} "
"WHERE code=%s AND snap_time >= %s AND snap_time < %s"
)
snap_params: List[Any] = [
code,
(buy_dt - lookback).strftime("%Y%m%d%H%M%S"),
(buy_dt + horizon).strftime("%Y%m%d%H%M%S"),
]
if source_filter and "source" in cols:
ph = ",".join(["%s"] * len(source_filter))
snap_sql += f" AND source IN ({ph})"
snap_params.extend(source_filter)
snap_sql += " ORDER BY snap_time ASC"
s_rows = db.conn.execute(snap_sql, tuple(snap_params)).fetchall()
snaps: List[Snap] = []
for sr in s_rows:
s_dt = _snap_to_dt(sr["snap_time"])
if not s_dt:
continue
snaps.append(
Snap(
t=s_dt,
total_bid=int(sr["total_bid_qty"] or 0),
total_ask=int(sr["total_ask_qty"] or 0),
best_bid=int(sr["best_bid"] or 0),
best_ask=int(sr["best_ask"] or 0),
)
)
if not snaps or buy_price <= 0 or qty <= 0:
continue
before = [s for s in snaps if s.t <= buy_dt]
entry_snaps = before if before else snaps
holding_snaps = [s for s in snaps if s.t > buy_dt]
# 진입 호가 지표 산정
best_entry_snap = entry_snaps[-1]
bid, ask = best_entry_snap.best_bid, best_entry_snap.best_ask
spread_pct = ((ask - bid) / ((ask + bid) / 2.0)) * 100.0 if (ask > 0 and bid > 0) else 0.0
tot_b, tot_a = best_entry_snap.total_bid, best_entry_snap.total_ask
ratio = (tot_b / tot_a) if tot_a > 0 else 999.0
trades.append(
TradeInfo(
code=code,
name=str(b.get("name") or code),
buy_dt=buy_dt,
buy_price=buy_price,
sell_price=float(b["sell_price"] or 0),
qty=qty,
actual_pnl=actual_pnl,
actual_profit_rate=actual_profit_rate,
entry_snaps=entry_snaps,
holding_snaps=holding_snaps,
orig_spread_pct=spread_pct,
orig_bid_ask_ratio=ratio,
passed_current=(spread_pct <= 0.45 and ratio >= 0.85),
)
)
if len(trades) < 3:
lg.warning("⚠️ [%s] 호가 연제 가능한 실제 매수 건수(%s건)가 부족하여 최적화 생략.", strat_upper, len(trades))
return {"ok": False, "reason": "not_enough_trades", "trade_count": len(trades)}
# 원본 실측값
orig_cnt = len(trades)
orig_win = sum(1 for t in trades if t.actual_pnl > 0) / orig_cnt * 100.0
orig_pnl = sum(t.actual_pnl for t in trades)
orig_rate = sum(t.actual_profit_rate for t in trades) / orig_cnt
def _sim_trade(tr: TradeInfo, p: Dict[str, Any]) -> Tuple[float, float, str]:
if tr.orig_spread_pct > p["max_spread_pct"] or tr.orig_bid_ask_ratio < p["min_bid_ask_ratio"]:
return (0.0, 0.0, "ENTRY_REJECTED")
if not p["use_ob_exit"]:
return (tr.actual_pnl, tr.actual_profit_rate, "ORIGINAL")
ma_win = p["ma_window"]
ratio_min = p["ob_ratio_min"]
min_prof = p["min_profit_pct"]
min_hold = p["min_hold_bars"]
history_ratio: List[float] = []
high_price = tr.buy_price
for s in tr.holding_snaps:
cur_p = float(s.best_bid if s.best_bid > 0 else s.best_ask)
if cur_p <= 0:
continue
if cur_p > high_price:
high_price = cur_p
r = (s.total_bid / s.total_ask) if s.total_ask > 0 else 1.0
history_ratio.append(r)
held_sec = (s.t - tr.buy_dt).total_seconds()
if held_sec < min_hold * 60:
continue
if len(history_ratio) < ma_win:
continue
ma_r = sum(history_ratio[-ma_win:]) / float(ma_win)
prof_rate = (cur_p - tr.buy_price) / tr.buy_price
if prof_rate < min_prof:
continue
# 래칫 & 어깨 체크
high_rate = (high_price - tr.buy_price) / tr.buy_price
if high_rate >= smh and ((high_price - cur_p) / high_price) >= sc:
continue
is_ratchet_blocked = False
for bar_m, target_r in tiers:
if (held_sec >= bar_m * 60) and (prof_rate >= (target_r / 100.0)):
is_ratchet_blocked = True
break
if is_ratchet_blocked:
continue
if ma_r < ratio_min:
realized = (cur_p - tr.buy_price) * tr.qty
return (realized, prof_rate * 100.0, "OB_EXIT")
return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")
def _calc_suite(p: Dict[str, Any]) -> Tuple[int, float, float, float]:
t_cnt = 0
w_cnt = 0
tot_pnl = 0.0
tot_rate = 0.0
for t in trades:
pnl, rate, rtype = _sim_trade(t, p)
if rtype != "ENTRY_REJECTED":
t_cnt += 1
tot_pnl += pnl
tot_rate += rate
if pnl > 0:
w_cnt += 1
w_rate = (w_cnt / t_cnt * 100.0) if t_cnt > 0 else 0.0
avg_r = (tot_rate / t_cnt) if t_cnt > 0 else 0.0
return t_cnt, w_rate, tot_pnl, avg_r
valid_records: List[Dict[str, Any]] = []
can_use_ob_exit = strat_upper in ("MOMENTUM", "BREAKOUT")
def obj_func(trial: optuna.Trial) -> float:
params = {
"max_spread_pct": trial.suggest_float("max_spread_pct", 0.3, 3.5, step=0.1),
"min_bid_ask_ratio": trial.suggest_float("min_bid_ask_ratio", 0.1, 1.0, step=0.05),
"use_ob_exit": trial.suggest_categorical("use_ob_exit", [True, False]) if can_use_ob_exit else False,
"min_hold_bars": trial.suggest_int("min_hold_bars", 1, 5) if can_use_ob_exit else 3,
"ob_ratio_min": trial.suggest_float("ob_ratio_min", 0.2, 0.8, step=0.05) if can_use_ob_exit else 0.4,
"min_profit_pct": trial.suggest_float("min_profit_pct", 0.003, 0.02, step=0.001) if can_use_ob_exit else 0.005,
"ma_window": trial.suggest_int("ma_window", 3, 10) if can_use_ob_exit else 5,
}
cnt, win_r, pnl, rate = _calc_suite(params)
if cnt < max(3, int(orig_cnt * 0.3)):
return -999999999.0
w_p = (pnl / 100000.0)
w_w = win_r * 2.0
score = w_p + w_w
if win_r >= 60.0:
score += (win_r - 60.0) * 1.5
valid_records.append({"score": score, "pnl": pnl, "win_rate": win_r, "count": cnt, "rate": rate, "params": params})
return score
study = optuna.create_study(direction="maximize")
study.optimize(obj_func, n_trials=n_trials)
valid_records.sort(key=lambda x: x["score"], reverse=True)
top5 = valid_records[: min(5, len(valid_records))]
if not top5:
return {"ok": False, "reason": "no_valid_trials"}
avg_spread = round(sum(r["params"]["max_spread_pct"] for r in top5) / len(top5), 2)
avg_bid_ask = round(sum(r["params"]["min_bid_ask_ratio"] for r in top5) / len(top5), 2)
use_ob_votes = sum(1 for r in top5 if r["params"]["use_ob_exit"])
cons_ob_exit = use_ob_votes >= (len(top5) / 2.0)
avg_ob_ratio = round(sum(r["params"]["ob_ratio_min"] for r in top5) / len(top5), 2)
avg_hold = int(round(sum(r["params"]["min_hold_bars"] for r in top5) / len(top5)))
avg_ma = int(round(sum(r["params"]["ma_window"] for r in top5) / len(top5)))
avg_prof = round(sum(r["params"]["min_profit_pct"] for r in top5) / len(top5), 4)
cons_params = {
"max_spread_pct": avg_spread,
"min_bid_ask_ratio": avg_bid_ask,
"use_ob_exit": cons_ob_exit,
"min_hold_bars": avg_hold,
"ob_ratio_min": avg_ob_ratio,
"min_profit_pct": avg_prof,
"ma_window": avg_ma,
}
c_cnt, c_win, c_pnl, c_rate = _calc_suite(cons_params)
lg.info(
"⚡ [호가 수급 합의 추천] 전략=%s (모수=%d건, %d회 탐색) | 스프레드≤%.2f%% 잔량비≥%.2f | 호가익절=%s | 승률: %.1f%% 손익: %.0f",
strat_upper,
len(trades),
n_trials,
avg_spread,
avg_bid_ask,
"ON" if cons_ob_exit else "OFF",
c_win,
c_pnl,
)
return {
"ok": True,
"strategy": strat_upper,
"ob_table": table,
"n_trials": n_trials,
"trade_count": len(trades),
"orig_stats": {"count": orig_cnt, "win_rate": round(orig_win, 1), "pnl": orig_pnl, "avg_rate": round(orig_rate, 2)},
"recommended_stats": {"count": c_cnt, "win_rate": round(c_win, 1), "pnl": c_pnl, "avg_rate": round(c_rate, 2), "pnl_diff": c_pnl - orig_pnl},
"params": {
"orderbook_filter_enabled": True,
"orderbook_max_spread_pct": avg_spread,
"orderbook_min_bid_ask_ratio": avg_bid_ask,
"exit_ob_enabled": cons_ob_exit,
"exit_ob_min_hold_bars": avg_hold,
"exit_ob_min_profit_pct": avg_prof,
"exit_ob_ratio_min": avg_ob_ratio,
"exit_ob_ma_window": avg_ma,
},
}
def attach_orderbook_recommend(
out_data: Dict[str, Any],
*,
log: Optional[logging.Logger] = None,
) -> Dict[str, Any]:
"""out_data에 호가 진입/청산 합의 수치 추천(orderbook_recommend)을 첨부."""
lg = log or logger
strat = str(out_data.get("strategy") or "MOMENTUM").strip().upper()
hist = (
out_data.get("universe_history_source")
or out_data.get("_universe_history_source")
or out_data.get("history_source")
)
ob_src = out_data.get("ob_source") or out_data.get("orderbook_source")
rec = recommend_orderbook_parameters(
strategy=strat,
n_trials=1000,
history_source=hist,
ob_source=ob_src,
log=lg,
)
out_data["orderbook_recommend"] = rec
mc = out_data.get("mode_combo")
if isinstance(mc, dict):
mc["orderbook_recommend"] = rec
if not rec.get("ok"):
lg.info(
"⚡ [호가 수급 합의 추천] 생략 — %s (table=%s)",
rec.get("reason") or "n/a",
rec.get("table") or "?",
)
return out_data
def build_orderbook_env_patch(rec: Dict[str, Any]) -> Dict[str, str]:
"""호가 수급 합의 추천 결과를 DB env 패치 dict로 변환."""
if not rec or not rec.get("ok"):
return {}
strat = str(rec.get("strategy") or "").strip().upper()
pfx = "TAIL" if strat in ("SHORT", "TAIL") else strat
p = rec.get("params", {})
if not pfx or not p:
return {}
patch = {
f"{pfx}_ORDERBOOK_FILTER_ENABLED": "true" if p.get("orderbook_filter_enabled", True) else "false",
f"{pfx}_ORDERBOOK_MAX_SPREAD_PCT": str(p["orderbook_max_spread_pct"]),
f"{pfx}_ORDERBOOK_MIN_BID_ASK_RATIO": str(p["orderbook_min_bid_ask_ratio"]),
}
if pfx in ("MOMENTUM", "BREAKOUT") and "exit_ob_enabled" in p:
patch[f"{pfx}_EXIT_OB_ENABLED"] = "true" if p.get("exit_ob_enabled") else "false"
if p.get("exit_ob_enabled"):
patch[f"{pfx}_EXIT_OB_RATIO_MIN"] = str(p["exit_ob_ratio_min"])
patch[f"{pfx}_EXIT_OB_MA_WINDOW"] = str(p["exit_ob_ma_window"])
patch[f"{pfx}_EXIT_OB_MIN_PROFIT_PCT"] = str(p["exit_ob_min_profit_pct"])
patch[f"{pfx}_EXIT_OB_MIN_HOLD_BARS"] = str(p["exit_ob_min_hold_bars"])
return patch