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.
549 lines
20 KiB
Python
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
|