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>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,570 @@
#!/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