Files
kis_bot/kis_trader/backtest/trade_orderbook_enrich.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

420 lines
14 KiB
Python

"""
거래내역 UI용 — 매수/매도 시각 근처 호가 스냅 부착.
필터 ON/OFF 와 무관: 수집된 ws_orderbook / ls_ws_orderbook 으로
진입(매수) · 청산(매도) 시점 유동성을 각각 보여 준다.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence, Tuple
logger = logging.getLogger("trade_orderbook_enrich")
# 표시용 매칭 창 — 체결≠호가저장 이므로 실매 UI는 ±15분까지 근처 기록 허용
_STRICT_DELTA_SEC = 180 # 판정(filter_eval) 정밀 창
_SOFT_DELTA_SEC = 900 # 본체·표시용 완화 창 (±15분)
_BODY_PREF_DELTA_SEC = 120 # 본체 우선 창
_EXIT_OB_OR_DEFAULT = 0.4 # L3 OR 임계 표시용 (MOMENTUM_EXIT_OB_RATIO_MIN 기본)
def _strategy_canon(strategy: str) -> str:
s = (strategy or "").strip().upper()
if s in ("SHORT", "TAIL_CATCH", "TAIL"):
return "TAIL"
if s in ("BO", "BREAKOUT"):
return "BREAKOUT"
if s in ("MOM", "MOMENTUM"):
return "MOMENTUM"
if s in ("SCALP", "SCALPING", "REVERSAL"):
return "SCALP"
if s.startswith("US_"):
return "US"
return s
def _ts14(raw: Any) -> str:
if raw is None:
return ""
s = str(raw).strip()
if not s:
return ""
s = (
s.replace("-", "")
.replace(":", "")
.replace(" ", "")
.replace("T", "")
.replace(".", "")
)
if len(s) < 8:
return ""
return (s + "000000")[:14]
def _parse14(ts14: str) -> Optional[datetime]:
st = (ts14 or "").strip()
if len(st) < 12:
return None
try:
return datetime.strptime(st[:14], "%Y%m%d%H%M%S")
except ValueError:
try:
return datetime.strptime(st[:12], "%Y%m%d%H%M")
except ValueError:
return None
def _entry_ts14(trade: Dict[str, Any]) -> str:
for k in ("buy_time", "entry_time", "buy_date"):
t = _ts14(trade.get(k))
if t:
return t
return ""
def _exit_ts14(trade: Dict[str, Any]) -> str:
for k in ("sell_time", "exit_time", "sell_date"):
t = _ts14(trade.get(k))
if t:
return t
return ""
def _spread_pct(best_bid: float, best_ask: float) -> Optional[float]:
if best_bid <= 0 or best_ask <= 0:
return None
mid = (best_bid + best_ask) / 2.0
if mid <= 0:
return None
return (best_ask - best_bid) / mid * 100.0
def _or_ratio(total_bid: float, total_ask: float) -> Optional[float]:
try:
ask = float(total_ask or 0)
bid = float(total_bid or 0)
except (TypeError, ValueError):
return None
if ask <= 0:
return None
return bid / ask
def _row_to_ob(
row: Dict[str, Any],
*,
delta_sec: int,
lim_spread_pct: float,
lim_ratio: float,
lim_or_ratio: float,
side: str,
) -> Dict[str, Any]:
bid = float(row.get("best_bid") or 0)
ask = float(row.get("best_ask") or 0)
bid_l3 = int(row.get("bid_qty_l3") or 0)
ask_l3 = int(row.get("ask_qty_l3") or 0)
ratio = (bid_l3 / ask_l3) if ask_l3 > 0 else None
tot_bid = int(row.get("total_bid_qty") or 0)
tot_ask = int(row.get("total_ask_qty") or 0)
or_r = _or_ratio(tot_bid, tot_ask)
rej = (row.get("reject_code") or "").strip() or None
msg = (row.get("reject_msg") or "").strip() or None
src = (row.get("source") or "").strip()
if side == "entry" and src == "filter_eval":
verdict = rej or "PASS"
else:
verdict = "BODY" # 주기 스냅 — 판정 메타 없음 (매도도 본체 위주)
near_only = int(delta_sec) > _STRICT_DELTA_SEC
# ratio = L3 매수/매도 잔량비, or_ratio = 전체 잔량비
# mid_ratio/whale_ratio = 웹 표(M/W) 호환 별칭 (동일 값)
ratio_r = round(ratio, 3) if ratio is not None else None
or_r_r = round(or_r, 3) if or_r is not None else None
return {
"side": side,
"snap_time": str(row.get("snap_time") or "")[:14],
"source": src,
"strategy": row.get("strategy"),
"best_bid": int(bid) if bid else 0,
"best_ask": int(ask) if ask else 0,
"spread_pct": round(_spread_pct(bid, ask) or 0.0, 3),
"bid_qty_l3": bid_l3,
"ask_qty_l3": ask_l3,
"ratio": ratio_r,
"mid_ratio": ratio_r,
"total_bid_qty": tot_bid,
"total_ask_qty": tot_ask,
"or_ratio": or_r_r,
"whale_ratio": or_r_r,
"reject_code": rej,
"reject_msg": msg,
"verdict": verdict,
"delta_sec": int(delta_sec),
"matched": True,
"near_only": bool(near_only),
"lim_spread_pct": float(lim_spread_pct),
"lim_ratio": float(lim_ratio),
"lim_or_ratio": float(lim_or_ratio),
}
def _pick_best(
candidates: Sequence[Tuple[int, Dict[str, Any]]],
*,
prefer_strategy: str,
prefer_body: bool = False,
) -> Optional[Tuple[int, Dict[str, Any]]]:
if not candidates:
return None
def _fe(c: Tuple[int, Dict[str, Any]]) -> bool:
return (c[1].get("source") or "") == "filter_eval"
def _is_body(c: Tuple[int, Dict[str, Any]]) -> bool:
# filter_eval = 진입 TRIGGER 판정 스냅 · 그 외(kiwoom_0d·ls_*)는 본체
return (c[1].get("source") or "") != "filter_eval"
# 매도: filter_eval(진입판정)보다 본체 시계열 우선
if prefer_body:
body_pref = [c for c in candidates if _is_body(c) and c[0] <= _BODY_PREF_DELTA_SEC]
if body_pref:
return min(body_pref, key=lambda x: x[0])
body_soft = [c for c in candidates if _is_body(c) and c[0] <= _SOFT_DELTA_SEC]
if body_soft:
return min(body_soft, key=lambda x: x[0])
fe_any = [c for c in candidates if _fe(c) and c[0] <= _SOFT_DELTA_SEC]
if fe_any:
return min(fe_any, key=lambda x: x[0])
return None
# 매수: 기존 우선순위 (filter_eval → 본체)
fe_match = [
c for c in candidates
if _fe(c)
and c[0] <= _STRICT_DELTA_SEC
and _strategy_canon(str(c[1].get("strategy") or "")) == prefer_strategy
]
if fe_match:
return min(fe_match, key=lambda x: x[0])
fe_any = [c for c in candidates if _fe(c) and c[0] <= _STRICT_DELTA_SEC]
if fe_any:
return min(fe_any, key=lambda x: x[0])
body_pref = [c for c in candidates if _is_body(c) and c[0] <= _BODY_PREF_DELTA_SEC]
if body_pref:
return min(body_pref, key=lambda x: x[0])
fe_soft = [c for c in candidates if _fe(c) and c[0] <= _SOFT_DELTA_SEC]
if fe_soft:
return min(fe_soft, key=lambda x: x[0])
body_soft = [c for c in candidates if _is_body(c) and c[0] <= _SOFT_DELTA_SEC]
if body_soft:
return min(body_soft, key=lambda x: x[0])
return None
def _load_lims(prefer: str) -> Tuple[float, float, float]:
lim_spread = 0.45
lim_ratio = 0.85
lim_or = float(_EXIT_OB_OR_DEFAULT)
try:
from kis_trader.engine.orderbook_env import (
OB_DEFAULT_MAX_SPREAD_PCT,
OB_DEFAULT_MIN_BID_ASK_RATIO,
load_orderbook_threshold_cfg as _load_ob_cfg,
)
lim_spread = float(OB_DEFAULT_MAX_SPREAD_PCT)
lim_ratio = float(OB_DEFAULT_MIN_BID_ASK_RATIO)
if prefer and prefer != "US":
cfg = _load_ob_cfg(prefer)
lim_spread = float(cfg.get("max_spread_pct") or lim_spread)
lim_ratio = float(cfg.get("min_bid_ask_ratio") or lim_ratio)
except Exception:
pass
try:
from kis_trader.utils.env import get_env_float
lim_or = float(get_env_float("MOMENTUM_EXIT_OB_RATIO_MIN", _EXIT_OB_OR_DEFAULT))
except Exception:
pass
return lim_spread, lim_ratio, lim_or
def _fetch_ob_rows(
db: Any,
code_list: List[str],
lo: str,
hi: str,
) -> List[Dict[str, Any]]:
conn = getattr(db, "conn", None) or db
placeholders = ",".join(["%s"] * len(code_list))
params: List[Any] = ["KR", *code_list, lo, hi]
sql_kw = (
f"SELECT code, snap_time, best_bid, best_ask, total_bid_qty, total_ask_qty, "
f"bid_qty_l3, ask_qty_l3, source, strategy, reject_code, reject_msg "
f"FROM ws_orderbook WHERE market = %s AND code IN ({placeholders}) "
f"AND snap_time >= %s AND snap_time <= %s "
f"AND source IN ('filter_eval', 'kiwoom_0d') "
f"ORDER BY code, snap_time"
)
rows = [dict(r) for r in conn.execute(sql_kw, params).fetchall()]
sql_ls = (
f"SELECT code, snap_time, best_bid, best_ask, total_bid_qty, total_ask_qty, "
f"bid_qty_l3, ask_qty_l3, source, "
f"NULL AS strategy, NULL AS reject_code, NULL AS reject_msg "
f"FROM ls_ws_orderbook WHERE market = %s AND code IN ({placeholders}) "
f"AND snap_time >= %s AND snap_time <= %s "
f"ORDER BY code, snap_time"
)
try:
ls_rows = [dict(r) for r in conn.execute(sql_ls, params).fetchall()]
if ls_rows:
rows.extend(ls_rows)
rows.sort(
key=lambda r: (
str(r.get("code") or ""),
str(r.get("snap_time") or ""),
)
)
except Exception as e_ls:
logger.debug("ls_ws_orderbook 조회 스킵: %s", e_ls)
return rows
def enrich_trades_with_entry_orderbook(
db: Any,
trades: List[Dict[str, Any]],
*,
strategy_hint: str = "",
) -> List[Dict[str, Any]]:
"""하위호환 — 매수·매도 호가 모두 부착."""
return enrich_trades_with_orderbook(db, trades, strategy_hint=strategy_hint)
def enrich_trades_with_orderbook(
db: Any,
trades: List[Dict[str, Any]],
*,
strategy_hint: str = "",
) -> List[Dict[str, Any]]:
"""거래 dict 에 ``entry_ob`` · ``exit_ob`` 를 in-place 부착."""
if not trades:
return trades
hint = _strategy_canon(strategy_hint)
if hint == "US":
for t in trades:
t["entry_ob"] = None
t["exit_ob"] = None
return trades
# (trade_idx, trade, code, entry_dt|None, exit_dt|None)
keyed: List[Tuple[int, Dict[str, Any], str, Optional[datetime], Optional[datetime]]] = []
codes = set()
t_min: Optional[datetime] = None
t_max: Optional[datetime] = None
def _bump(dt: Optional[datetime]) -> None:
nonlocal t_min, t_max
if dt is None:
return
if t_min is None or dt < t_min:
t_min = dt
if t_max is None or dt > t_max:
t_max = dt
for i, t in enumerate(trades):
if _strategy_canon(str(t.get("strategy") or strategy_hint)) == "US":
t["entry_ob"] = None
t["exit_ob"] = None
continue
code = str(t.get("code") or "").strip()
edt = _parse14(_entry_ts14(t))
xdt = _parse14(_exit_ts14(t))
if not code or (edt is None and xdt is None):
t["entry_ob"] = None
t["exit_ob"] = None
continue
keyed.append((i, t, code, edt, xdt))
codes.add(code)
_bump(edt)
_bump(xdt)
if not keyed or t_min is None or t_max is None:
for t in trades:
t.setdefault("entry_ob", None)
t.setdefault("exit_ob", None)
return trades
lo = (t_min - timedelta(seconds=_SOFT_DELTA_SEC)).strftime("%Y%m%d%H%M%S")
hi = (t_max + timedelta(seconds=_SOFT_DELTA_SEC)).strftime("%Y%m%d%H%M%S")
code_list = sorted(codes)
try:
rows = _fetch_ob_rows(db, code_list, lo, hi)
except Exception as e:
logger.warning("orderbook enrich 조회 실패: %s", e)
for t in trades:
t.setdefault("entry_ob", None)
t.setdefault("exit_ob", None)
return trades
by_code: Dict[str, List[Dict[str, Any]]] = {}
for r in rows:
c = str(r.get("code") or "").strip()
if not c:
continue
by_code.setdefault(c, []).append(r)
for _i, t, code, edt, xdt in keyed:
prefer = _strategy_canon(str(t.get("strategy") or strategy_hint) or hint)
lim_spread, lim_ratio, lim_or = _load_lims(prefer)
def _cands_for(dt: Optional[datetime]) -> List[Tuple[int, Dict[str, Any]]]:
if dt is None:
return []
out: List[Tuple[int, Dict[str, Any]]] = []
for r in by_code.get(code, []):
rdt = _parse14(str(r.get("snap_time") or ""))
if rdt is None:
continue
delta = abs(int((rdt - dt).total_seconds()))
if delta > _SOFT_DELTA_SEC:
continue
out.append((delta, r))
return out
ep = _pick_best(_cands_for(edt), prefer_strategy=prefer, prefer_body=False)
if ep:
delta, row = ep
t["entry_ob"] = _row_to_ob(
row,
delta_sec=delta,
lim_spread_pct=lim_spread,
lim_ratio=lim_ratio,
lim_or_ratio=lim_or,
side="entry",
)
else:
t["entry_ob"] = None
# 미청산(보유중) — 매도호가 없음
if xdt is None:
t["exit_ob"] = None
else:
xp = _pick_best(_cands_for(xdt), prefer_strategy=prefer, prefer_body=True)
if xp:
delta, row = xp
t["exit_ob"] = _row_to_ob(
row,
delta_sec=delta,
lim_spread_pct=lim_spread,
lim_ratio=lim_ratio,
lim_or_ratio=lim_or,
side="exit",
)
else:
t["exit_ob"] = None
for t in trades:
t.setdefault("entry_ob", None)
t.setdefault("exit_ob", None)
return trades