#!/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 ( flatten_remaining_portfolio_trades, min_invest_ratio_of_slot, portfolio_exposure_krw, target_qty_and_cost, ) from kis_trader.engine.scalping_engine import ( _apply_buy_state_filters, _eval_scalp_buy_at_index, _macd_lines_from_params, _slot_key, _t2dt, _to_bool, check_sell_signal_backtest_bar, check_sell_signal_live, compute_rsi_series, effective_tp_pct_from_params, ) from kis_trader.engine.strategy_eod import ( eod_bar_time_key, is_strategy_eod_bar, resolve_strategy_eod_params, ) from kis_trader.engine.tick_exit_common import ( backtest_sell_slip_pct, backtest_tick_poll_ms, collect_minute_ticks, resolve_backtest_sell, strategy_tick_fallback_ohlc, strategy_use_tick_exit, ) from kis_trader.engine.tail_tick_replay import align_entry_price_from_ticks from kis_trader.utils.env import get_env_bool 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 _scalp_use_tick_entry(params: Optional[Dict[str, Any]] = None) -> bool: """백테 진입: 예약 체결 시 해당 분 첫 틱 가격 (기본 ON — 실매 체결 정합).""" if params is not None and params.get("backtest_use_tick_entry") is not None: return _to_bool(params.get("backtest_use_tick_entry"), True) return get_env_bool("SCALP_BACKTEST_USE_TICK_ENTRY", True) 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", ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: """ 시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사. - reversal 전용 (모멘텀은 ``momentum_portfolio_backtest``). - 매도: 틱 우선 ``resolve_backtest_sell`` → ``check_sell_signal_live`` """ 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, ticks_by_code=ticks_by_code, ) strategy = "SCALP" rsi_period = int(params.get("rsi_period", 3)) min_bars = rsi_period + 5 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 use_tick_exit = bool(ticks_by_code) and strategy_use_tick_exit( params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True, ) tick_fallback_ohlc = strategy_tick_fallback_ohlc( params, "SCALP_BACKTEST_TICK_FALLBACK_OHLC", default=False, ) tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="SCALP_BACKTEST_POLL_MS") tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="SCALP_BACKTEST_SELL_SLIP_PCT") use_tick_entry = bool(ticks_by_code) and _scalp_use_tick_entry(params) tick_exit_count = 0 ohlc_exit_count = 0 tick_entry_count = 0 ctx_by_code: Dict[str, Dict[str, Any]] = {} all_times_set = set() period_start = str(params.get("_backtest_period_start_key") or "")[:12] 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: ct = str(c.get("candle_time") or "") if period_start and ct < period_start: continue all_times_set.add(ct) all_times = sorted(all_times_set) portfolio: Dict[str, Dict[str, Any]] = {} all_trades: List[Dict] = [] from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at for t in all_times: if apply_env_timeline_at(params, t, "SCALP"): 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) invest_cap = _resolve_invest_cap_krw(params, slot_money) sl_pct = abs(float(params.get("sl_pct", 0.015))) tp_pct = effective_tp_pct_from_params(params) 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 use_tick_entry and entry_price > 0: minute_ticks = collect_minute_ticks(ticks_by_code, code, t) aligned, align_src = align_entry_price_from_ticks(minute_ticks, entry_price) if aligned > 0: entry_price = float(aligned) if align_src == "ws_ticks": tick_entry_count += 1 # 진입가 변경 시 손절·익절 재계산 (비율 동일) pe["stop"] = entry_price * (1 - sl_pct) pe["target"] = entry_price * (1 + tp_pct) 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: 보유 종목 청산 ── # 실매는 벽시계 EOD(15:25) — 해당 분봉이 없는 종목도 직전가로 장마감청산 is_eod_t = is_strategy_eod_bar(t, params, "SCALP") for code in list(portfolio.keys()): ctx = ctx_by_code.get(code) if ctx is None: continue pos = portfolio[code] entry_t = str(pos.get("entry_time") or "") entry_key = entry_t[:12] if entry_t else "" t_key = str(t)[:12] if entry_key and t_key <= entry_key: continue idx = ctx["time_index"].get(t) candles = ctx["candles"] day = t_key[:8] if len(t_key) >= 8 else str(t)[:8] if idx is None: if not is_eod_t: continue # 벽시계 EOD: 이 시각 봉 없음 → 진입 이후 마지막 확정봉 종가 last = None for c in reversed(candles): ct = str(c.get("candle_time") or "") if not ct: continue if entry_key and ct[:12] < entry_key: continue if ct[:12] > t_key: continue last = c break if last is None: continue exit_price = float(last.get("close") or 0) if exit_price <= 0: continue _eod_on, eod_hm = resolve_strategy_eod_params(params, "SCALP") sell_time = eod_bar_time_key(day, eod_hm, default_hm="15:25") or t_key trade = { "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": "장마감청산", "hold_min": 0, "exit_source": "wallclock_eod", } if pos.get("rsi") is not None: try: trade["rsi_entry"] = round(float(pos["rsi"]), 1) except (TypeError, ValueError): pass all_trades.append(trade) ctx["last_exit_dt"][day] = _t2dt(sell_time) ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1 del portfolio[code] continue c = candles[idx] hi = float(c["high"]) lo = float(c["low"]) cl = float(c["close"]) op = float(c["open"]) is_eod = is_eod_t cur_c_info = { "open": op, "high": hi, "low": lo, "close": cl, "candle_time": t, } if use_tick_exit: minute_ticks = collect_minute_ticks(ticks_by_code, code, t) res5 = resolve_backtest_sell( pos, cur_c_info, params, is_eod=is_eod, sell_fn=check_sell_signal_live, low_mode="current", ticks=minute_ticks, use_tick_exit=use_tick_exit, tick_fallback_ohlc=tick_fallback_ohlc, poll_ms=tick_poll_ms, slip_pct=tick_sell_slip, ) if not res5: continue reason, exit_price, sell_time, _hold_min, exit_src = res5 if exit_src == "ws_ticks": tick_exit_count += 1 else: ohlc_exit_count += 1 else: max_p = max(float(pos.get("max_price", 0) or 0), hi) pos["max_price"] = max_p res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod) if not res: continue reason, exit_price = res sell_time = t ohlc_exit_count += 1 # EOD 봉이 15:30만 있어도 사유·시각은 실매 EOD(15:25)에 맞춤 if reason == "장마감청산": eod_on, eod_hm = resolve_strategy_eod_params(params, "SCALP") eod_key = eod_bar_time_key(day, eod_hm, default_hm="15:25") if eod_key: sell_time = eod_key trade: Dict[str, Any] = { "code": code, "buy_time": pos["entry_time"], "sell_time": sell_time or 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(sell_time or 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 "skip_hts_scan_dupes" not in eval_params: from kis_trader.engine.scalping_engine import resolve_scalp_skip_hts_scan_dupes eval_params["skip_hts_scan_dupes"] = resolve_scalp_skip_hts_scan_dupes() state = { "daily_cnt": ctx["daily_cnt"].get(day, 0), "last_exit_dt": ctx["last_exit_dt"].get(day), } st = _apply_buy_state_filters(candles, idx, eval_params, state) if st[2] is None: continue from kis_trader.engine.whipsaw_filter import inject_whipsaw_ticks_into_params from kis_trader.backtest.trigger_snapshot_loader import ( inject_trigger_snapshots_into_params, ) # 휩쏘 틱 lookback — 모멘텀 포트폴리오와 동일 (신호봉 기준) inject_whipsaw_ticks_into_params( eval_params, ticks_by_code=ticks_by_code, code=code, bar_candle_time=str(c.get("candle_time") or t), strategy="SCALP", tf_min=1, ) inject_trigger_snapshots_into_params( eval_params, orderbook_by_code=params.get("_bt_orderbook_by_code"), program_by_code=params.get("_bt_program_by_code"), code=code, bar_candle_time=str(c.get("candle_time") or t), ) 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: 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 from kis_trader.engine.mid_enroll_entry_gate import bt_should_defer_mid_enroll if bt_should_defer_mid_enroll( str(next_c.get("candle_time") or ""), code, t, tf_min=1, universe_by_slot=universe_by_slot, params=params, ): 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, "entry_bar_key": str(next_c.get("candle_time") or "")[:12], "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 skip_stats: Dict[str, Any] = {} if skipped_micro_buys: skip_stats["skipped_micro_buys"] = skipped_micro_buys if tick_exit_count or ohlc_exit_count: skip_stats["tick_exit_count"] = tick_exit_count skip_stats["ohlc_exit_count"] = ohlc_exit_count if tick_entry_count: skip_stats["tick_entry_count"] = tick_entry_count flat_n = flatten_remaining_portfolio_trades( portfolio, ctx_by_code, all_trades, params=params, strategy=strategy, ) if flat_n: skip_stats["bt_flatten_count"] = flat_n if skip_stats: params["_portfolio_skip_stats"] = skip_stats all_trades.sort(key=lambda x: x["sell_time"]) return all_trades