""" 거래내역 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