Files
kis_bot/kis_trader/backtest/backtest_portfolio_common.py

924 lines
37 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
"""
백테·파라서치 공통 — 시각순 포트폴리오(1회투자·동시보유·총한도·소액매수 스킵) 해석·요약.
꼬리·스캘핑·모멘텀·돌파·Updow 파라서치/웹이 동일 분모·지표를 쓰도록 단일 진입.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
def ensure_meta_db(meta_out: Optional[Dict[str, Any]]) -> Any:
"""``meta_out['db']`` 보장 — 틱/스냅샷 로드용.
웹 API는 항상 db 를 넣지만, CLI·잡·일부 스크립트에서 빠지면
틱 미로드 → OHLC만 청산 → 실매와 크게 어긋난다.
없을 때만 TradeDB 를 만들고 ``_db_created_for_bt`` 표시.
"""
if meta_out is None:
return None
db = meta_out.get("db")
if db is not None:
return db
try:
from database import TradeDB
db = TradeDB()
meta_out["db"] = db
meta_out["_db_created_for_bt"] = True
return db
except Exception:
return None
def flatten_remaining_portfolio_trades(
portfolio: Dict[str, Dict[str, Any]],
ctx_by_code: Dict[str, Dict[str, Any]],
all_trades: List[Dict[str, Any]],
*,
params: Optional[Dict[str, Any]] = None,
strategy: str = "",
default_reason: str = "bt_flatten",
) -> int:
"""루프 종료 후 미청산 포지션을 마지막 확정봉 종가로 장부에 남긴다.
분봉이 중간에 끊겨 EOD/max_hold 판정이 안 돈 종목(실매는 벽시계로 청산) 정합용.
마지막 봉 시각이 EOD 시각 **이후**일 때만 ``장마감청산``/``eod`` —
그 전이면 ``bt_flatten`` (14:57 봉을 장마감으로 위장하지 않음).
"""
reason_default = str(default_reason or "bt_flatten")
eod_on = False
eod_hm = "15:20"
eod_reason = "eod"
_is_eod_bar = None
if params is not None and strategy:
try:
from kis_trader.engine.strategy_eod import (
is_backtest_eod_bar,
resolve_strategy_eod_params,
)
eod_on, eod_hm = resolve_strategy_eod_params(params, strategy)
sid = str(strategy or "").strip().upper()
eod_reason = "장마감청산" if sid == "SCALP" else "eod"
_is_eod_bar = is_backtest_eod_bar
except Exception:
eod_on = False
n = 0
for code in list(portfolio.keys()):
pos = portfolio[code]
ctx = ctx_by_code.get(code)
entry_t = str(pos.get("entry_time") or "")
last = None
if ctx and ctx.get("candles"):
candles = ctx["candles"]
for c in reversed(candles):
ct = str(c.get("candle_time") or "")
if not ct:
continue
if entry_t and ct < entry_t[:12]:
continue
last = c
break
if last is None and candles:
last = candles[-1]
if last is None:
exit_price = float(pos.get("entry_price") or 0)
sell_time = entry_t
else:
exit_price = float(last.get("close") or 0)
sell_time = str(last.get("candle_time") or entry_t)
if exit_price <= 0 or not sell_time:
del portfolio[code]
continue
reason = reason_default
if eod_on and _is_eod_bar is not None:
try:
if _is_eod_bar(sell_time, True, eod_hm, default_hm=eod_hm):
reason = eod_reason
except Exception:
pass
trade: Dict[str, Any] = {
"code": code,
"buy_time": pos["entry_time"],
"sell_time": sell_time,
"buy_price": pos["entry_price"],
"sell_price": round(exit_price, 2),
"qty": pos.get("qty", 1),
"pnl": 0,
"sell_reason": reason,
"hold_min": 0,
"exit_source": "bt_flatten",
}
if strategy:
trade["strategy"] = strategy
if pos.get("rsi") is not None:
try:
trade["rsi_entry"] = round(float(pos["rsi"]), 1)
except (TypeError, ValueError):
pass
all_trades.append(trade)
if ctx is not None:
day = sell_time[:8]
try:
from kis_trader.engine.scalping_engine import _t2dt
ctx.setdefault("last_exit_dt", {})[day] = _t2dt(sell_time)
except Exception:
pass
del portfolio[code]
n += 1
return n
def load_portfolio_env_row(db: Any = None) -> Dict[str, Any]:
"""웹·실매와 동일 — ``get_latest_env()`` 병합 스냅샷.
``SELECT * FROM env_config`` 만 쓰면 ``config_momentum`` 등의
``*_TOTAL_BUDGET_KRW`` 가 빠져 ``total_budget≤0 → max_stocks×slot``
(예: 동시20×30만=**600만**) 폴백이 난다. Optuna/Grid 공통 금지.
"""
own_db = db is None
if own_db:
from database import TradeDB
db = TradeDB()
try:
latest = db.get_latest_env()
if latest and isinstance(latest.get("snapshot"), dict):
return dict(latest["snapshot"])
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(row) if row else {}
finally:
if own_db:
try:
db.close()
except Exception:
pass
# 전략별 env 키 (첫 매칭 우선)
STRATEGY_PORTFOLIO_KEYS: Dict[str, Dict[str, Tuple[str, ...]]] = {
"TAIL": {
"max_stocks": ("TAIL_MAX_STOCKS", "SHORT_MAX_STOCKS"),
"total_budget": ("TAIL_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("TAIL_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("TAIL_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT"),
"min_invest_env": ("TAIL_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
# 실매 strategy_id=SHORT — TAIL 과 동일 env. 없으면 SCALP 폴백되어 한도 600만이 찍히는 버그 방지.
"SHORT": {
"max_stocks": ("SHORT_MAX_STOCKS", "TAIL_MAX_STOCKS"),
"total_budget": ("SHORT_TOTAL_BUDGET_KRW", "TAIL_TOTAL_BUDGET_KRW"),
"slot": ("TAIL_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT"),
"min_invest_env": ("TAIL_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"SCALP": {
"max_stocks": ("SCALP_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("SCALP_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("SLOT_MONEY_DEFAULT",),
"per_stock_cap": ("SCALP_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
"min_invest_env": ("SCALP_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"MOMENTUM": {
"max_stocks": ("MOMENTUM_MAX_STOCKS", "SCALP_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("MOMENTUM_TOTAL_BUDGET_KRW", "SCALP_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("MOMENTUM_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("MOMENTUM_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
"min_invest_env": ("MOMENTUM_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"US_MOMENTUM": {
"max_stocks": ("US_MOMENTUM_MAX_STOCKS",),
"total_budget": ("US_MOMENTUM_TOTAL_BUDGET", "US_MOMENTUM_TOTAL_BUDGET_KRW"),
"slot": ("US_MOMENTUM_SLOT_MONEY",),
"per_stock_cap": ("US_MOMENTUM_MAX_BUY_AMOUNT",),
"min_invest_env": ("US_MOMENTUM_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"BREAKOUT": {
"max_stocks": ("BREAKOUT_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("BREAKOUT_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("BREAKOUT_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
"min_invest_env": ("BREAKOUT_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"RANGE_BREAK": {
"max_stocks": ("RANGE_BREAK_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("RANGE_BREAK_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("RANGE_BREAK_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("RANGE_BREAK_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
"min_invest_env": ("RANGE_BREAK_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"UPDOW": {
"max_stocks": ("UPDOW_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("UPDOW_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("UPDOW_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("UPDOW_MAX_BUY_AMOUNT",),
"min_invest_env": ("UPDOW_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"DBBAND": {
"max_stocks": ("DBBAND_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("DBBAND_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("DBBAND_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("DBBAND_MAX_BUY_AMOUNT",),
"min_invest_env": ("DBBAND_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"DART": {
"max_stocks": ("DART_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("DART_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
"slot": ("DART_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
"per_stock_cap": ("DART_MAX_BUY_AMOUNT",),
"min_invest_env": ("DART_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
}
def _pick_env(r: Dict[str, Any], keys: Tuple[str, ...], default: Any = None) -> Any:
for k in keys:
v = r.get(k)
if v not in (None, "", "None"):
return v
return default
def fee_and_slot_from_env_row(
row: Optional[Dict[str, Any]],
*,
strategy: str = "SCALP",
) -> Tuple[float, float, float]:
"""env_config 1행 → (fee_rate, sell_tax, slot_money)."""
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
keys = STRATEGY_PORTFOLIO_KEYS.get(strategy, STRATEGY_PORTFOLIO_KEYS["SCALP"])
slot_money = float(
_pick_env(r, keys["slot"], 3_000_000)
)
return fee_rate, sell_tax, slot_money
def resolve_portfolio_params(
env_row: Optional[Dict[str, Any]],
base_defaults: Optional[Dict[str, Any]] = None,
*,
strategy: str = "SCALP",
slot_money: Optional[float] = None,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
) -> Dict[str, Any]:
"""웹·파라서치 공통 — 1회투자·동시보유·총한도. total_budget ≤0 → max_stocks×slot."""
r = dict(env_row) if env_row else {}
d = dict(base_defaults) if base_defaults else {}
keys = STRATEGY_PORTFOLIO_KEYS.get(strategy, STRATEGY_PORTFOLIO_KEYS["SCALP"])
slot = float(slot_money) if slot_money is not None else float(
_pick_env(r, keys["slot"], d.get("slot_money") or 3_000_000)
)
mxs = int(max_stocks) if max_stocks is not None else int(
_pick_env(r, keys["max_stocks"], d.get("max_stocks") or 3)
)
tb_raw = total_budget_krw
if tb_raw is None:
tb_raw = float(_pick_env(r, keys["total_budget"], d.get("total_budget_krw") or 0) 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}원 — 잔여금 소액매수·과다 회전 위험. "
"실매 정렬: 총한도↑ 또는 동시보유↓"
)
per_cap = int(float(_pick_env(r, keys["per_stock_cap"], d.get("short_max_buy_amount") or 0) or 0))
return {
"slot_money": slot,
"max_stocks": max(1, mxs),
"total_budget_krw": total_budget,
"short_max_buy_amount": per_cap,
"portfolio_mode": True,
"budget_warning": budget_warning,
"strategy": strategy,
}
def merge_portfolio_into_params(
params: Dict[str, Any],
portfolio: Dict[str, Any],
) -> Dict[str, Any]:
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 merge_param_search_apply_source(
item: Dict[str, Any],
search_meta: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
파라서치 JSON rank 항목 + 파일 meta(포트폴리오·CLI) 병합.
apply_cfg → merged_params → params 순, params(그리드)가 최우선.
"""
src: Dict[str, Any] = {}
for key in ("apply_cfg", "merged_params"):
part = item.get(key)
if isinstance(part, dict):
src.update(part)
params = item.get("params")
if isinstance(params, dict):
src.update(params)
meta = dict(search_meta or {})
for k in ("slot_money", "max_stocks", "total_budget_krw", "time_start_hm", "time_end_hm"):
if meta.get(k) is not None:
src[k] = meta[k]
pf = meta.get("portfolio")
if isinstance(pf, dict):
for k in ("slot_money", "max_stocks", "total_budget_krw", "time_start_hm", "time_end_hm"):
if pf.get(k) is not None:
src[k] = pf[k]
return src
def session_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
"""매매시간 HHMM → env (전략별 TIME_*). 꼬리(SHORT/TAIL)는 TAIL_TIME_* 만.
기본 OFF — 파라서치/Optuna apply 가 TIME_* 를 덮어쓰지 않음
(짧은 창 과적합 → 실매 오후 진입 잠금 방지). 운영 시간은 DB·웹에서 수동.
켜려면 PARAM_SEARCH_APPLY_SESSION_TIME=1.
"""
try:
from kis_trader.utils.env import get_env_bool
if not get_env_bool("PARAM_SEARCH_APPLY_SESSION_TIME", False):
return {}
except Exception:
return {}
patch: Dict[str, str] = {}
ts = p.get("time_start_hm")
te = p.get("time_end_hm")
if ts in (None, ""):
ts = None
if te in (None, ""):
te = None
strat = (strategy or "").upper()
if ts is not None:
v = str(int(float(ts)))
if strat in ("TAIL", "SHORT"):
patch["TAIL_TIME_START"] = v
elif strat == "SCALP":
patch["SCALP_TIME_START"] = v
patch["TIME_START"] = v
elif strat == "MOMENTUM":
patch["MOMENTUM_TIME_START"] = v
elif strat == "US_MOMENTUM":
patch["US_MOMENTUM_TIME_START"] = v
elif strat == "BREAKOUT":
patch["BREAKOUT_TIME_START"] = v
elif strat == "RANGE_BREAK":
patch["RANGE_BREAK_TIME_START"] = v
else:
patch["TIME_START"] = v
if te is not None:
v = str(int(float(te)))
if strat in ("TAIL", "SHORT"):
patch["TAIL_TIME_END"] = v
elif strat == "SCALP":
patch["SCALP_TIME_END"] = v
patch["TIME_END"] = v
elif strat == "MOMENTUM":
patch["MOMENTUM_TIME_END"] = v
elif strat == "US_MOMENTUM":
patch["US_MOMENTUM_TIME_END"] = v
elif strat == "BREAKOUT":
patch["BREAKOUT_TIME_END"] = v
patch["BREAKOUT_GOLDEN_END_HM"] = v
elif strat == "RANGE_BREAK":
patch["RANGE_BREAK_TIME_END_HM"] = v
else:
patch["TIME_END"] = v
return patch
# 전략 전용 키 prefix — 쓰기 시 '남의 전략' 키 오염 방지용.
# (읽기 fallback 튜플엔 타 전략 키가 폴백으로 들어있어, 그대로 쓰면 SCALP·SHORT 등이 덮인다)
# US_MOMENTUM_ 은 MOMENTUM_ 보다 먼저 매칭되도록 앞에 둠.
_KNOWN_STRATEGY_PREFIXES: Tuple[str, ...] = (
"TAIL_", "SHORT_", "SCALP_", "US_MOMENTUM_", "MOMENTUM_",
"BREAKOUT_", "RANGE_BREAK_", "UPDOW_", "DBBAND_",
)
def _is_foreign_strategy_key(key: str, own_prefix: str) -> bool:
"""key 가 '자기 전략 prefix 가 아닌' 다른 전략 전용 키인지. (무prefix 공용키는 False)."""
# SHORT ↔ TAIL 동일 전략군 — 서로의 prefix 는 자기 키로 취급
own_set = {own_prefix}
if own_prefix in ("SHORT_", "TAIL_"):
own_set.update(("SHORT_", "TAIL_"))
for pfx in _KNOWN_STRATEGY_PREFIXES:
if key.startswith(pfx):
return pfx not in own_set
return False
def portfolio_env_keys(strategy: str) -> frozenset:
"""파람서치 apply 시 제외할 슬롯·동시보유·총한도·1회상한 env 키 집합.
웹「봇에 설정저장」은 운영 한도를 의도적으로 바꿀 수 있으므로 이 키를 써도 된다.
Optuna/Grid 1등 적용은 slot×max_stocks 로 총한도를 덮어쓰는 사고(예: 120만→600만)를
막기 위해 ``strip_portfolio_keys_from_apply_patch`` 로 제거한다.
"""
strat = (strategy or "").upper()
keys = STRATEGY_PORTFOLIO_KEYS.get(strat)
if not keys:
return frozenset()
own_prefix = strat + "_"
out: set = set()
for group in ("slot", "per_stock_cap", "max_stocks", "total_budget"):
for k in keys.get(group, ()):
if _is_foreign_strategy_key(k, own_prefix):
continue
out.add(k)
return frozenset(out)
def strip_portfolio_keys_from_apply_patch(
patch: Dict[str, str],
strategy: str,
*extra_strategies: str,
) -> Dict[str, str]:
"""파람서치/Optuna apply patch 에서 포트폴리오(슬롯·한도) 키 제거."""
if not patch:
return patch
skip: set = set(portfolio_env_keys(strategy))
for s in extra_strategies:
skip |= set(portfolio_env_keys(s))
if not skip:
return patch
return {k: v for k, v in patch.items() if k not in skip}
def portfolio_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
"""1회투자·동시보유·총한도 → env_config 문자열 patch (웹 save 전용 권장).
※ 쓰기 시 '자기 전략 키 + 공용 무prefix 키' 만 기록한다. 읽기용 fallback 튜플에
섞인 다른 전략 전용 키(SCALP_*, SHORT_* 등)는 제외 → 남의 전략 설정 오염 금지.
※ 파람서치/Optuna apply 경로에서는 호출하지 말 것 — ``strip_portfolio_keys_from_apply_patch`` 참고.
"""
patch: Dict[str, str] = {}
strat = (strategy or "").upper()
keys = STRATEGY_PORTFOLIO_KEYS.get(strat)
if not keys:
return patch
own_prefix = strat + "_"
def _set_many(env_keys: Tuple[str, ...], value: str) -> None:
for k in env_keys:
if _is_foreign_strategy_key(k, own_prefix):
continue # 남의 전략 전용 키 → 건드리지 않음
patch[k] = value
sm = p.get("slot_money")
if sm not in (None, ""):
v = str(int(float(sm)))
_set_many(keys["slot"], v)
_set_many(keys.get("per_stock_cap", ()), v)
mxs = p.get("max_stocks")
if mxs not in (None, ""):
v = str(int(float(mxs)))
_set_many(keys["max_stocks"], v)
tb = p.get("total_budget_krw")
if tb not in (None, ""):
v = str(int(float(tb)))
_set_many(keys["total_budget"], v)
return patch
def min_invest_ratio_of_slot(params: Dict[str, Any], strategy: str = "SCALP") -> float:
v = params.get("min_invest_ratio_of_slot")
if v not in (None, ""):
return max(0.01, min(1.0, float(v)))
keys = STRATEGY_PORTFOLIO_KEYS.get(strategy, STRATEGY_PORTFOLIO_KEYS["SCALP"])
try:
from kis_trader.utils.env import get_env_float
for k in keys["min_invest_env"]:
raw = get_env_float(k, None)
if raw is not None:
return max(0.01, min(1.0, float(raw)))
return max(0.01, min(1.0, get_env_float("MIN_INVEST_RATIO_OF_SLOT", 0.90)))
except Exception:
return 0.90
def target_qty_and_cost(entry_price: float, slot_money: float) -> Tuple[int, float]:
from kis_trader.utils.position_sizing import invest_qty_for_price
qty = invest_qty_for_price(entry_price, slot_money)
if qty < 1:
return 0, 0.0
return qty, qty * entry_price
def portfolio_exposure_krw(portfolio: Dict[str, Dict[str, Any]]) -> float:
total = 0.0
for p in portfolio.values():
ep = float(p.get("entry_price") or p.get("buy_price") or 0)
total += ep * int(p.get("qty") or 1)
return total
def build_budget_warning(
portfolio: Dict[str, Any],
skip_stats: Optional[Dict[str, Any]] = None,
*,
min_invest_ratio: float = 0.9,
) -> Optional[str]:
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 _t2dt(candle_time: str) -> datetime:
from kis_trader.utils.trade_time import parse_trade_datetime
return parse_trade_datetime(candle_time)
def backtest_slip_pct(params: Optional[Dict[str, Any]]) -> float:
"""백테 체결 슬리피지(편도 %) — 엄격 체결 검증(STRICT_FILL_VERIFY)이 켜졌을 때만.
실매 정합: 실전은 항상 fill 확인 / 모의는 STRICT_FILL_VERIFY 로 확인. 백테도
같은 토글로 묶어 OFF=순수 이론 체결(슬립0), ON=실매 호가밀림 근사.
BACKTEST_SLIP_PCT(전 전략 공통, 편도 %) 단일 소스. params 우선 → env(DB) 폴백.
"""
p = params or {}
strict = p.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):
return 0.0
v = p.get("backtest_slip_pct")
if v is None:
from kis_trader.utils.env import get_env_float
v = get_env_float("BACKTEST_SLIP_PCT", 0.0)
return max(0.0, float(v or 0.0))
def vol_capped_qty(qty: int, candle: Optional[Dict[str, Any]], cap_pct: float) -> int:
"""진입봉 거래량×cap_pct% 로 체결 가능 주수 제한 (실매 IOC 유동성 제약 근사).
cap_pct<=0 → 제한 없음. 거래량 정보 없으면(0) 기존 동작 유지(보수적). 반환 0=미체결.
실매 시장가 IOC 가 호가에 있는 만큼만 체결되는 것을 봉단위로 근사 (소형주 미체결↑).
"""
if cap_pct <= 0:
return qty
try:
vol = int(float((candle or {}).get("volume") or 0))
except (TypeError, ValueError):
vol = 0
if vol <= 0:
return qty
fillable = int(vol * cap_pct / 100.0)
return min(qty, fillable)
def _daily_halt_cfg(p: Dict[str, Any]) -> Dict[str, Any]:
"""백테 daily_profit 시뮬용 cfg — params 우선 → 전략접두 env → 마스터 env (실매 load_* 와 동일)."""
from kis_trader.utils.env import get_env_from_db, get_env_float
sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "").strip().upper()
if sid in ("TAIL", ""):
sid = "SHORT"
pfx = sid # SHORT / MOMENTUM / BREAKOUT / SCALP …
def _env_str(key: str, default: str = "") -> str:
# 전략 서브키 우선 → 마스터 폴백 (실매 load_strategy_profit_target 과 동일)
raw = str(get_env_from_db(f"{pfx}_{key}", "") or "").strip()
if raw != "":
return raw
return str(get_env_from_db(key, default) or default).strip()
def _pf(param_keys: Tuple[str, ...], key_env: str, default: float = 0.0) -> float:
# daily 전용 키(daily_trail_*) 우선 → 파람서치 호환 키 → env 폴백.
# 주의: 꼬리 개별포지션 'trail_arm_pct' 와 충돌 방지 위해 arm_pct 는
# 'daily_trail_arm_pct' 전용 키만 본다(generic 키 미참조).
for k in param_keys:
v = p.get(k)
if v is not None:
return float(v or 0.0)
try:
return float(_env_str(key_env, str(default)) or default)
except (TypeError, ValueError):
return float(get_env_float(key_env, default) or 0.0)
mode = str(
p.get("daily_profit_mode") or _env_str("DAILY_PROFIT_MODE", "fixed") or "fixed"
).lower()
# 다단계 tier(우선) — 전용키 daily_trail_tiers → env. 비면 단일 drop 폴백.
tiers = p.get("daily_trail_tiers")
if tiers is None:
tiers = _env_str("DAILY_PROFIT_TRAIL_TIERS", "")
return {
"enabled": True,
"mode": mode,
"trail_tiers": str(tiers or "").strip(),
"krw": _pf(("daily_profit_target_krw",), "DAILY_PROFIT_TARGET_KRW"),
"pct": _pf(("daily_profit_target_pct",), "DAILY_PROFIT_TARGET_PCT"),
"trail_drop_pct": _pf(
("daily_trail_drop_pct", "trail_drop_pct"), "DAILY_PROFIT_TRAIL_DROP_PCT",
),
"trail_arm_krw": _pf(
("daily_trail_arm_krw", "trail_arm_krw"), "DAILY_PROFIT_TRAIL_ARM_KRW",
),
"trail_arm_pct": _pf(("daily_trail_arm_pct",), "DAILY_PROFIT_TRAIL_ARM_PCT"),
}
def apply_daily_profit_halt_sim(
trades: List[Dict], params: Optional[Dict[str, Any]], *, budget_krw: float,
) -> List[Dict]:
"""백테: 당일 실현손익 고정/트레일 익절 → 트리거 시각 이후 '신규 진입' 거래 제거.
실매 daily_profit_halt 의 _target_reached/_trail_reached 를 그대로 재사용(봉단위 근사).
당일 누적 실현손익을 청산 시각순으로 그려 고점(peak)을 추적, 고정목표 도달 또는
고점대비 trail_drop_pct% 되돌림이 처음 발생한 시각 이후의 신규 진입을 차단한다.
(보유분 청산은 그대로 — 실매 buy_allowed 와 동일: 신규매수만 중단.)
B(리스크버짓): ``_backtest_daily_profit_risk_budget`` ON 이면 hit 시점에
미청산 포지션의 최악손절합 > cushion 인 종목을 **손절가 전량청산**으로 근사
(실매 plan_risk_budget_trim 과 동일 선정 · 체결가는 stop 보수 가정).
게이트:
- params['_backtest_daily_profit_trail'] 명시 시 그 값 (웹 손익감시 스위치).
- 미지정이면 실매 ``{pfx}_DAILY_PROFIT_TARGET_ENABLED`` 추종.
"""
p = params or {}
gate = p.get("_backtest_daily_profit_trail")
if gate is None:
sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "SHORT").strip().upper()
if sid == "TAIL":
sid = "SHORT"
try:
from kis_trader.engine.daily_profit_halt import load_strategy_profit_target
gate = bool(load_strategy_profit_target(sid).get("enabled"))
except Exception:
from kis_trader.utils.env import get_env_bool
gate = get_env_bool(f"{sid}_DAILY_PROFIT_TARGET_ENABLED", False)
if not bool(gate):
return trades
from kis_trader.engine.daily_profit_halt import (
_guard_active, _target_reached, _trail_reached,
plan_risk_budget_trim, resolve_stop_price, position_stop_risk_krw,
)
cfg = _daily_halt_cfg(p)
if not _guard_active(cfg):
return trades
mode = str(cfg.get("mode") or "fixed").lower()
rb_gate = p.get("_backtest_daily_profit_risk_budget")
if rb_gate is None:
sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "SHORT").strip().upper()
if sid == "TAIL":
sid = "SHORT"
try:
from kis_trader.engine.daily_profit_halt import load_strategy_profit_target
rb_gate = bool(load_strategy_profit_target(sid).get("risk_budget_enabled"))
except Exception:
from kis_trader.utils.env import get_env_bool
rb_gate = get_env_bool(f"{sid}_DAILY_PROFIT_RISK_BUDGET_ENABLED", False)
rb_on = bool(rb_gate)
sid_rb = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "SHORT").strip().upper()
if sid_rb == "TAIL":
sid_rb = "SHORT"
by_day: Dict[str, List[Dict]] = {}
for t in trades:
et = str(t.get("entry_time") or t.get("buy_time") or "")
if len(et) < 8:
return trades # 진입 시각 없으면 시뮬 불가 → 불변
by_day.setdefault(et[:8], []).append(t)
kept: List[Dict] = []
for _day, ts in by_day.items():
closes = sorted(ts, key=lambda x: str(x.get("exit_time") or x.get("sell_time") or ""))
cum = 0.0
peak = 0.0
trigger_time: Optional[str] = None
trail_hit = False
fixed_hit = False
for t in closes:
cum += float(t.get("pnl") or 0)
peak = max(peak, cum)
# 실매 _check_hit 와 동일: both 는 고정(하드캡) 먼저 → 트레일. 먼저 닿는 쪽.
fixed_hit = mode in ("fixed", "both") and _target_reached(cum, cfg, budget_krw)
trail_hit = mode in ("trailing", "both") and _trail_reached(cum, peak, cfg, budget_krw)
if fixed_hit or trail_hit:
trigger_time = str(t.get("exit_time") or t.get("sell_time") or "")
break
# B: hit 시점 미청산 → 리스크버짓 전량(손절가) 근사
force_codes: set = set()
if rb_on and trigger_time:
open_rows: List[Dict[str, Any]] = []
for t in ts:
et = str(t.get("entry_time") or t.get("buy_time") or "")
xt = str(t.get("exit_time") or t.get("sell_time") or "")
if et and et < trigger_time and (not xt or xt > trigger_time):
entry = float(t.get("buy_price") or t.get("entry") or 0)
qty = int(t.get("qty") or 1)
stop = float(t.get("stop_price") or 0)
stop = resolve_stop_price(
entry=entry, stop_price=stop, strategy_id=sid_rb,
)
risk = position_stop_risk_krw(qty=qty, entry=entry, stop_price=stop)
code = str(t.get("code") or t.get("ticker") or id(t))
open_rows.append({
"code": code,
"_trade": t,
"qty": qty,
"buy_price": entry,
"stop_price": stop,
"risk_krw": risk,
})
plan = plan_risk_budget_trim(
pnl_krw=cum,
peak_krw=peak,
cfg=cfg,
budget_krw=budget_krw,
positions=open_rows,
trail_hit=trail_hit,
fixed_hit=fixed_hit,
)
if plan.get("action") == "trim":
for row in plan.get("closes") or []:
tr = row.get("_trade")
if not tr:
continue
code = str(row.get("code") or "")
force_codes.add(code)
stop = float(row.get("stop_price") or 0)
entry = float(row.get("buy_price") or 0)
qty = int(row.get("qty") or 1)
# 보수: 손절가 청산으로 pnl 재계산 (수수료 미차감 — 기존 trade pnl 스케일 유지)
if entry > 0 and stop > 0:
tr["sell_price"] = stop
tr["exit"] = stop
tr["pnl"] = round((stop - entry) * qty)
tr["exit_time"] = trigger_time
tr["sell_time"] = trigger_time
tr["sell_reason"] = "리스크버짓"
tr["_risk_budget_force"] = True
for t in ts:
et = str(t.get("entry_time") or t.get("buy_time") or "")
if trigger_time and et >= trigger_time and not t.get("_risk_budget_force"):
continue # 트리거 후 신규매수 차단
kept.append(t)
return kept
def attach_scalp_trade_pnl(
trades: List[Dict],
*,
fee_rate: float,
sell_tax: float,
slip_pct: float = 0.0,
fx_fee_rate: float = 0.0,
pnl_decimals: int = 0,
) -> None:
"""스캘핑·모멘텀·돌파 공통 손익 (buy_time/sell_time 필드).
slip_pct: 백테 체결 슬리피지(편도 %). 시장가 즉시 체결 가정이라 매수는 불리(+)·
매도는 불리(-) 로 진입·청산 양측 반영. 표시용 가격은 그대로 두고 pnl 에만 반영.
0=OFF(동작 불변).
fx_fee_rate: 환전 스프레드 **편도** 비율 (예: 0.0005=0.05%). 매수·매도 금액에 각각 적용.
해외 US 등 — 0=OFF(국장 동작 불변).
pnl_decimals: 손익 반올림 자리 (국장 기본 0=원 단위 int, 해외 USD는 4 권장).
"""
slip = max(0.0, float(slip_pct or 0.0)) / 100.0
fx = max(0.0, float(fx_fee_rate or 0.0))
nd = max(0, int(pnl_decimals or 0))
for t in trades:
if t.get("pnl") is not None and t.get("pnl") != 0:
continue
ep = float(t.get("buy_price") or t.get("entry") or 0)
xp = float(t.get("sell_price") or t.get("exit") or 0)
qty = int(t.get("qty") or 1)
if slip > 0:
ep = ep * (1.0 + slip) # 매수 체결 불리 (실매 호가 밀림)
xp = xp * (1.0 - slip) # 매도 체결 불리
buy_amt = ep * qty
sell_amt = xp * qty
# 매매수수료(편도×2) + 매도세 + 환전(편도×매수·매도)
pnl = (
sell_amt - buy_amt
- buy_amt * float(fee_rate)
- sell_amt * float(fee_rate)
- sell_amt * float(sell_tax)
- (buy_amt + sell_amt) * fx
)
t["pnl"] = round(pnl, nd) if nd > 0 else round(pnl)
t["fee_rate"] = float(fee_rate)
t["sell_tax"] = float(sell_tax)
if fx > 0:
t["fx_fee_rate"] = fx
bt = t.get("buy_time") or t.get("entry_time")
st = t.get("sell_time") or t.get("exit_time")
if bt and st:
t["hold_min"] = round((_t2dt(st) - _t2dt(bt)).total_seconds() / 60, 1)
def summarize_trades(
trades: List[Dict],
*,
total_budget_krw: float,
period_days: int = 1,
) -> Dict[str, Any]:
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 resolve_trigger_snapshots_for_backtest(
candles_by_code: Dict[str, Any],
engine_params: Dict[str, Any],
*,
strategy: str,
meta_out: Optional[Dict[str, Any]] = None,
orderbook_by_code: Optional[Dict[str, Any]] = None,
program_by_code: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], Dict[str, Any]]:
"""백테·파람서치 — ws_orderbook/ws_program 로드 (미제공 시 meta_out+DB)."""
from kis_trader.backtest.trigger_snapshot_loader import (
backtest_use_trigger_snapshot_db,
load_trigger_snapshots_by_code,
)
snap_meta: Dict[str, Any] = {}
if not backtest_use_trigger_snapshot_db(engine_params, strategy=strategy):
snap_meta["trigger_snapshot_skipped"] = True
snap_meta["skip_reason"] = "filters_off"
return orderbook_by_code, program_by_code, snap_meta
ob = dict(orderbook_by_code or {})
pg = dict(program_by_code or {})
if (not ob or not pg) 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")
if db is None and start_key and end_key:
db = ensure_meta_db(meta_out)
if db and start_key and end_key:
ob_loaded, pg_loaded, snap_meta = load_trigger_snapshots_by_code(
db, start_key, end_key, set(candles_by_code.keys()),
engine_params=engine_params, strategy=strategy,
)
if not ob:
ob = ob_loaded
if not pg:
pg = pg_loaded
if snap_meta.get("log_verdict_by_code") and engine_params is not None:
if not engine_params.get("_backtest_disable_log_orderbook_verdict"):
if str(snap_meta.get("orderbook_history_source") or "").lower() != "ls":
engine_params["_backtest_log_verdict_by_code"] = snap_meta[
"log_verdict_by_code"
]
return (ob or None), (pg or None), snap_meta