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.
832 lines
34 KiB
Python
832 lines
34 KiB
Python
#!/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
|