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

832 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
꼬리잡기 백테스트 공통 로더 — backtest_web(api/backtest/tail) 과 tail_param_search 가
동일한 캔들·유니버스·손익 계산을 쓰도록 단일 진입점.
"""
from __future__ import annotations
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
import kis_trader.engine.tail_engine as te
from kis_trader.backtest.backtest_portfolio_common import resolve_trigger_snapshots_for_backtest
from kis_trader.engine.indicator_cache import (
materialize_ws_candles_batch,
ws_candles_select_indicator_cols,
)
TAIL_STRATEGY_ID = "SHORT"
VALID_TIMEFRAMES = (3, 5, 15, 60)
# 종목×기간일 REST 웜업 캐시 (프로세스 메모리만 — DB 미기록)
_TAIL_REST_WARMUP_PREFIX_CACHE: Dict[Tuple[str, str, int], List[Dict[str, Any]]] = {}
def tail_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] = None) -> bool:
"""백테 유니버스: 1분 슬롯 대신 초단위 스캔시각 타임라인 (기본 ON, 실매 정합).
실매 꼬리잡기는 봉 마감 시점의 조건검색 유니버스를 본다. 1분 슬롯(strict lag)은
편입을 최대 1분 늦춰 실매와 어긋난다. 초단위 타임라인은 그 봉 마감(HH:MM:59)
직전 최신 스냅샷을 그대로 써 실매 ``get_universe_at`` 와 정합.
끄려면 env TAIL_BACKTEST_UNIVERSE_SCAN_AT=0.
"""
if params is not None and params.get("backtest_universe_scan_at") is not None:
s = str(params.get("backtest_universe_scan_at")).strip().lower()
if s in ("1", "true", "t", "y", "yes", "on"):
return True
if s in ("0", "false", "f", "n", "no", "off", ""):
return False
from kis_trader.utils.env import get_env_bool
return get_env_bool("TAIL_BACKTEST_UNIVERSE_SCAN_AT", True)
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
"""YYYY-MM-DD → candle_time 키 및 ymd."""
start_key = start.replace("-", "") + "0000"
end_key = end.replace("-", "") + "2359"
return start_key, end_key, start_key[:8], end_key[:8]
def tail_backtest_candle_warmup_bars() -> int:
"""백테 지표 warm-up — 실매 ``get_candles(..., n=50)`` 과 동일하게 전일 N봉(전략 TF).
꼬리 기본 TF=3분 → 50봉 ≈ 실매 RAM 조회 길이. RSI·패턴은 확정봉 ≥20 필요.
"""
from kis_trader.utils.env import get_env_int
return max(0, int(get_env_int("TAIL_BACKTEST_CANDLE_WARMUP_BARS", 50)))
def _tail_rows_have_prev_day(rows: List[Dict], period_day: str) -> bool:
pd = str(period_day or "")[:8]
if not pd:
return True
for r in rows or []:
ct = str(r.get("candle_time") or "")
if len(ct) >= 8 and ct[:8] < pd:
return True
return False
def prepend_tail_candle_warmup(
db,
candles_by_code: Dict[str, List[Dict]],
period_start_key: str,
*,
timeframe: int = 3,
warmup_bars: Optional[int] = None,
peak_sel: str = "",
) -> int:
"""
``period_start_key`` 이전 N봉(전략 TF)을 종목별로 prepend.
지표·패턴용 — 엔진 ``all_times`` 는 기간일만 쓰도록 ``_bt_period_start_ymd`` 로 걸러짐.
"""
wb = (
tail_backtest_candle_warmup_bars()
if warmup_bars is None
else max(0, int(warmup_bars))
)
if wb <= 0 or db is None or not period_start_key:
return 0
tf = int(timeframe)
if tf not in VALID_TIMEFRAMES:
tf = 3
ps = str(period_start_key)[:12]
ind_cols = ws_candles_select_indicator_cols(db)
total_prepended = 0
from kis_trader.backtest.bt_candle_source import fetch_ws_candles_warmup_before
for code, rows in list(candles_by_code.items()):
if not rows:
continue
# 이미 기간 이전 봉이 있으면 skip
if _tail_rows_have_prev_day(rows, ps[:8]):
continue
first_ct = ""
for r in rows:
ct = str(r.get("candle_time") or "")
if ct >= ps:
first_ct = ct
break
if not first_ct:
continue
warm_rows = fetch_ws_candles_warmup_before(
db, code, tf, first_ct, wb,
extra_select=ind_cols,
peak_sel=peak_sel,
)
if not warm_rows:
continue
prefix = warm_rows
candles_by_code[code] = prefix + [dict(r) for r in rows]
total_prepended += len(prefix)
if total_prepended > 0:
materialize_ws_candles_batch(db, candles_by_code, tf)
return total_prepended
def inject_tail_rest_warmup_memory(
candles_by_code: Dict[str, List[Dict]],
period_start_key: str,
*,
timeframe: int = 3,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> Dict[str, int]:
"""
DB 전일봉이 없을 때 키움 ka10080 REST 1회 → 1분봉 → TF 롤업 → 메모리 prepend.
- 실매: ``SHORT_GAP_FILL_LIMIT``(기본 150) 1M/3M 갭보정
- 백테: ``TAIL_BACKTEST_REST_WARMUP_BARS`` 기본 **150**(1분) → 3분 ≈50봉
(= 실매 ``get_candles(n=50)`` 근사). 모멘텀 REST 기본 700(1분·HTS E)과 다름.
- DB INSERT 없음.
"""
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int
from kis_trader.utils.logger import get_logger
from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf
log = get_logger("kis_trader.backtest.tail")
stats = {"need": 0, "ok": 0, "fail": 0, "cache_hit": 0, "bars": 0, "skipped": 0}
if not get_env_bool("TAIL_BACKTEST_REST_WARMUP", True):
stats["skipped"] = 1
return stats
ps = str(period_start_key or "")[:12]
if len(ps) < 8 or not candles_by_code:
return stats
period_day = ps[:8]
tf = int(timeframe) if int(timeframe) in VALID_TIMEFRAMES else 3
if universe_by_slot:
target: Set[str] = set()
for codes in universe_by_slot.values():
for c in codes or []:
if c:
target.add(str(c).strip())
target &= set(candles_by_code.keys())
else:
target = set(candles_by_code.keys())
need_codes = [
c for c in sorted(target)
if not _tail_rows_have_prev_day(candles_by_code.get(c) or [], period_day)
]
stats["need"] = len(need_codes)
if not need_codes:
return stats
max_codes = int(get_env_int("TAIL_BACKTEST_REST_MAX_CODES", 0))
if max_codes > 0:
need_codes = need_codes[:max_codes]
# 1분봉 개수 — 전일 세션 커버용 (장중 1분≈380봉). 기본 450.
# (150은 당일 최근만 닿아 prev_day 판정 실패 → REST 무의미. 모멘텀 700보다 작고
# SHORT_GAP_FILL_LIMIT(150·3분)≈450·1분 과 맞춤.)
n_1m = max(
50,
int(get_env_int("TAIL_BACKTEST_REST_WARMUP_BARS", 450)),
)
sleep_sec = float(get_env_float("TAIL_BACKTEST_REST_SLEEP_SEC", 0.25))
from kis_trader.backtest.momentum_backtest_common import _kiwoom_gap_credentials
from kis_trader.ws.kis_ws import get_kiwoom_candles_df
kw_key, kw_secret, is_mock = _kiwoom_gap_credentials()
if not kw_key or not kw_secret:
log.warning("⚠️ 꼬리 REST 웜업 스킵 — 키움 앱키/시크릿 없음")
stats["fail"] = len(need_codes)
return stats
log.info(
"📡 꼬리 REST 웜업(메모리): 전일봉 부족 %d종목 · 1M n=%d%dM 롤업 (DB 미기록)",
len(need_codes), n_1m, tf,
)
for i, code in enumerate(need_codes):
rows = candles_by_code.get(code) or []
if not rows:
stats["fail"] += 1
continue
cache_key = (code, period_day, tf)
cached = _TAIL_REST_WARMUP_PREFIX_CACHE.get(cache_key)
if cached is not None:
stats["cache_hit"] += 1
prefix = [dict(r) for r in cached]
else:
try:
df = get_kiwoom_candles_df(
code, 1, kw_key, kw_secret, is_mock=is_mock, n=n_1m,
)
except Exception as e:
log.warning("⚠️ 꼬리 REST 웜업 실패 %s: %s", code, e)
stats["fail"] += 1
continue
if df is None or getattr(df, "empty", True):
stats["fail"] += 1
continue
first_ct = ""
for r in rows:
ct = str(r.get("candle_time") or "")
if ct >= ps:
first_ct = ct[:12]
break
if not first_ct:
first_ct = ps
existing = {str(r.get("candle_time") or "")[:12] for r in rows}
bars_1m: List[Dict[str, Any]] = []
try:
for _, rec in df.iterrows():
t = str(rec.get("time") or "")[:12]
if len(t) < 12 or t >= first_ct:
continue
op = float(rec.get("open") or 0)
if op <= 0:
continue
bars_1m.append({
"candle_time": t,
"open": op,
"high": float(rec.get("high") or op),
"low": float(rec.get("low") or op),
"close": float(rec.get("close") or op),
"volume": int(float(rec.get("volume") or 0)),
"is_confirmed": 1,
"_rest_warmup": 1,
})
except Exception as e:
log.warning("⚠️ 꼬리 REST 웜업 파싱 실패 %s: %s", code, e)
stats["fail"] += 1
continue
bars_1m.sort(key=lambda x: str(x.get("candle_time") or ""))
rolled = rollup_1m_bars_to_tf(bars_1m, tf) if tf != 1 else bars_1m
prefix = [
dict(r) for r in rolled
if str(r.get("candle_time") or "")[:12] < first_ct
and str(r.get("candle_time") or "")[:12] not in existing
]
_TAIL_REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix]
if sleep_sec > 0 and i + 1 < len(need_codes):
time.sleep(sleep_sec)
if not prefix or not _tail_rows_have_prev_day(prefix, period_day):
stats["fail"] += 1
continue
candles_by_code[code] = [dict(r) for r in prefix] + [dict(r) for r in rows]
stats["ok"] += 1
stats["bars"] += len(prefix)
log.info(
"✅ 꼬리 REST 웜업 완료: ok=%d fail=%d cache=%d bars=%d",
stats["ok"], stats["fail"], stats["cache_hit"], stats["bars"],
)
return stats
def resolve_tail_universe(
start_ymd: str,
end_ymd: str,
*,
use_saved_history: bool,
strategy_id: str = TAIL_STRATEGY_ID,
history_source: str = "kiwoom",
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
"""
backtest_web._resolve_backtest_universe(꼬리) 와 동일.
Returns:
(universe_by_slot, source_label, history_slot_count, scan_interval_min)
"""
if use_saved_history and strategy_id:
try:
from kis_trader.database.db_manager import get_db as _get_ext_db
from kis_trader.backtest.universe_timeline import (
universe_exit_debounce_sec_for_strategy,
)
from kis_trader.backtest.universe_history_source import (
history_source_label,
resolve_backtest_universe_history_source,
)
hs = resolve_backtest_universe_history_source(history_source)
history = _get_ext_db().get_universe_by_candle_time(
strategy_id=strategy_id,
start_ymd=start_ymd,
end_ymd=end_ymd,
exit_debounce_sec=universe_exit_debounce_sec_for_strategy(strategy_id),
history_source=hs,
)
if history:
return history, history_source_label(hs), len(history), 1
except Exception:
pass
return None, "all", 0, 1
def load_tail_candles_by_code(
db,
start_key: str,
end_key: str,
timeframe: int,
rsi_period: int = 14,
) -> Tuple[Dict[str, List[Dict]], int, bool]:
"""
ws_candles 전 종목 로드 (backtest_web api/backtest/tail 과 동일 쿼리).
유니버스 필터는 엔진 run_tail_backtest 에서 슬롯별 적용.
``TAIL_BT_SYNTH_3M_FROM_1M``(기본 true) 이고 timeframe=3 이면
DB 3분 구멍을 1분봉 롤업으로 보강 (실매 RAM 롤업과 동일 규칙).
"""
from kis_trader.utils.env import get_env_bool
tail_tf = int(timeframe)
if tail_tf not in VALID_TIMEFRAMES:
raise ValueError(f"timeframe 은 {VALID_TIMEFRAMES} 중 하나여야 합니다")
has_holding_peak = False
try:
wc_cols = db.conn.get_columns("ws_candles")
has_holding_peak = "holding_peak" in wc_cols
except Exception:
has_holding_peak = False
peak_sel = ", holding_peak" if has_holding_peak else ""
ind_cols = ws_candles_select_indicator_cols(db)
from kis_trader.backtest.bt_candle_source import (
fetch_ws_candles_for_code,
list_ws_candle_codes,
)
codes = list_ws_candle_codes(db, tail_tf, start_key, end_key)
# 3분 합성 시 1분만 있는 종목도 후보에 포함
synth_on = (
tail_tf == 3
and get_env_bool("TAIL_BT_SYNTH_3M_FROM_1M", True)
)
if synth_on:
try:
codes_1m = list_ws_candle_codes(db, 1, start_key, end_key)
for c in codes_1m:
if c not in codes:
codes.append(c)
codes = sorted(set(codes))
except Exception:
pass
candles_by_code: Dict[str, List[Dict]] = {}
total_candles = 0
min_bars = int(rsi_period) + 5
synth_filled_total = 0
for code in codes:
bars = fetch_ws_candles_for_code(
db, code, tail_tf, start_key, end_key,
extra_select=ind_cols,
peak_sel=peak_sel,
confirmed_only=True,
)
if synth_on:
bars, n_fill = _synth_fill_3m_holes_from_1m(
db, code, bars, start_key, end_key, peak_sel=peak_sel,
)
synth_filled_total += n_fill
# 기간 내 1봉 이상이면 일단 적재 — 전일 웜업 후 min_bars 재필터
if len(bars) < 1:
continue
candles_by_code[code] = bars
# B) 전일 TF봉 DB prepend (실매 get_candles n=50 정합)
warmup_n = prepend_tail_candle_warmup(
db, candles_by_code, start_key,
timeframe=tail_tf, peak_sel=peak_sel,
)
# 웜업 후에도 RSI+여유 미달 종목 제거
for code in list(candles_by_code.keys()):
if len(candles_by_code[code]) < min_bars:
del candles_by_code[code]
total_candles = sum(len(v) for v in candles_by_code.values())
materialize_ws_candles_batch(db, candles_by_code, tail_tf)
if synth_on and synth_filled_total:
import logging
logging.getLogger("kis_trader.backtest.tail").info(
"🔧 [백테-합성] 1M→3M 구멍 보강 %d봉 (종목 %d)",
synth_filled_total, len(candles_by_code),
)
if warmup_n:
import logging
logging.getLogger("kis_trader.backtest.tail").info(
"🔧 [백테-웜업] 전일 %dM prepend %d봉 (종목 %d, 목표 N=%d)",
tail_tf, warmup_n, len(candles_by_code), tail_backtest_candle_warmup_bars(),
)
return candles_by_code, total_candles, has_holding_peak
def _synth_fill_3m_holes_from_1m(
db,
code: str,
bars_3m: List[Dict],
start_key: str,
end_key: str,
*,
peak_sel: str = "",
) -> Tuple[List[Dict], int]:
"""DB 3분 리스트에 없는 시각만 1분→3분 롤업으로 보강."""
from kis_trader.engine.candle_rollup import merge_fill_holes, rollup_1m_bars_to_tf
from kis_trader.backtest.bt_candle_source import fetch_ws_candles_for_code
try:
rows_1m = fetch_ws_candles_for_code(
db, code, 1, start_key, end_key,
peak_sel=peak_sel,
confirmed_only=True,
)
except Exception:
return bars_3m, 0
if not rows_1m:
return bars_3m, 0
rolled = rollup_1m_bars_to_tf(rows_1m, 3)
return merge_fill_holes(bars_3m, rolled)
def _t2dt(candle_time: str) -> datetime:
from kis_trader.utils.trade_time import parse_trade_datetime
return parse_trade_datetime(candle_time)
def attach_tail_trade_pnl(
trades: List[Dict],
*,
slot_money: float,
fee_rate: float,
sell_tax: float,
slip_pct: float = 0.0,
entry_already_slipped: bool = False,
) -> None:
"""backtest_web api/backtest/tail 손익·보유시간 계산과 동일.
slip_pct: 백테 체결 슬리피지(편도 %, 실매 호가 밀림 근사). 표시용 entry/exit 은
그대로 두고 손익(pnl)에만 반영한다. 청산가는 항상 불리(-) 적용, 진입가는
``entry_already_slipped=False`` (align — 다음봉 시가 시장가)일 때만 불리(+).
limit_atr 진입은 체결 단계(limit_entry_common.try_limit_fill_on_bar)에서
이미 슬립이 반영되므로 ``entry_already_slipped=True`` 로 이중 적용을 막는다.
"""
slip = max(0.0, float(slip_pct or 0.0)) / 100.0
for t in trades:
qty = t.get("qty")
if qty is None:
qty = max(1, int(slot_money / max(1, t["entry"])))
ep = float(t["entry"])
xp = float(t["exit"])
if slip > 0:
if not entry_already_slipped:
ep = ep * (1.0 + slip) # 매수 체결 불리 (실매 호가 밀림)
xp = xp * (1.0 - slip) # 매도 체결 불리
fee = (ep + xp) * qty * fee_rate
tax = xp * qty * sell_tax
t["pnl"] = round((xp - ep) * qty - fee - tax)
t["hold_min"] = round(
(_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1,
)
def fee_and_slot_from_env_row(row: Optional[Dict[str, Any]]) -> Tuple[float, float, float]:
"""env_config 1행 → (fee_rate, sell_tax, slot_money) — backtest_web 과 동일."""
if not row:
return 0.015 / 100, 0.18 / 100, 3_000_000.0
r = dict(row)
fee_rate = float(r.get("FEE_RATE_PCT") or 0.015) / 100
sell_tax = float(r.get("SELL_TAX_RATE_PCT") or 0.18) / 100
slot_money = float(
r.get("SLOT_MONEY_DEFAULT") or r.get("MAX_BUY_AMOUNT_PER_STOCK") or 3_000_000
)
return fee_rate, sell_tax, slot_money
def resolve_tail_portfolio_params(
env_row: Optional[Dict[str, Any]],
base_defaults: Optional[Dict[str, Any]] = None,
*,
slot_money: Optional[float] = None,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
) -> Dict[str, Any]:
"""
웹 api/backtest/tail 과 동일 — 1회투자·동시보유·총한도 해석.
total_budget_krw ≤ 0 이면 max_stocks × slot_money.
"""
r = dict(env_row) if env_row else {}
d = dict(base_defaults) if base_defaults else {}
slot = float(slot_money) if slot_money is not None else float(
r.get("TAIL_SLOT_MONEY") or d.get("slot_money") or 3_000_000
)
mxs = int(max_stocks) if max_stocks is not None else int(
r.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3
)
tb_raw = total_budget_krw
if tb_raw is None:
tb_raw = float(r.get("TAIL_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0)
total_budget = float(tb_raw)
if total_budget <= 0:
total_budget = float(mxs * slot)
budget_warning = None
if total_budget < mxs * slot * 0.95:
budget_warning = (
f"총한도 {total_budget:,.0f}원 < 동시{mxs}×1회투자 "
f"{mxs * slot:,.0f}원 — 잔여금 소액매수·과다 회전 위험. "
"실매 정렬: 총한도↑ 또는 동시보유↓"
)
short_max_buy = int(float(
r.get("TAIL_MAX_BUY_AMOUNT") or d.get("short_max_buy_amount") or 0
))
return {
"slot_money": slot,
"max_stocks": mxs,
"total_budget_krw": total_budget,
"short_max_buy_amount": short_max_buy,
"portfolio_mode": True,
"budget_warning": budget_warning,
}
def merge_tail_portfolio_into_params(
params: Dict[str, Any],
portfolio: Dict[str, Any],
) -> Dict[str, Any]:
"""엔진 params에 포트폴리오 필드 병합 (in-place + 반환)."""
params["slot_money"] = float(portfolio["slot_money"])
params["max_stocks"] = int(portfolio["max_stocks"])
params["total_budget_krw"] = float(portfolio["total_budget_krw"])
params.setdefault("portfolio_mode", True)
sb = int(portfolio.get("short_max_buy_amount") or 0)
if sb > 0:
params["short_max_buy_amount"] = sb
return params
def build_tail_budget_warning(
portfolio: Dict[str, Any],
skip_stats: Optional[Dict[str, Any]] = None,
*,
min_invest_ratio: float = 0.9,
) -> Optional[str]:
"""웹 summary.budget_warning 과 동일 조립."""
msg = portfolio.get("budget_warning")
skip_stats = skip_stats or {}
skipped_micro = int(skip_stats.get("skipped_micro_buys") or 0)
if skipped_micro > 0:
micro_note = f"소액매수 스킵 {skipped_micro}건 (slot {min_invest_ratio * 100:.0f}% 미만)"
msg = f"{msg} | {micro_note}" if msg else micro_note
return msg
def summarize_tail_trades(
trades: List[Dict],
*,
total_budget_krw: float,
period_days: int = 1,
) -> Dict[str, Any]:
"""웹 꼬리 summary 핵심 지표 — 파라서치 결과 JSON용."""
total = len(trades)
wins = [t for t in trades if t.get("pnl", 0) > 0]
losses = [t for t in trades if t.get("pnl", 0) <= 0]
total_pnl = sum(t.get("pnl", 0) for t in trades)
win_pnl = sum(t["pnl"] for t in wins)
loss_pnl = sum(t["pnl"] for t in losses)
win_rate = round(len(wins) / total * 100, 2) if total else 0.0
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
bot_pct = round(total_pnl / total_budget_krw * 100, 2) if total_budget_krw > 0 else 0.0
days = max(1, int(period_days))
daily_avg_pct = round(bot_pct / days, 3) if days > 0 else 0.0
hold_vals = [t.get("hold_min") for t in trades if t.get("hold_min") is not None]
avg_hold = round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else 0.0
return {
"total_trades": total,
"wins": len(wins),
"losses": len(losses),
"win_rate": win_rate,
"total_pnl": int(round(total_pnl)),
"pf": pf,
"bot_pct": bot_pct,
"daily_avg_pct": daily_avg_pct,
"avg_hold_min": avg_hold,
}
def run_tail_backtest_web_aligned(
candles_by_code: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]],
*,
slot_money: float,
fee_rate: float,
sell_tax: float,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
meta_out: Optional[Dict[str, Any]] = None,
) -> List[Dict]:
"""엔진 1회 + 웹과 동일 손익 부착 (ws_ticks 리플레이 옵션)."""
from kis_trader.engine.tail_tick_replay import tail_backtest_wants_tick_replay
from kis_trader.backtest.tail_tick_loader import load_tail_ticks_by_code, tick_coverage_stats
engine_params = dict(params)
engine_params["slot_money"] = float(slot_money)
# 종목 일일 손익 게이트가 실매 realized_pnl과 동일 net 기준으로 온라인 누적하도록 전달
engine_params["fee_rate"] = float(fee_rate)
engine_params["sell_tax"] = float(sell_tax)
if max_stocks is not None:
engine_params["max_stocks"] = int(max_stocks)
if total_budget_krw is not None:
tb = float(total_budget_krw)
engine_params["total_budget_krw"] = tb if tb > 0 else float(
int(engine_params.get("max_stocks") or 3) * slot_money
)
# 웜업봉은 지표용 — 매매 시계는 백테 기간일만 (전일 all_times 오염 방지)
_sk0 = str((meta_out or {}).get("start_key") or "")[:12]
if len(_sk0) >= 8:
engine_params["_bt_period_start_ymd"] = _sk0[:8]
# B) DB 전일 부족 시 REST 1회 (유니버스 종목 우선, 모멘텀과 동일 패턴)
_tf_rest = int(engine_params.get("timeframe") or engine_params.get("tf") or 3)
if _tf_rest not in VALID_TIMEFRAMES:
_tf_rest = 3
if _sk0:
rest_stats = inject_tail_rest_warmup_memory(
candles_by_code,
_sk0,
timeframe=_tf_rest,
universe_by_slot=universe_by_slot,
)
if meta_out is not None and (
rest_stats.get("ok") or rest_stats.get("need") or rest_stats.get("skipped")
):
meta_out.setdefault("skip_stats", {})
# skip_stats 는 엔진 후 overwrite 되므로 임시 보관
meta_out["_tail_rest_warmup"] = dict(rest_stats)
meta_out["_tail_candle_warmup_bars"] = tail_backtest_candle_warmup_bars()
if universe_by_slot is not None:
engine_params.setdefault("scan_interval_min", 1)
# 실매 정합 — skip_hts_scan_dupes 는 DB(TAIL_SKIP_HTS_SCAN_DUPES) 값을 그대로 쓴다.
# (과거엔 저장 이력 백테 시 이 값을 True 로 강제했으나, 실매와 다른 값을 쓰게 되어
# 정합성이 깨졌다. 이제는 params 에 이미 들어온 값(= 호출측이 DB/그리드에서 읽은 값)을
# 그대로 사용 — 웹·파라서치·실매가 항상 같은 소스를 본다.)
engine_params.setdefault("skip_hts_scan_dupes", te.resolve_tail_skip_hts_scan_dupes())
if meta_out is not None:
meta_out["skip_hts_scan_dupes_effective"] = bool(engine_params.get("skip_hts_scan_dupes"))
meta_out["skip_hts_scan_dupes_requested"] = bool(params.get("skip_hts_scan_dupes"))
engine_params.setdefault("portfolio_mode", True)
# ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합, 돌파·모멘텀 공통) ──────
# 1분 슬롯(strict lag)의 "편입 +최대 1분 지연" 을 제거. 봉 마감(HH:MM:59) 직전
# 최신 조건검색 스냅샷을 그대로 조회해 실매와 동일 시점 유니버스로 매수 판정.
if universe_by_slot is not None and tail_backtest_universe_scan_at_enabled(engine_params):
_sk = str((meta_out or {}).get("start_key") or "")
_ek = str((meta_out or {}).get("end_key") or "")
if len(_sk) < 8 or len(_ek) < 8:
# meta_out 키 없으면 캔들 시각 min/max 일자로 폴백
_days = [
str(c.get("candle_time") or "")[:8]
for rows in candles_by_code.values() for c in rows
if c.get("candle_time")
]
if _days:
_sk, _ek = min(_days), max(_days)
if len(_sk) >= 8 and len(_ek) >= 8:
from kis_trader.backtest.universe_timeline import (
build_universe_timeline,
universe_exit_debounce_sec_for_strategy,
)
from kis_trader.backtest.universe_history_source import (
resolve_backtest_universe_history_source,
)
_deb = universe_exit_debounce_sec_for_strategy(TAIL_STRATEGY_ID)
# 슬롯 dict(resolve_tail_universe)와 동일 소스 — LS 라벨인데 키움 타임라인 쓰는 사고 방지
_hs = resolve_backtest_universe_history_source(
engine_params.get("_universe_history_source")
or engine_params.get("universe_history_source")
)
engine_params["_universe_history_source"] = _hs
_tl = build_universe_timeline(
strategy_id=TAIL_STRATEGY_ID,
start_ymd=_sk[:8], end_ymd=_ek[:8],
debounce_sec=_deb, strict=False, strict_lag_minutes=0,
history_source=_hs,
)
if _tl is not None:
engine_params["_universe_timeline"] = _tl
if meta_out is not None:
meta_out["universe_timing"] = "scan_at"
meta_out["universe_timeline_snapshots"] = _tl.snapshot_count
meta_out["universe_exit_debounce_sec"] = _deb
meta_out["universe_history_source"] = _hs
# ── 체결 검증 게이트 (STRICT_FILL_VERIFY) ──────────────────────────────
# 백테 체결모델(슬리피지·체결량 상한)은 '엄격 체결 검증'이 켜졌을 때만 적용한다.
# 실매: 실전은 항상 fill 확인 / 모의는 STRICT_FILL_VERIFY=true 시 동일하게 확인.
# 백테도 같은 토글로 묶어 → OFF=순수 이론 체결(슬립0·100%체결), ON=실매 근사.
# params 우선(웹 1회용 오버라이드) → env(DB) 폴백.
_strict = engine_params.get("strict_fill_verify")
if _strict is None:
from kis_trader.utils.env import get_env_bool
_strict = get_env_bool("STRICT_FILL_VERIFY", False)
if not bool(_strict):
engine_params["limit_fill_slip_pct"] = 0.0
engine_params["backtest_vol_fill_cap_pct"] = 0.0
elif not float(engine_params.get("backtest_vol_fill_cap_pct") or 0):
# vol_cap(체결량 상한)은 공통키 우선 — 전 전략 동일 소스. TAIL_ 폴백값이
# 비어/0 이면 글로벌 BACKTEST_VOL_FILL_CAP_PCT 를 사용(웹 운영설정에서 조절).
from kis_trader.utils.env import get_env_float
engine_params["backtest_vol_fill_cap_pct"] = get_env_float(
"BACKTEST_VOL_FILL_CAP_PCT", 0.0,
)
loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {})
tick_meta: Dict[str, Any] = {}
if tail_backtest_wants_tick_replay(engine_params):
if not loaded_ticks and meta_out is not None:
start_key = str(meta_out.get("start_key") or "")
end_key = str(meta_out.get("end_key") or "")
db = meta_out.get("db")
# 웹은 meta_out["db"] 를 넘김. CLI/잡 경로에서 빠지면 틱 미로드 →
# tick_exit_count=0·OHLC 장마감만 → 실매 래칫(분단위)과 크게 어긋남.
if db is None and start_key and end_key:
from kis_trader.backtest.backtest_portfolio_common import ensure_meta_db
db = ensure_meta_db(meta_out)
if db and start_key and end_key:
loaded_ticks, tick_rows = load_tail_ticks_by_code(
db, start_key, end_key, set(candles_by_code.keys()),
)
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
tick_meta["ws_tick_rows_loaded"] = tick_rows
elif loaded_ticks:
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
tick_meta["ws_tick_rows_loaded"] = sum(
len(lst) for cm in loaded_ticks.values() for lst in cm.values()
)
ob_loaded, pg_loaded, snap_meta = resolve_trigger_snapshots_for_backtest(
candles_by_code, engine_params, strategy="TAIL", meta_out=meta_out,
orderbook_by_code=orderbook_by_code, program_by_code=program_by_code,
)
from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params
attach_backtest_env_timeline_to_params(engine_params, meta_out, TAIL_STRATEGY_ID)
if snap_meta.get("log_verdict_by_code"):
engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
trades = te.run_tail_backtest(
candles_by_code,
engine_params,
universe_by_slot=universe_by_slot,
ticks_by_code=loaded_ticks or None,
orderbook_by_code=ob_loaded,
program_by_code=pg_loaded,
)
# 백테 슬리피지(편도 %) — BACKTEST_SLIP_PCT 공통키(전 전략 동일, STRICT 게이트는
# 헬퍼가 처리). align 은 진입+청산 양측 반영, limit_atr 진입은 try_limit_fill_on_bar
# 의 limit_fill_slip_pct 로 이미 반영돼 청산만 추가(entry_already_slipped 로 중복 차단).
from kis_trader.backtest.backtest_portfolio_common import backtest_slip_pct
_slip_pct = backtest_slip_pct(engine_params)
_entry_mode = str(engine_params.get("entry_mode") or "align").strip().lower()
attach_tail_trade_pnl(
trades, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=_slip_pct,
entry_already_slipped=(_entry_mode == "limit_atr"),
)
# 당일 실현손익 고정/트레일 익절 시뮬 (실매 daily_profit_halt 동일 판정).
# 게이트 OFF 기본 → 기존 동작 불변. 파람서치 trail 축 있으면 자동 ON.
from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim
trades = apply_daily_profit_halt_sim(
trades, engine_params, budget_krw=float(total_budget_krw or 0),
)
if meta_out is not None:
skip_stats = engine_params.get("_portfolio_skip_stats") or {}
meta_out["skip_stats"] = dict(skip_stats)
if meta_out.get("_tail_rest_warmup"):
meta_out["skip_stats"]["rest_warmup"] = meta_out.pop("_tail_rest_warmup")
if meta_out.get("_tail_candle_warmup_bars") is not None:
meta_out["skip_stats"]["candle_warmup_bars"] = meta_out.pop(
"_tail_candle_warmup_bars"
)
meta_out["engine_params"] = engine_params
if tick_meta:
from kis_trader.backtest.tail_tick_loader import enrich_tick_meta_with_traded_codes
tick_meta = enrich_tick_meta_with_traded_codes(
tick_meta, candles_by_code, loaded_ticks, trades,
)
meta_out["tick_backtest"] = tick_meta
if int(tick_meta.get("ws_tick_rows_loaded") or 0) > 0:
meta_out["backtest_buy_source"] = "ws_ticks"
elif tail_backtest_wants_tick_replay(engine_params):
meta_out["backtest_buy_source"] = "ohlc_fallback"
if snap_meta:
meta_out["trigger_snapshot_backtest"] = snap_meta
return trades