517 lines
20 KiB
Python
517 lines
20 KiB
Python
#!/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
|