Files
kis_bot/kis_trader/backtest/ls_history_loaders.py
Your Name 8fbba264ba feat(옵투나·웹): 후처리 재탐색·ob_modes·적용감사·수집통계
- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그
- 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭
- 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 15:23:44 +09:00

252 lines
8.7 KiB
Python

"""
kis_trader/backtest/ls_history_loaders.py — ls_ws_candles / ls_ws_ticks 백테 로드
==============================================================================
``history_source=ls`` 일 때 키움 ``ws_*`` 대신 LS 테이블을 읽는다.
반환 스키마는 전략/엔진이 기대하는 candle_time(12)·tick_time(14) 으로 정규화.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple
from kis_trader.utils.logger import get_logger
logger = get_logger("kis_trader.ls_history_loaders")
def _ct_to_ls_dt(ct: str) -> str:
d = "".join(ch for ch in (ct or "") if ch.isdigit())[:12]
if len(d) < 12:
return ""
return f"{d[0:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:00"
def _ls_dt_to_ct(dt_s: str) -> str:
s = (dt_s or "").strip()
if len(s) >= 16 and s[4] == "-" and s[10] == " ":
return s[0:4] + s[5:7] + s[8:10] + s[11:13] + s[14:16]
digits = "".join(ch for ch in s if ch.isdigit())
return digits[:12]
def _row_to_strategy_candle(r: dict) -> dict:
ct = _ls_dt_to_ct(str(r.get("datetime") or ""))
return {
"candle_time": ct,
"open": float(r.get("open") or 0),
"high": float(r.get("high") or 0),
"low": float(r.get("low") or 0),
"close": float(r.get("close") or 0),
"volume": float(r.get("volume") or 0),
"is_confirmed": 1,
"source": "ls",
}
def load_ls_candles_by_code(
db,
start_key: str,
end_key: str,
*,
min_bars: int = 5,
tf_min: int = 1,
) -> Tuple[Dict[str, List[Dict]], int]:
"""ls_ws_candles → {code: [candle dict...]}, total_rows."""
s_dt = _ct_to_ls_dt(start_key)
e_dt = _ct_to_ls_dt(end_key)
if not s_dt or not e_dt:
return {}, 0
tf = max(1, int(tf_min or 1))
mb = max(1, int(min_bars or 5))
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ls_ws_candles WHERE tf_min=%s "
"AND datetime >= %s AND datetime <= %s ORDER BY code",
[tf, s_dt, e_dt],
).fetchall()
codes = [r["code"] for r in (codes_raw or [])]
candles_by_code: Dict[str, List[Dict]] = {}
total = 0
for code in codes:
rows = db.conn.execute(
"SELECT datetime, open, high, low, close, volume "
"FROM ls_ws_candles WHERE tf_min=%s AND code=%s "
"AND datetime >= %s AND datetime <= %s "
"ORDER BY datetime ASC",
[tf, code, s_dt, e_dt],
).fetchall()
if len(rows or []) < mb:
continue
bars = [_row_to_strategy_candle(dict(r)) for r in rows]
bars = [b for b in bars if len(str(b.get("candle_time") or "")) >= 12]
if len(bars) < mb:
continue
candles_by_code[code] = bars
total += len(bars)
return candles_by_code, total
def prepend_ls_candle_warmup(
db,
candles_by_code: Dict[str, List[Dict]],
period_start_key: str,
warmup_bars: int,
*,
tf_min: int = 1,
) -> int:
"""기간 시작 전 N봉 prepend (RSI 등)."""
wb = max(0, int(warmup_bars or 0))
if wb <= 0 or not period_start_key or not candles_by_code:
return 0
ps = str(period_start_key)[:12]
ps_dt = _ct_to_ls_dt(ps)
if not ps_dt:
return 0
tf = max(1, int(tf_min or 1))
total_prepended = 0
for code, rows in list(candles_by_code.items()):
if not rows:
continue
first_period_idx = None
for i, r in enumerate(rows):
ct = str(r.get("candle_time") or "")
if ct >= ps:
first_period_idx = i
break
if first_period_idx is None or first_period_idx > 0:
continue
first_ct = str(rows[first_period_idx].get("candle_time") or "")
if not first_ct:
continue
first_dt = _ct_to_ls_dt(first_ct)
warm_rows = db.conn.execute(
"SELECT datetime, open, high, low, close, volume "
"FROM ls_ws_candles WHERE tf_min=%s AND code=%s "
"AND datetime < %s ORDER BY datetime DESC LIMIT %s",
[tf, code, first_dt, wb],
).fetchall()
if not warm_rows:
continue
prefix = [_row_to_strategy_candle(dict(r)) for r in reversed(list(warm_rows))]
candles_by_code[code] = prefix + [dict(r) for r in rows]
total_prepended += len(prefix)
return total_prepended
def _iter_day_chunks_dt(s_dt: str, e_dt: str) -> Iterator[Tuple[str, str]]:
"""datetime 문자열 범위를 달력일 단위로."""
try:
d0 = datetime.strptime(s_dt[:10], "%Y-%m-%d")
d1 = datetime.strptime(e_dt[:10], "%Y-%m-%d")
except ValueError:
return
cur = d0
while cur <= d1:
day = cur.strftime("%Y-%m-%d")
chunk_s = max(s_dt, f"{day} 00:00:00")
chunk_e = min(e_dt, f"{day} 23:59:59")
if chunk_s <= chunk_e:
yield chunk_s, chunk_e
cur += timedelta(days=1)
def _ts_to_tick_time(ts) -> str:
if isinstance(ts, datetime):
return ts.strftime("%Y%m%d%H%M%S")
s = str(ts or "").strip()
digits = "".join(ch for ch in s if ch.isdigit())
if len(digits) >= 14:
return digits[:14]
# "YYYY-MM-DD HH:MM:SS"
if len(s) >= 19 and s[4] == "-":
return (
s[0:4] + s[5:7] + s[8:10]
+ s[11:13] + s[14:16] + s[17:19]
)
return digits[:14].ljust(14, "0")
def load_ls_ticks_by_code(
db,
start_key: str,
end_key: str,
codes: Optional[Set[str]] = None,
) -> Tuple[Dict[str, Dict[str, List[Dict[str, Any]]]], int]:
"""
ls_ws_ticks → {code: {minute_key: [tick...]}}.
tick 스키마: tick_time/price/volume/source(=ls)/_lag_sec(가능 시)
"""
from kis_trader.engine.feed_fallback import packet_lag_seconds
s12 = (start_key or "")[:12]
e12 = (end_key or "")[:12]
s_dt = _ct_to_ls_dt(s12)
e_dt = _ct_to_ls_dt(e12)
if not s_dt or not e_dt:
return {}, 0
# end 분 포함 → 초 59
e_dt = e_dt[:17] + "59" if len(e_dt) >= 17 else e_dt
out: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(dict)
total = 0
code_list = sorted(codes) if codes else None
for chunk_s, chunk_e in _iter_day_chunks_dt(s_dt, e_dt):
try:
if code_list:
# 종목 수가 많으면 IN 절 — 청크당 한 번
ph = ",".join(["%s"] * len(code_list))
rows = db.conn.execute(
f"SELECT code, ts, price, volume, chetime FROM ls_ws_ticks "
f"WHERE ts >= %s AND ts <= %s AND code IN ({ph})",
[chunk_s, chunk_e, *code_list],
).fetchall()
else:
rows = db.conn.execute(
"SELECT code, ts, price, volume, chetime FROM ls_ws_ticks "
"WHERE ts >= %s AND ts <= %s",
[chunk_s, chunk_e],
).fetchall()
except Exception as e:
logger.warning("ls_ws_ticks day=%s 조회 실패: %s", chunk_s[:10], e)
continue
for r in rows or []:
code = str(r["code"]).strip()
tt = _ts_to_tick_time(r.get("ts"))
che = str(r.get("chetime") or "").strip()
che_d = "".join(ch for ch in che if ch.isdigit())
# chetime 이 HHMMSS 이면 수신 ts 날짜와 합쳐 14자리
if len(che_d) >= 14:
tt = che_d[:14]
elif len(che_d) >= 6 and len(tt) >= 8:
tt = (tt[:8] + che_d[-6:]).ljust(14, "0")[:14]
if len(tt) < 12:
continue
minute_key = tt[:12]
lag = None
try:
recv_raw = r.get("ts")
if isinstance(recv_raw, datetime):
recv_dt = recv_raw
else:
recv_s = str(recv_raw or "")[:19]
recv_dt = datetime.strptime(recv_s, "%Y-%m-%d %H:%M:%S")
lag_f = packet_lag_seconds(tt, now_dt=recv_dt)
if lag_f is not None:
lag = int(lag_f)
except Exception:
lag = None
tick = {
"code": code,
"tick_time": tt.ljust(14, "0")[:14],
"price": float(r.get("price") or 0),
"volume": int(float(r.get("volume") or 0)),
"source": "ls",
"_lag_sec": lag,
}
bucket = out[code].setdefault(minute_key, [])
bucket.append(tick)
total += 1
for _code, minutes in out.items():
for _mk, ticks in minutes.items():
ticks.sort(key=lambda t: str(t.get("tick_time") or ""))
return dict(out), total