#!/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: engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"] return (ob or None), (pg or None), snap_meta