Files
kis_bot/kis_trader/backtest/backtest_portfolio_common.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

571 lines
23 KiB
Python
Raw Permalink 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
# 전략별 env 키 (첫 매칭 우선)
STRATEGY_PORTFOLIO_KEYS: Dict[str, Dict[str, Tuple[str, ...]]] = {
"TAIL": {
"max_stocks": ("TAIL_MAX_STOCKS",),
"total_budget": ("TAIL_TOTAL_BUDGET_KRW",),
"slot": ("TAIL_SLOT_MONEY",),
"per_stock_cap": ("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"),
},
"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"),
},
}
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_* 만."""
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 == "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 == "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 등이 덮인다)
_KNOWN_STRATEGY_PREFIXES: Tuple[str, ...] = (
"TAIL_", "SHORT_", "SCALP_", "MOMENTUM_",
"BREAKOUT_", "RANGE_BREAK_", "UPDOW_", "DBBAND_",
)
def _is_foreign_strategy_key(key: str, own_prefix: str) -> bool:
"""key 가 '자기 전략 prefix 가 아닌' 다른 전략 전용 키인지. (무prefix 공용키는 False)."""
for pfx in _KNOWN_STRATEGY_PREFIXES:
if key.startswith(pfx):
return pfx != own_prefix
return False
def portfolio_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
"""1회투자·동시보유·총한도 → env_config 문자열 patch (웹 save·파라서치 apply 공통).
※ 쓰기 시 '자기 전략 키 + 공용 무prefix 키' 만 기록한다. 읽기용 fallback 튜플에
섞인 다른 전략 전용 키(SCALP_*, SHORT_* 등)는 제외 → 남의 전략 설정 오염 금지.
"""
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:
s = str(candle_time).strip().replace("-", "").replace(":", "").replace(" ", "")
if len(s) >= 14:
return datetime.strptime(s[:14], "%Y%m%d%H%M%S")
return datetime.strptime(s[:12], "%Y%m%d%H%M")
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(DB) 폴백. (실매 load_* 와 동일 키)."""
from kis_trader.utils.env import get_env_from_db, get_env_float
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)
return float(get_env_float(key_env, default) or 0.0)
mode = str(
p.get("daily_profit_mode") or get_env_from_db("DAILY_PROFIT_MODE", "fixed") or "fixed"
).lower()
# 다단계 tier(우선) — 전용키 daily_trail_tiers → env. 비면 단일 drop 폴백.
tiers = p.get("daily_trail_tiers")
if tiers is None:
tiers = get_env_from_db("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 와 동일: 신규매수만 중단.)
명시 게이트 BACKTEST_DAILY_PROFIT_TRAIL(또는 params['_backtest_daily_profit_trail'])
OFF 면 그대로 반환 → 기존 백테 동작 100% 불변.
"""
p = params or {}
gate = p.get("_backtest_daily_profit_trail")
if gate is None:
from kis_trader.utils.env import get_env_bool
gate = get_env_bool("BACKTEST_DAILY_PROFIT_TRAIL", False)
if not bool(gate):
return trades
from kis_trader.engine.daily_profit_halt import (
_guard_active, _target_reached, _trail_reached,
)
cfg = _daily_halt_cfg(p)
if not _guard_active(cfg):
return trades
mode = str(cfg.get("mode") or "fixed").lower()
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
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
for t in ts:
et = str(t.get("entry_time") or t.get("buy_time") or "")
if trigger_time and et >= trigger_time:
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,
) -> None:
"""스캘핑·모멘텀·돌파 공통 손익 (buy_time/sell_time 필드).
slip_pct: 백테 체결 슬리피지(편도 %). 시장가 즉시 체결 가정이라 매수는 불리(+)·
매도는 불리(-) 로 진입·청산 양측 반영. 표시용 가격은 그대로 두고 pnl 에만 반영.
0=OFF(동작 불변).
"""
slip = max(0.0, float(slip_pct or 0.0)) / 100.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
t["pnl"] = round(
sell_amt - buy_amt - buy_amt * fee_rate - sell_amt * fee_rate - sell_amt * sell_tax
)
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 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:
engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
return (ob or None), (pg or None), snap_meta