Changes: - Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically. - Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database. - Introduced a mechanism to handle master subscription states, improving the management of subscription statuses. - Updated the database schema to include new fields for managing subscription states and order book filtering. Impact: - These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies. 히스토리 align 제거 븅신같은 초기설계 아예 제거 진입모드에 구멍메움 호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
911 lines
34 KiB
Python
911 lines
34 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
|
||
bid_qty_l3: int = 0
|
||
ask_qty_l3: int = 0
|
||
|
||
|
||
@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
|
||
orig_ask_qty_l3: int
|
||
passed_current: bool
|
||
|
||
|
||
def _krw_int(v: Any) -> int:
|
||
"""원 단위 정수 절삭(소수 버림). 표시·추천 통계 공통."""
|
||
try:
|
||
x = float(v)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
if x != x or abs(x) >= 1e15:
|
||
return 0
|
||
return int(x)
|
||
|
||
|
||
def _ob_n_trials(default: int = 1000) -> int:
|
||
from kis_trader.utils.env import get_env_int
|
||
return max(10, int(get_env_int("OPTUNA_OB_RECOMMEND_TRIALS", int(default))))
|
||
|
||
|
||
def _ob_lookback_horizon() -> Tuple[timedelta, timedelta]:
|
||
from kis_trader.utils.env import get_env_int
|
||
lb = max(1, int(get_env_int("OPTUNA_OB_LOOKBACK_MIN", 30)))
|
||
hz = max(1, int(get_env_int("OPTUNA_OB_HORIZON_MIN", 6)))
|
||
return timedelta(minutes=lb), timedelta(minutes=hz)
|
||
|
||
|
||
def _parse_dt(v: Any) -> datetime:
|
||
if isinstance(v, datetime):
|
||
return v
|
||
s = str(v or "").strip()
|
||
if not s:
|
||
raise ValueError("empty dt")
|
||
if s[:10].isdigit() and ("-" in s[:12] or " " in s or "T" in s):
|
||
return datetime.strptime(s[:19].replace("T", " "), "%Y-%m-%d %H:%M:%S")
|
||
digits = "".join(ch for ch in s if ch.isdigit())
|
||
if len(digits) >= 14:
|
||
return datetime.strptime(digits[:14], "%Y%m%d%H%M%S")
|
||
if len(digits) >= 12:
|
||
return datetime.strptime(digits[:12], "%Y%m%d%H%M")
|
||
return datetime.strptime(s[: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 _fetch_ob_snaps(
|
||
db: Any,
|
||
table: str,
|
||
cols: List[str],
|
||
source_filter: Tuple[str, ...],
|
||
code: str,
|
||
buy_dt: datetime,
|
||
lookback: timedelta,
|
||
horizon: timedelta,
|
||
) -> List[Snap]:
|
||
sel = ["snap_time", "total_bid_qty", "total_ask_qty", "best_bid", "best_ask"]
|
||
if "bid_qty_l3" in cols:
|
||
sel.append("bid_qty_l3")
|
||
if "ask_qty_l3" in cols:
|
||
sel.append("ask_qty_l3")
|
||
snap_sql = (
|
||
f"SELECT {', '.join(sel)} 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),
|
||
bid_qty_l3=int(sr["bid_qty_l3"] or 0) if "bid_qty_l3" in sr else 0,
|
||
ask_qty_l3=int(sr["ask_qty_l3"] or 0) if "ask_qty_l3" in sr else 0,
|
||
)
|
||
)
|
||
return snaps
|
||
|
||
|
||
def _tradeinfo_from_fill(
|
||
*,
|
||
code: str,
|
||
name: str,
|
||
buy_dt: datetime,
|
||
buy_price: float,
|
||
sell_price: float,
|
||
qty: int,
|
||
actual_pnl: float,
|
||
actual_profit_rate: float,
|
||
snaps: List[Snap],
|
||
) -> Optional[TradeInfo]:
|
||
if not snaps or buy_price <= 0 or qty <= 0:
|
||
return None
|
||
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
|
||
return TradeInfo(
|
||
code=code,
|
||
name=name,
|
||
buy_dt=buy_dt,
|
||
buy_price=buy_price,
|
||
sell_price=sell_price,
|
||
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,
|
||
orig_ask_qty_l3=int(best_entry_snap.ask_qty_l3 or 0),
|
||
passed_current=False,
|
||
)
|
||
|
||
|
||
def raw_fills_to_ob_trades(
|
||
raw_fills: List[Dict[str, Any]],
|
||
*,
|
||
db: Any,
|
||
table: str,
|
||
cols: List[str],
|
||
source_filter: Tuple[str, ...],
|
||
) -> List[TradeInfo]:
|
||
"""백테 체결 dict(buy_time/buy_price/qty/pnl) → 호가 TradeInfo."""
|
||
lookback, horizon = _ob_lookback_horizon()
|
||
out: List[TradeInfo] = []
|
||
for b in raw_fills or []:
|
||
if not isinstance(b, dict):
|
||
continue
|
||
code = str(b.get("code") or "").strip()
|
||
raw_dt = b.get("buy_date") or b.get("buy_time") or b.get("entry_time")
|
||
try:
|
||
buy_dt = _parse_dt(raw_dt)
|
||
except Exception:
|
||
continue
|
||
buy_price = float(b.get("buy_price") or b.get("entry") or 0)
|
||
qty = int(b.get("qty") or 0)
|
||
actual_pnl = float(b.get("actual_pnl") if b.get("actual_pnl") is not None else (b.get("pnl") or b.get("realized_pnl") or 0))
|
||
actual_profit_rate = float(b.get("profit_rate") or b.get("actual_profit_rate") or 0)
|
||
sell_price = float(b.get("sell_price") or b.get("exit") or 0)
|
||
snaps = _fetch_ob_snaps(db, table, cols, source_filter, code, buy_dt, lookback, horizon)
|
||
ti = _tradeinfo_from_fill(
|
||
code=code,
|
||
name=str(b.get("name") or code),
|
||
buy_dt=buy_dt,
|
||
buy_price=buy_price,
|
||
sell_price=sell_price,
|
||
qty=qty,
|
||
actual_pnl=actual_pnl,
|
||
actual_profit_rate=actual_profit_rate,
|
||
snaps=snaps,
|
||
)
|
||
if ti:
|
||
out.append(ti)
|
||
return out
|
||
|
||
|
||
def _ob_axis_n_trials(n_trials: int) -> int:
|
||
from kis_trader.utils.env import get_env_int
|
||
axis = int(get_env_int("OPTUNA_OB_AXIS_TRIALS", 0) or 0)
|
||
if axis > 0:
|
||
return max(10, axis)
|
||
return max(10, int(n_trials or _ob_n_trials(1000)))
|
||
|
||
|
||
def _ob_orig_stats(trades: List[TradeInfo]) -> Dict[str, Any]:
|
||
orig_cnt = len(trades)
|
||
if orig_cnt <= 0:
|
||
return {"count": 0, "win_rate": 0.0, "pnl": 0, "avg_rate": 0.0}
|
||
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
|
||
return {
|
||
"count": orig_cnt,
|
||
"win_rate": round(orig_win, 1),
|
||
"pnl": _krw_int(orig_pnl),
|
||
"avg_rate": round(orig_rate, 2),
|
||
}
|
||
|
||
|
||
def _suite_stats(rows: List[Tuple[float, float, str]], *, skip_reject: bool = True) -> Dict[str, Any]:
|
||
kept = [(p, r) for p, r, t in rows if (not skip_reject) or t != "ENTRY_REJECTED"]
|
||
t_cnt = len(kept)
|
||
if t_cnt <= 0:
|
||
return {"count": 0, "win_rate": 0.0, "pnl": 0, "avg_rate": 0.0, "pnl_diff": 0}
|
||
tot_pnl = sum(p for p, _ in kept)
|
||
tot_rate = sum(r for _, r in kept)
|
||
w_cnt = sum(1 for p, _ in kept if p > 0)
|
||
return {
|
||
"count": t_cnt,
|
||
"win_rate": round(w_cnt / t_cnt * 100.0, 1),
|
||
"pnl": _krw_int(tot_pnl),
|
||
"avg_rate": round(tot_rate / t_cnt, 2),
|
||
}
|
||
|
||
|
||
def _axis_score(cnt: int, orig_cnt: int, win_r: float, pnl: float) -> float:
|
||
if cnt < max(3, int(orig_cnt * 0.3)):
|
||
return -999999999.0
|
||
score = (pnl / 100000.0) + win_r * 2.0
|
||
if win_r >= 60.0:
|
||
score += (win_r - 60.0) * 1.5
|
||
return score
|
||
|
||
|
||
def _snap_or_and_price(s: Snap) -> Tuple[float, float]:
|
||
cur_p = float(s.best_bid if s.best_bid > 0 else s.best_ask)
|
||
ratio = (s.total_bid / s.total_ask) if s.total_ask > 0 else 1.0
|
||
return cur_p, ratio
|
||
|
||
|
||
def _sim_entry(tr: TradeInfo, p: Dict[str, Any]) -> Tuple[float, float, str]:
|
||
if tr.orig_spread_pct > float(p["max_spread_pct"]) or tr.orig_bid_ask_ratio < float(p["min_bid_ask_ratio"]):
|
||
return (0.0, 0.0, "ENTRY_REJECTED")
|
||
# 실매 매도벽: L3 매도잔량 > 주문수량 × 배수 이면 탈락. L3 없으면 이 축은 스킵.
|
||
ask_mult = float(p.get("ask_max_mult") or 0.0)
|
||
ask_l3 = int(tr.orig_ask_qty_l3 or 0)
|
||
if ask_mult > 0 and tr.qty > 0 and ask_l3 > 0:
|
||
if ask_l3 > int(tr.qty * ask_mult):
|
||
return (0.0, 0.0, "ENTRY_REJECTED")
|
||
return (tr.actual_pnl, tr.actual_profit_rate, "ORIGINAL")
|
||
|
||
|
||
def _sim_exit_ob(tr: TradeInfo, p: Dict[str, Any]) -> Tuple[float, float, str]:
|
||
"""실매 _check_exit_ob_l3 와 동일 조건 — 엔진 함수는 수정하지 않고 호출만."""
|
||
from kis_trader.engine.momentum_hts_logic import _check_exit_ob_l3
|
||
|
||
if not p.get("exit_ob_enabled"):
|
||
return (tr.actual_pnl, tr.actual_profit_rate, "ORIGINAL")
|
||
params = {
|
||
"exit_ob_enabled": True,
|
||
"exit_ob_ratio_min": float(p.get("exit_ob_ratio_min") or p.get("ob_ratio_min") or 0.4),
|
||
"exit_ob_ma_window": int(p.get("exit_ob_ma_window") or p.get("ma_window") or 5),
|
||
"exit_ob_min_profit_pct": float(p.get("exit_ob_min_profit_pct") or p.get("min_profit_pct") or 0.005),
|
||
"exit_ob_min_hold_bars": int(p.get("exit_ob_min_hold_bars") or p.get("min_hold_bars") or 3),
|
||
}
|
||
history: List[Optional[float]] = []
|
||
for s in tr.holding_snaps:
|
||
cur_p, ratio = _snap_or_and_price(s)
|
||
if cur_p <= 0:
|
||
history.append(None)
|
||
continue
|
||
history.append(ratio)
|
||
hold_bars = max(0, int((s.t - tr.buy_dt).total_seconds() / 60.0))
|
||
if _check_exit_ob_l3(params, history, tr.buy_price, cur_p, hold_bars):
|
||
realized = (cur_p - tr.buy_price) * tr.qty
|
||
rate = ((cur_p - tr.buy_price) / tr.buy_price) * 100.0 if tr.buy_price else 0.0
|
||
return (realized, rate, "OB_EXIT")
|
||
return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")
|
||
|
||
|
||
def _sim_stop_ob(tr: TradeInfo, p: Dict[str, Any]) -> Tuple[float, float, str]:
|
||
"""실매 _check_stop_ob 와 동일 조건 — 엔진 본체 미수정."""
|
||
from kis_trader.engine.momentum_hts_logic import _check_stop_ob
|
||
|
||
if not p.get("stop_ob_enabled"):
|
||
return (tr.actual_pnl, tr.actual_profit_rate, "ORIGINAL")
|
||
params = {
|
||
"stop_ob_enabled": True,
|
||
"stop_ob_ratio_min": float(p.get("stop_ob_ratio_min") or 0.4),
|
||
"stop_ob_ma_window": int(p.get("stop_ob_ma_window") or 5),
|
||
"stop_ob_min_loss_pct": float(p.get("stop_ob_min_loss_pct") or 0.003),
|
||
"stop_ob_min_hold_bars": int(p.get("stop_ob_min_hold_bars") or 2),
|
||
}
|
||
history: List[Optional[float]] = []
|
||
for s in tr.holding_snaps:
|
||
cur_p, ratio = _snap_or_and_price(s)
|
||
if cur_p <= 0:
|
||
history.append(None)
|
||
continue
|
||
history.append(ratio)
|
||
hold_bars = max(0, int((s.t - tr.buy_dt).total_seconds() / 60.0))
|
||
if _check_stop_ob(params, history, tr.buy_price, cur_p, hold_bars):
|
||
realized = (cur_p - tr.buy_price) * tr.qty
|
||
rate = ((cur_p - tr.buy_price) / tr.buy_price) * 100.0 if tr.buy_price else 0.0
|
||
return (realized, rate, "OB_STOP")
|
||
return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")
|
||
|
||
|
||
def _run_axis_study(
|
||
*,
|
||
trades: List[TradeInfo],
|
||
orig_cnt: int,
|
||
n_trials: int,
|
||
suggest_fn: Any,
|
||
sim_fn: Any,
|
||
skip_reject: bool,
|
||
lg: logging.Logger,
|
||
axis_name: str,
|
||
) -> Dict[str, Any]:
|
||
orig = _ob_orig_stats(trades)
|
||
valid_records: List[Dict[str, Any]] = []
|
||
|
||
def obj_func(trial: optuna.Trial) -> float:
|
||
params = suggest_fn(trial)
|
||
rows = [sim_fn(t, params) for t in trades]
|
||
st = _suite_stats(rows, skip_reject=skip_reject)
|
||
score = _axis_score(int(st["count"]), orig_cnt, float(st["win_rate"]), float(st["pnl"]))
|
||
if score > -1e8:
|
||
valid_records.append({"score": score, "params": params, "stats": st})
|
||
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:
|
||
lg.info("⚡ [%s] 유효 trial 없음", axis_name)
|
||
return {"ok": False, "reason": "no_valid_trials", "params": {}, "recommended_stats": orig}
|
||
|
||
# 합의: 숫자 median, bool 최빈
|
||
keys = list(top5[0]["params"].keys())
|
||
cons: Dict[str, Any] = {}
|
||
for k in keys:
|
||
vs = [r["params"][k] for r in top5 if k in r["params"]]
|
||
if not vs:
|
||
continue
|
||
if isinstance(vs[0], bool):
|
||
cons[k] = sum(1 for v in vs if v) >= (len(vs) / 2.0)
|
||
elif isinstance(vs[0], int) and not isinstance(vs[0], bool):
|
||
cons[k] = int(round(sum(float(v) for v in vs) / len(vs)))
|
||
else:
|
||
cons[k] = round(sum(float(v) for v in vs) / len(vs), 4)
|
||
rows = [sim_fn(t, cons) for t in trades]
|
||
rec_st = _suite_stats(rows, skip_reject=skip_reject)
|
||
rec_st["pnl_diff"] = _krw_int(int(rec_st["pnl"]) - int(orig["pnl"]))
|
||
lg.info(
|
||
"⚡ [%s] 합의 %s | 건=%s WR=%.1f PnL=%s",
|
||
axis_name, cons, rec_st["count"], rec_st["win_rate"], rec_st["pnl"],
|
||
)
|
||
return {"ok": True, "params": cons, "recommended_stats": rec_st, "orig_stats": orig, "n_trials": n_trials}
|
||
|
||
|
||
def _optimize_entry_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
|
||
from kis_trader.utils.env import get_env_float
|
||
|
||
lo_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MIN", 0.1))
|
||
hi_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MAX", 8.0))
|
||
lo_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MIN", 0.05))
|
||
hi_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MAX", 1.5))
|
||
lo_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MIN", 1.0))
|
||
hi_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MAX", 80.0))
|
||
if hi_s < lo_s:
|
||
lo_s, hi_s = hi_s, lo_s
|
||
if hi_r < lo_r:
|
||
lo_r, hi_r = hi_r, lo_r
|
||
if hi_a < lo_a:
|
||
lo_a, hi_a = hi_a, lo_a
|
||
|
||
def suggest(trial: optuna.Trial) -> Dict[str, Any]:
|
||
return {
|
||
"max_spread_pct": trial.suggest_float("max_spread_pct", lo_s, hi_s, step=0.1),
|
||
"min_bid_ask_ratio": trial.suggest_float("min_bid_ask_ratio", lo_r, hi_r, step=0.05),
|
||
"ask_max_mult": trial.suggest_float("ask_max_mult", lo_a, hi_a, step=1.0),
|
||
}
|
||
|
||
rec = _run_axis_study(
|
||
trades=trades, orig_cnt=orig_cnt, n_trials=n_trials,
|
||
suggest_fn=suggest, sim_fn=_sim_entry, skip_reject=True, lg=lg, axis_name="진입호가",
|
||
)
|
||
if rec.get("ok"):
|
||
p = rec["params"]
|
||
rec["params"] = {
|
||
"orderbook_filter_enabled": True,
|
||
"orderbook_max_spread_pct": p.get("max_spread_pct"),
|
||
"orderbook_min_bid_ask_ratio": p.get("min_bid_ask_ratio"),
|
||
"orderbook_entry_ask_max_mult": p.get("ask_max_mult"),
|
||
}
|
||
return rec
|
||
|
||
|
||
def _optimize_exit_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
|
||
from kis_trader.utils.env import get_env_float, get_env_int
|
||
|
||
def suggest(trial: optuna.Trial) -> Dict[str, Any]:
|
||
on = trial.suggest_categorical("exit_ob_enabled", [True, False])
|
||
return {
|
||
"exit_ob_enabled": on,
|
||
"exit_ob_min_hold_bars": trial.suggest_int(
|
||
"exit_ob_min_hold_bars",
|
||
int(get_env_int("OPTUNA_OB_EXIT_HOLD_MIN", 1)),
|
||
int(get_env_int("OPTUNA_OB_EXIT_HOLD_MAX", 5)),
|
||
),
|
||
"exit_ob_ratio_min": trial.suggest_float(
|
||
"exit_ob_ratio_min",
|
||
float(get_env_float("OPTUNA_OB_EXIT_RATIO_MIN", 0.2)),
|
||
float(get_env_float("OPTUNA_OB_EXIT_RATIO_MAX", 0.8)),
|
||
step=0.05,
|
||
),
|
||
"exit_ob_min_profit_pct": trial.suggest_float(
|
||
"exit_ob_min_profit_pct",
|
||
float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MIN", 0.003)),
|
||
float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MAX", 0.02)),
|
||
step=0.001,
|
||
),
|
||
"exit_ob_ma_window": trial.suggest_int(
|
||
"exit_ob_ma_window",
|
||
int(get_env_int("OPTUNA_OB_EXIT_MA_MIN", 3)),
|
||
int(get_env_int("OPTUNA_OB_EXIT_MA_MAX", 10)),
|
||
),
|
||
}
|
||
|
||
return _run_axis_study(
|
||
trades=trades, orig_cnt=orig_cnt, n_trials=n_trials,
|
||
suggest_fn=suggest, sim_fn=_sim_exit_ob, skip_reject=False, lg=lg, axis_name="익절호가",
|
||
)
|
||
|
||
|
||
def _optimize_stop_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
|
||
from kis_trader.utils.env import get_env_float, get_env_int
|
||
|
||
def suggest(trial: optuna.Trial) -> Dict[str, Any]:
|
||
on = trial.suggest_categorical("stop_ob_enabled", [True, False])
|
||
return {
|
||
"stop_ob_enabled": on,
|
||
"stop_ob_min_hold_bars": trial.suggest_int(
|
||
"stop_ob_min_hold_bars",
|
||
int(get_env_int("OPTUNA_OB_STOP_HOLD_MIN", 1)),
|
||
int(get_env_int("OPTUNA_OB_STOP_HOLD_MAX", 5)),
|
||
),
|
||
"stop_ob_ratio_min": trial.suggest_float(
|
||
"stop_ob_ratio_min",
|
||
float(get_env_float("OPTUNA_OB_STOP_RATIO_MIN", 0.2)),
|
||
float(get_env_float("OPTUNA_OB_STOP_RATIO_MAX", 0.8)),
|
||
step=0.05,
|
||
),
|
||
"stop_ob_min_loss_pct": trial.suggest_float(
|
||
"stop_ob_min_loss_pct",
|
||
float(get_env_float("OPTUNA_OB_STOP_LOSS_MIN", 0.001)),
|
||
float(get_env_float("OPTUNA_OB_STOP_LOSS_MAX", 0.02)),
|
||
step=0.001,
|
||
),
|
||
"stop_ob_ma_window": trial.suggest_int(
|
||
"stop_ob_ma_window",
|
||
int(get_env_int("OPTUNA_OB_STOP_MA_MIN", 3)),
|
||
int(get_env_int("OPTUNA_OB_STOP_MA_MAX", 10)),
|
||
),
|
||
}
|
||
|
||
return _run_axis_study(
|
||
trades=trades, orig_cnt=orig_cnt, n_trials=n_trials,
|
||
suggest_fn=suggest, sim_fn=_sim_stop_ob, skip_reject=False, lg=lg, axis_name="손절호가",
|
||
)
|
||
|
||
|
||
def recommend_orderbook_parameters(
|
||
strategy: str = "MOMENTUM",
|
||
n_trials: int = 0,
|
||
ob_table: Optional[str] = None,
|
||
history_source: Optional[str] = None,
|
||
ob_source: Optional[str] = None,
|
||
log: Optional[logging.Logger] = None,
|
||
raw_fills: Optional[List[Dict[str, Any]]] = None,
|
||
date_from: Optional[str] = None,
|
||
date_to: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""호가 후처리. raw_fills 있으면 그 체결만(백테 앵커). 없으면 trade_history(실매 참고행)."""
|
||
lg = log or logger
|
||
if int(n_trials or 0) <= 0:
|
||
n_trials = _ob_n_trials(1000)
|
||
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),
|
||
}
|
||
|
||
trades: List[TradeInfo] = []
|
||
lookback, horizon = _ob_lookback_horizon()
|
||
if raw_fills:
|
||
trades = raw_fills_to_ob_trades(
|
||
list(raw_fills),
|
||
db=db,
|
||
table=table,
|
||
cols=cols,
|
||
source_filter=source_filter,
|
||
)
|
||
else:
|
||
date_from_s = str(date_from or "").strip()[:10]
|
||
date_to_s = str(date_to or "").strip()[:10]
|
||
for dt_str in avail_dates:
|
||
day_hyphen = f"{dt_str[:4]}-{dt_str[4:6]}-{dt_str[6:]}"
|
||
if date_from_s and day_hyphen < date_from_s:
|
||
continue
|
||
if date_to_s and day_hyphen > date_to_s:
|
||
continue
|
||
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
|
||
snaps = _fetch_ob_snaps(
|
||
db, table, cols, source_filter, str(b["code"]), buy_dt, lookback, horizon,
|
||
)
|
||
ti = _tradeinfo_from_fill(
|
||
code=str(b["code"]),
|
||
name=str(b.get("name") or b["code"]),
|
||
buy_dt=buy_dt,
|
||
buy_price=float(b["buy_price"] or 0),
|
||
sell_price=float(b["sell_price"] or 0),
|
||
qty=int(b["qty"] or 0),
|
||
actual_pnl=float(b["realized_pnl"] or 0),
|
||
actual_profit_rate=float(b["profit_rate"] or 0),
|
||
snaps=snaps,
|
||
)
|
||
if ti:
|
||
trades.append(ti)
|
||
|
||
if len(trades) < 3:
|
||
lg.warning("⚠️ [%s] 호가 연제 가능한 실제 매수 건수(%s건)가 부족하여 최적화 생략.", strat_upper, len(trades))
|
||
return {"ok": False, "reason": "not_enough_trades", "trade_count": len(trades)}
|
||
|
||
orig_stats = _ob_orig_stats(trades)
|
||
orig_cnt = int(orig_stats["count"])
|
||
axis_n = _ob_axis_n_trials(int(n_trials))
|
||
can_exit_stop = strat_upper in ("MOMENTUM", "BREAKOUT")
|
||
|
||
entry_axis = _optimize_entry_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
|
||
exit_axis: Dict[str, Any]
|
||
stop_axis: Dict[str, Any]
|
||
if can_exit_stop:
|
||
exit_axis = _optimize_exit_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
|
||
stop_axis = _optimize_stop_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
|
||
else:
|
||
exit_axis = {"ok": False, "reason": "n/a_strategy", "params": {}, "recommended_stats": {}}
|
||
stop_axis = {"ok": False, "reason": "n/a_strategy", "params": {}, "recommended_stats": {}}
|
||
|
||
merged_params: Dict[str, Any] = {}
|
||
if entry_axis.get("ok"):
|
||
merged_params.update(entry_axis.get("params") or {})
|
||
if exit_axis.get("ok"):
|
||
merged_params.update(exit_axis.get("params") or {})
|
||
if stop_axis.get("ok"):
|
||
merged_params.update(stop_axis.get("params") or {})
|
||
|
||
rec_stats = dict(entry_axis.get("recommended_stats") or orig_stats)
|
||
lg.info(
|
||
"⚡ [호가 축분리] 전략=%s 모수=%d entry=%s exit=%s stop=%s",
|
||
strat_upper,
|
||
orig_cnt,
|
||
"ok" if entry_axis.get("ok") else entry_axis.get("reason"),
|
||
"ok" if exit_axis.get("ok") else exit_axis.get("reason"),
|
||
"ok" if stop_axis.get("ok") else stop_axis.get("reason"),
|
||
)
|
||
return {
|
||
"ok": bool(entry_axis.get("ok") or exit_axis.get("ok") or stop_axis.get("ok")),
|
||
"strategy": strat_upper,
|
||
"ob_table": table,
|
||
"n_trials": axis_n,
|
||
"trade_count": orig_cnt,
|
||
"orig_stats": orig_stats,
|
||
"recommended_stats": rec_stats,
|
||
"entry": entry_axis,
|
||
"exit": exit_axis,
|
||
"stop": stop_axis,
|
||
"params": merged_params,
|
||
}
|
||
|
||
|
||
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=0,
|
||
history_source=hist,
|
||
ob_source=ob_src,
|
||
log=lg,
|
||
date_from=str(out_data.get("start") or "") or None,
|
||
date_to=str(out_data.get("end") or "") or None,
|
||
)
|
||
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 _env_pfx(strat: str) -> str:
|
||
u = str(strat or "").strip().upper()
|
||
if u in ("SHORT", "TAIL"):
|
||
return "TAIL"
|
||
if u in ("SCALPING", "SCALP"):
|
||
return "SCALP"
|
||
return u
|
||
|
||
|
||
def _axis_params(rec: Optional[Dict[str, Any]], axis: str = "") -> Dict[str, Any]:
|
||
if not isinstance(rec, dict):
|
||
return {}
|
||
if axis:
|
||
nested = rec.get(axis)
|
||
if isinstance(nested, dict) and (nested.get("params") or nested.get("ok")):
|
||
return dict(nested.get("params") or {})
|
||
return dict(rec.get("params") or {})
|
||
|
||
|
||
def build_entry_ob_env_patch(rec: Dict[str, Any], strategy: str = "") -> Dict[str, str]:
|
||
"""진입 호가필터만 (*_ORDERBOOK_*)."""
|
||
strat = str(strategy or rec.get("strategy") or "").strip().upper()
|
||
pfx = _env_pfx(strat)
|
||
p = _axis_params(rec, "entry")
|
||
if not pfx or not p:
|
||
return {}
|
||
if p.get("orderbook_max_spread_pct") is None or p.get("orderbook_min_bid_ask_ratio") is None:
|
||
return {}
|
||
out = {
|
||
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 p.get("orderbook_entry_ask_max_mult") is not None:
|
||
out[f"{pfx}_ORDERBOOK_ENTRY_ASK_MAX_MULT"] = str(p["orderbook_entry_ask_max_mult"])
|
||
return out
|
||
|
||
|
||
def build_exit_ob_env_patch(rec: Dict[str, Any], strategy: str = "") -> Dict[str, str]:
|
||
"""익절 호가매도만 (*_EXIT_OB_*). MOMENTUM/BREAKOUT만."""
|
||
strat = str(strategy or rec.get("strategy") or "").strip().upper()
|
||
pfx = _env_pfx(strat)
|
||
if pfx not in ("MOMENTUM", "BREAKOUT"):
|
||
return {}
|
||
p = _axis_params(rec, "exit")
|
||
if not p or "exit_ob_enabled" not in p:
|
||
return {}
|
||
patch = {
|
||
f"{pfx}_EXIT_OB_ENABLED": "true" if p.get("exit_ob_enabled") else "false",
|
||
}
|
||
if p.get("exit_ob_enabled"):
|
||
if p.get("exit_ob_ratio_min") is not None:
|
||
patch[f"{pfx}_EXIT_OB_RATIO_MIN"] = str(p["exit_ob_ratio_min"])
|
||
if p.get("exit_ob_ma_window") is not None:
|
||
patch[f"{pfx}_EXIT_OB_MA_WINDOW"] = str(p["exit_ob_ma_window"])
|
||
if p.get("exit_ob_min_profit_pct") is not None:
|
||
patch[f"{pfx}_EXIT_OB_MIN_PROFIT_PCT"] = str(p["exit_ob_min_profit_pct"])
|
||
if p.get("exit_ob_min_hold_bars") is not None:
|
||
patch[f"{pfx}_EXIT_OB_MIN_HOLD_BARS"] = str(p["exit_ob_min_hold_bars"])
|
||
return patch
|
||
|
||
|
||
def build_stop_ob_env_patch(rec: Dict[str, Any], strategy: str = "") -> Dict[str, str]:
|
||
"""손절 호가 (*_STOP_OB_*). MOMENTUM/BREAKOUT만. 실매 엔진 미변경."""
|
||
strat = str(strategy or rec.get("strategy") or "").strip().upper()
|
||
pfx = _env_pfx(strat)
|
||
if pfx not in ("MOMENTUM", "BREAKOUT"):
|
||
return {}
|
||
p = _axis_params(rec, "stop")
|
||
if not p or "stop_ob_enabled" not in p:
|
||
return {}
|
||
patch = {
|
||
f"{pfx}_STOP_OB_ENABLED": "true" if p.get("stop_ob_enabled") else "false",
|
||
}
|
||
if p.get("stop_ob_enabled"):
|
||
if p.get("stop_ob_ratio_min") is not None:
|
||
patch[f"{pfx}_STOP_OB_RATIO_MIN"] = str(p["stop_ob_ratio_min"])
|
||
if p.get("stop_ob_ma_window") is not None:
|
||
patch[f"{pfx}_STOP_OB_MA_WINDOW"] = str(p["stop_ob_ma_window"])
|
||
if p.get("stop_ob_min_loss_pct") is not None:
|
||
patch[f"{pfx}_STOP_OB_MIN_LOSS_PCT"] = str(p["stop_ob_min_loss_pct"])
|
||
if p.get("stop_ob_min_hold_bars") is not None:
|
||
patch[f"{pfx}_STOP_OB_MIN_HOLD_BARS"] = str(p["stop_ob_min_hold_bars"])
|
||
return patch
|
||
|
||
|
||
def build_orderbook_env_patch(
|
||
rec: Dict[str, Any],
|
||
*,
|
||
include_stop: bool = False,
|
||
) -> Dict[str, str]:
|
||
"""CLI 호환: 진입+익절. STOP은 include_stop=True 일 때만."""
|
||
if not rec or not rec.get("ok"):
|
||
return {}
|
||
strat = str(rec.get("strategy") or "").strip().upper()
|
||
patch: Dict[str, str] = {}
|
||
patch.update(build_entry_ob_env_patch(rec, strat))
|
||
patch.update(build_exit_ob_env_patch(rec, strat))
|
||
if include_stop:
|
||
patch.update(build_stop_ob_env_patch(rec, strat))
|
||
return patch
|