#!/usr/bin/env python3 """ SCALP reversal 시각순 포트폴리오 백테스트 — tail_engine.run_tail_backtest_portfolio 와 동일 구조. 모멘텀(MOMENTUM)은 ``momentum_portfolio_backtest.run_momentum_backtest_portfolio`` 로 위임. """ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from kis_trader.backtest.backtest_portfolio_common import ( min_invest_ratio_of_slot, portfolio_exposure_krw, target_qty_and_cost, ) from kis_trader.engine.scalping_engine import ( _apply_buy_state_filters, _eval_momentum_buy_at_index, _eval_scalp_buy_at_index, _macd_lines_from_params, _slot_key, _t2dt, _to_bool, check_sell_signal_backtest_bar, compute_rsi_series, effective_tp_pct_from_params, ) def _buy_priority_key( code: str, slot_key: str, universe_by_slot: Optional[Dict[str, List[str]]], ) -> Tuple[int, str]: if universe_by_slot is None: return (0, code) lst = universe_by_slot.get(slot_key) or [] try: return (lst.index(code), code) except ValueError: return (999999, code) def _max_stocks_from_params(params: Dict[str, Any]) -> int: for key in ("max_stocks", "scalp_max_stocks", "short_max_stocks"): v = params.get(key) if v not in (None, "", 0): return max(1, int(v)) try: from kis_trader.utils.env import get_env_int n = get_env_int("SCALP_MAX_STOCKS", 0) or get_env_int("MAX_STOCKS", 3) return max(1, int(n)) except Exception: return 3 def _total_budget_from_params(params: Dict[str, Any]) -> float: for key in ("total_budget_krw", "scalp_total_budget_krw", "short_total_budget_krw"): v = params.get(key) if v not in (None, ""): try: return float(v) except (TypeError, ValueError): pass try: from kis_trader.utils.env import get_env_int cap = get_env_int("SCALP_TOTAL_BUDGET_KRW", 0) if cap > 0: return float(cap) except Exception: pass return 0.0 def _resolve_invest_cap_krw(params: Dict[str, Any], slot_money: float) -> float: """legacy loop 와 동일 — max_loss/sl_pct 로 1회 투입 상한.""" sl_pct = abs(float(params.get("sl_pct", 0.015))) max_loss_krw = float(params.get("max_loss_krw", 200000.0)) invest_amount = float(slot_money) if max_loss_krw > 0 and sl_pct > 0: invest_limit = max_loss_krw / sl_pct invest_amount = min(invest_limit, float(slot_money)) return invest_amount def run_scalping_backtest_portfolio( codes_candles: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, mode: str = "reversal", ) -> List[Dict]: """ 시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사. - ``mode='reversal'``: ``_eval_scalp_buy_at_index`` - ``mode='momentum'``: ``_eval_momentum_buy_at_index`` """ mode = str(mode or "reversal").strip().lower() if mode == "momentum": from kis_trader.backtest.momentum_portfolio_backtest import run_momentum_backtest_portfolio return run_momentum_backtest_portfolio( codes_candles, params, universe_by_slot=universe_by_slot, ) strategy = "SCALP" rsi_period = int(params.get("rsi_period", 3)) min_bars = max(rsi_period + 5, 6) if mode == "momentum" else rsi_period + 5 force_eod_exit = _to_bool(params.get("force_eod_exit"), False) sl_pct = abs(float(params.get("sl_pct", 0.015))) tp_pct = effective_tp_pct_from_params(params) max_stocks = _max_stocks_from_params(params) slot_money = float(params.get("slot_money", 300_000)) total_budget = _total_budget_from_params(params) if total_budget <= 0: total_budget = float(max_stocks * slot_money) min_invest_ratio = min_invest_ratio_of_slot(params, strategy=strategy) invest_cap = _resolve_invest_cap_krw(params, slot_money) use_macd_cross = _to_bool(params.get("use_macd_cross", False), False) skipped_micro_buys = 0 ctx_by_code: Dict[str, Dict[str, Any]] = {} all_times_set = set() for code, raw_rows in codes_candles.items(): if len(raw_rows) < min_bars: continue candles = [dict(r) for r in raw_rows] macd_combined = ( _macd_lines_from_params(candles, params) if use_macd_cross else None ) ctx_by_code[code] = { "code": code, "candles": candles, "macd_combined": macd_combined, "time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)}, "last_exit_dt": {}, "daily_cnt": {}, "pending_entry": None, } for c in candles: all_times_set.add(c["candle_time"]) all_times = sorted(all_times_set) portfolio: Dict[str, Dict[str, Any]] = {} all_trades: List[Dict] = [] for t in all_times: slot_key = _slot_key(t, params.get("scan_interval_min", 1)) # ── Phase 0: 예약 진입 (직전 봉 신호 → 이번 봉 시가) ── pending_codes = [ code for code, ctx in ctx_by_code.items() if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t ] pending_codes.sort(key=lambda c: _buy_priority_key(c, slot_key, universe_by_slot)) for code in pending_codes: ctx = ctx_by_code[code] pe = ctx.pop("pending_entry", None) if not pe or code in portfolio: continue if len(portfolio) >= max_stocks: break entry_price = float(pe["entry_price"]) if entry_price <= 0: continue exposure = portfolio_exposure_krw(portfolio) remaining = max(0.0, total_budget - exposure) target_qty, target_cost = target_qty_and_cost(entry_price, invest_cap) min_required = target_cost * min_invest_ratio if target_qty < 1 or remaining < min_required: skipped_micro_buys += 1 continue invest = min(invest_cap, remaining, target_cost) qty = int(invest / entry_price) if qty < 1: skipped_micro_buys += 1 continue cost = qty * entry_price if cost < min_required: skipped_micro_buys += 1 continue if exposure + cost > total_budget + 1e-6: skipped_micro_buys += 1 continue portfolio[code] = { "entry_price": entry_price, "entry_time": t, "qty": qty, "stop": pe["stop"], "target": pe["target"], "max_price": entry_price, "rsi": pe.get("rsi"), } break # 1시각 1매수 # ── Phase 1: 보유 종목 청산 ── for code in list(portfolio.keys()): ctx = ctx_by_code.get(code) if ctx is None: continue idx = ctx["time_index"].get(t) if idx is None: continue candles = ctx["candles"] c = candles[idx] day = t[:8] hi = float(c["high"]) lo = float(c["low"]) cl = float(c["close"]) op = float(c["open"]) is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day) is_eod = is_eod_raw and force_eod_exit pos = portfolio[code] if t == pos["entry_time"]: continue max_p = max(float(pos.get("max_price", 0) or 0), hi) pos["max_price"] = max_p cur_c_info = { "open": op, "high": hi, "low": lo, "close": cl, "candle_time": t, } res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod) if not res: continue reason, exit_price = res trade: Dict[str, Any] = { "code": code, "buy_time": pos["entry_time"], "sell_time": t, "buy_price": pos["entry_price"], "sell_price": round(exit_price, 2), "qty": pos.get("qty", 1), "pnl": 0, "sell_reason": reason, "hold_min": 0, } if pos.get("rsi") is not None: trade["rsi_entry"] = round(float(pos["rsi"]), 1) all_trades.append(trade) ctx["last_exit_dt"][day] = _t2dt(t) ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1 del portfolio[code] # ── Phase 2: 신규 매수 신호 (다음 봉 시가 진입 예약) ── if len(portfolio) >= max_stocks: continue exposure = portfolio_exposure_krw(portfolio) if exposure >= total_budget - 1e-6: continue candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = [] for code, ctx in ctx_by_code.items(): if code in portfolio or ctx.get("pending_entry"): continue idx = ctx["time_index"].get(t) if idx is None: continue candles = ctx["candles"] c = candles[idx] day = t[:8] cl = float(c["close"]) if universe_by_slot is not None: if code not in universe_by_slot.get(slot_key, []): continue if cl <= 0: continue eval_params = dict(params) if universe_by_slot is not None: eval_params.setdefault("skip_hts_scan_dupes", True) else: eval_params.setdefault("skip_hts_scan_dupes", False) state = { "daily_cnt": ctx["daily_cnt"].get(day, 0), "last_exit_dt": ctx["last_exit_dt"].get(day), } if mode == "momentum": if idx < 5: continue reject, _msg, sig = _eval_momentum_buy_at_index( candles, idx, eval_params, state, ) else: st = _apply_buy_state_filters(candles, idx, eval_params, state) if st[2] is None: continue reject, _msg, sig = _eval_scalp_buy_at_index( candles, idx, eval_params, macd_combined=ctx.get("macd_combined"), ) if reject or not sig: continue rsi = sig.get("rsi") if rsi is None and mode == "reversal": continue if idx + 1 >= len(candles): continue next_c = candles[idx + 1] if next_c["candle_time"][:8] != day: continue entry_price = float(next_c["open"]) if entry_price <= 0: continue stop = entry_price * (1 - sl_pct) target = entry_price * (1 + tp_pct) pri = _buy_priority_key(code, slot_key, universe_by_slot) pe_data: Dict[str, Any] = { "entry_time": next_c["candle_time"], "entry_price": entry_price, "stop": stop, "target": target, } if rsi is not None: pe_data["rsi"] = rsi candidates.append((pri, code, pe_data)) if not candidates: continue candidates.sort(key=lambda x: x[0]) _pri, pick_code, pe = candidates[0] ctx_by_code[pick_code]["pending_entry"] = pe if skipped_micro_buys: params["_portfolio_skip_stats"] = {"skipped_micro_buys": skipped_micro_buys} all_trades.sort(key=lambda x: x["sell_time"]) return all_trades