Files
kis_bot/kis_trader/backtest/momentum_random_benchmark.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

566 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
모멘텀 vs 무작위 진입 벤치마크 — param_search 와 동일 데이터·포트폴리오·청산.
청산: ``check_sell_signal_momentum_backtest_bar`` (실매 ``check_sell_signal_momentum_live`` 동일).
무작위: 유니버스(MOMENTUM history) + 매매시간 + 쿨다운·일일한도만 맞추고,
TRIGGER(RSI·vol·EMA) 없이 슬롯마다 후보 1종목 무작위 선택.
실행 (기본: 백그라운드 — nohup 불필요):
cd /home/hoon/kis_bot
python3 -m kis_trader.backtest.momentum_random_benchmark \\
--start 2026-06-01 --end 2026-06-14 --seeds 100
# 포그라운드(터미널 붙잡기)가 필요할 때만:
python3 -m kis_trader.backtest.momentum_random_benchmark --foreground --seeds 10
로그: /tmp/mom_random_bench.log (기본) · PID: /tmp/mom_random_bench.pid
"""
from __future__ import annotations
import argparse
import json
import os
import random
import subprocess
import sys
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
_BG_WORKER_ENV = "MOM_RANDOM_BENCH_WORKER"
DEFAULT_LOG_PATH = "/tmp/mom_random_bench.log"
DEFAULT_PID_PATH = "/tmp/mom_random_bench.pid"
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from database import TradeDB # noqa: E402
from kis_trader.backtest import momentum_backtest_common as mbc # noqa: E402
from kis_trader.backtest import scalping_backtest_common as sbc # noqa: E402
from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402
attach_scalp_trade_pnl,
backtest_slip_pct,
load_portfolio_env_row,
min_invest_ratio_of_slot,
portfolio_exposure_krw,
target_qty_and_cost,
)
from kis_trader.backtest.momentum_portfolio_backtest import ( # noqa: E402
_buy_priority_key,
_max_stocks_from_params,
_resolve_invest_cap_krw,
_total_budget_from_params,
)
from kis_trader.backtest.param_search_momentum import ( # noqa: E402
_load_candles_for_search,
_mom_fixed_defaults,
_ui_to_engine_params,
)
from kis_trader.engine.momentum_engine import ( # noqa: E402
MOMENTUM_STRATEGY_ID,
_slot_key,
_t2dt,
_to_bool,
check_sell_signal_momentum_backtest_bar,
effective_tp_pct_from_params,
eval_momentum_buy_at_index,
)
def _hm_from_candle_time(t: str) -> int:
s = str(t)[8:12]
return int(s) if len(s) >= 4 else 0
def _session_ok(t: str, params: Dict[str, Any]) -> bool:
hm = _hm_from_candle_time(t)
ts = int(params.get("time_start_hm", 900))
te = int(params.get("time_end_hm", 1530))
return ts <= hm < te
def _cooldown_ok(t: str, day: str, last_exit_dt, cooldown_min: float) -> bool:
if not last_exit_dt:
return True
try:
from datetime import datetime as _dt
cur = _dt.strptime(t, "%Y%m%d%H%M%S")
last = last_exit_dt if hasattr(last_exit_dt, "year") else _t2dt(str(last_exit_dt))
elapsed = (cur - last).total_seconds() / 60.0
return elapsed >= float(cooldown_min)
except Exception:
return True
def run_portfolio(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]],
*,
random_seed: Optional[int] = None,
) -> List[Dict]:
"""시각순 포트폴리오 — random_seed 있으면 무작위 진입."""
rng = random.Random(random_seed) if random_seed is not None else None
rsi_period = int(params.get("rsi_period", 3))
min_bars = max(rsi_period + 5, 6)
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=MOMENTUM_STRATEGY_ID)
invest_cap = _resolve_invest_cap_krw(params, slot_money)
cooldown_min = float(params.get("cooldown_min", 10))
max_daily = int(params.get("max_daily", 5))
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]
ctx_by_code[code] = {
"code": code,
"candles": candles,
"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:
if not _session_ok(t, params):
continue
slot_key = _slot_key(t, int(params.get("scan_interval_min", 1)))
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:
continue
invest = min(invest_cap, remaining, target_cost)
qty = int(invest / entry_price)
if qty < 1:
continue
cost = qty * entry_price
if cost < min_required or exposure + cost > total_budget + 1e-6:
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
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]
if t == portfolio[code]["entry_time"]:
continue
is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day)
is_eod = is_eod_raw and force_eod_exit
cur_c_info = {
"open": float(c["open"]),
"high": float(c["high"]),
"low": float(c["low"]),
"close": float(c["close"]),
"candle_time": t,
}
pos = portfolio[code]
res = check_sell_signal_momentum_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,
"strategy": MOMENTUM_STRATEGY_ID,
}
all_trades.append(trade)
ctx["last_exit_dt"][day] = _t2dt(t)
del portfolio[code]
if len(portfolio) >= max_stocks:
continue
if portfolio_exposure_krw(portfolio) >= 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 and code not in universe_by_slot.get(slot_key, []):
continue
if cl <= 0 or idx < 5:
continue
if ctx["daily_cnt"].get(day, 0) >= max_daily:
continue
if not _cooldown_ok(t, day, ctx["last_exit_dt"].get(day), cooldown_min):
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
if rng is None:
eval_params = dict(params)
if "skip_hts_scan_dupes" not in eval_params:
from kis_trader.engine.momentum_hts_logic import resolve_momentum_skip_hts_scan_dupes
eval_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes()
state = {
"daily_cnt": ctx["daily_cnt"].get(day, 0),
"last_exit_dt": ctx["last_exit_dt"].get(day),
}
reject, _msg, sig = eval_momentum_buy_at_index(candles, idx, eval_params, state)
if reject or not sig:
continue
pe_data: Dict[str, Any] = {
"entry_time": next_c["candle_time"],
"entry_price": entry_price,
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"rsi": sig.get("rsi"),
}
else:
pe_data = {
"entry_time": next_c["candle_time"],
"entry_price": entry_price,
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"rsi": None,
}
candidates.append((_buy_priority_key(code, slot_key, universe_by_slot), code, pe_data))
if not candidates:
continue
if rng is not None:
_pri, pick_code, pe = rng.choice(candidates)
else:
candidates.sort(key=lambda x: x[0])
_pri, pick_code, pe = candidates[0]
ctx_by_code[pick_code]["pending_entry"] = pe
ctx_by_code[pick_code]["daily_cnt"][t[:8]] = (
ctx_by_code[pick_code]["daily_cnt"].get(t[:8], 0) + 1
)
fee_rate = float(params.get("fee_rate", 0.00015))
sell_tax = float(params.get("sell_tax", 0.0018))
attach_scalp_trade_pnl(
all_trades, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=backtest_slip_pct(params),
)
return all_trades
def _build_engine_params(ui: Dict[str, Any], fixed: Dict[str, Any]) -> Dict[str, Any]:
merged = dict(fixed)
merged.update(ui)
return _ui_to_engine_params(merged)
def _load_universe(start: str, end: str) -> Optional[Dict[str, List[str]]]:
start_ymd = start.replace("-", "")
end_ymd = end.replace("-", "")
try:
universe, _, _, _, _ = mbc.resolve_momentum_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM",
)
return universe
except Exception:
return None
def _stats(trades: List[Dict], total_budget: float, period_days: int) -> Dict[str, Any]:
return mbc.summarize_momentum_trades(
trades, total_budget_krw=total_budget, period_days=period_days,
)
def _print_row(label: str, st: Dict[str, Any]) -> None:
print(
f" {label:<22} | 손익 {st['total_pnl']:>10,.0f}원 | "
f"거래 {st['total_trades']:>3} | 승률 {st['win_rate']:>5.1f}% | "
f"PF {st['pf']:>5.2f} | MDD {st.get('mdd_krw', st.get('mdd', 0)):,.0f}",
flush=True,
)
def _read_running_pid(pid_path: str) -> Optional[int]:
try:
with open(pid_path, "r", encoding="utf-8") as f:
pid = int(f.read().strip())
os.kill(pid, 0)
return pid
except (OSError, ValueError, ProcessLookupError):
return None
def _write_pid(pid_path: str) -> None:
with open(pid_path, "w", encoding="utf-8") as f:
f.write(str(os.getpid()))
def _clear_pid(pid_path: str) -> None:
try:
if _read_running_pid(pid_path) == os.getpid():
os.remove(pid_path)
except OSError:
pass
def _spawn_background(log_path: str, pid_path: str) -> int:
"""부모는 즉시 반환 — 워커는 detached 세션에서 로그 파일로 출력."""
running = _read_running_pid(pid_path)
if running:
print(f"⛔ 이미 실행 중 (pid={running})", flush=True)
print(f" tail -f {log_path}", flush=True)
return 2
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
log_f = open(log_path, "a", encoding="utf-8")
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_f.write(f"\n[{stamp}] 백그라운드 워커 시작\n")
log_f.flush()
child_argv = [sys.executable, "-u"] + sys.argv[1:]
if "--foreground" not in child_argv:
child_argv.append("--foreground")
env = os.environ.copy()
env[_BG_WORKER_ENV] = "1"
proc = subprocess.Popen(
child_argv,
stdin=subprocess.DEVNULL,
stdout=log_f,
stderr=subprocess.STDOUT,
cwd=ROOT,
env=env,
start_new_session=True,
)
log_f.close()
try:
with open(pid_path, "w", encoding="utf-8") as pf:
pf.write(str(proc.pid))
except OSError:
pass
print(f"✅ 백그라운드 시작 pid={proc.pid}", flush=True)
print(f" 로그: {log_path}", flush=True)
print(f" 확인: tail -f {log_path}", flush=True)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="모멘텀 vs 무작위 진입 벤치마크")
parser.add_argument("--start", default="2026-06-01")
parser.add_argument("--end", default="2026-06-14")
parser.add_argument("--seeds", type=int, default=100, help="무작위 시드 반복 횟수")
parser.add_argument("--json-rank1", default="", help="search_momentum JSON (1위 params)")
parser.add_argument(
"--foreground", action="store_true",
help="포그라운드 실행 (기본: 백그라운드)",
)
parser.add_argument("--log", default=DEFAULT_LOG_PATH, help="백그라운드 로그 경로")
parser.add_argument("--pid-file", default=DEFAULT_PID_PATH, help="실행 중 PID 파일")
args = parser.parse_args()
is_worker = os.environ.get(_BG_WORKER_ENV) == "1" or args.foreground
if not is_worker:
return _spawn_background(args.log, args.pid_file)
_write_pid(args.pid_file)
try:
return _run_benchmark(args)
finally:
_clear_pid(args.pid_file)
def _run_benchmark(args: argparse.Namespace) -> int:
t0 = time.time()
fixed = _mom_fixed_defaults()
rsi_period = int(fixed.get("rsi_period", 3))
env_row = load_portfolio_env_row()
fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")
portfolio = sbc.resolve_scalp_portfolio_params(
env_row, None, strategy="MOMENTUM", slot_money=slot_from_env,
)
slot_money = float(portfolio["slot_money"])
max_stocks = int(portfolio["max_stocks"])
total_budget = float(portfolio["total_budget_krw"])
period_days = max(
1,
(datetime.strptime(args.end, "%Y-%m-%d") - datetime.strptime(args.start, "%Y-%m-%d")).days + 1,
)
json_path = args.json_rank1
if not json_path:
json_path = os.path.join(
HERE, "results", "search_momentum_fast_20260615_012529.json",
)
with open(json_path, "r", encoding="utf-8") as f:
search_data = json.load(f)
rank1_ui = dict((search_data.get("top") or [{}])[0].get("params") or {})
rank1_engine = _build_engine_params(rank1_ui, fixed)
rank1_engine["slot_money"] = slot_money
rank1_engine["max_stocks"] = max_stocks
rank1_engine["total_budget_krw"] = total_budget
rank1_engine["fee_rate"] = fee_rate
rank1_engine["sell_tax"] = sell_tax
db_engine = _build_engine_params({}, fixed)
db_engine["slot_money"] = slot_money
db_engine["max_stocks"] = max_stocks
db_engine["total_budget_krw"] = total_budget
db_engine["fee_rate"] = fee_rate
db_engine["sell_tax"] = sell_tax
print("=" * 72, flush=True)
print(f"모멘텀 벤치마크 {args.start} ~ {args.end} ({period_days}일)", flush=True)
print(
f"포트폴리오: 슬롯 {slot_money:,.0f} | 동시 {max_stocks} | 한도 {total_budget:,.0f} | "
f"수수료 {fee_rate*100:.4f}% + 세 {sell_tax*100:.2f}%",
flush=True,
)
print("=" * 72, flush=True)
print("⏳ 캔들 로드...", flush=True)
candles = _load_candles_for_search(args.start, args.end, rsi_period)
print(f"{len(candles):,}종목", flush=True)
universe = _load_universe(args.start, args.end)
if universe:
avg = sum(len(v) for v in universe.values()) / max(1, len(universe))
print(f"✅ 유니버스: MOMENTUM history | {len(universe):,}슬롯 · 평균 {avg:.1f}", flush=True)
else:
print("⚠️ 유니버스 이력 없음 — 전종목", flush=True)
print("\n[1] 탐색 1위 로직 (fast grid rank1)", flush=True)
print(f" params: vol×{rank1_ui.get('mom_vol_mult')} RSI {rank1_ui.get('mom_rsi_min')}~{rank1_ui.get('mom_rsi_max')} "
f"EMA {'ON' if rank1_ui.get('use_ema_filter') else 'OFF'} "
f"{rank1_ui.get('ema_fast_period')}/{rank1_ui.get('ema_slow_period')}", flush=True)
t1 = run_portfolio(candles, rank1_engine, universe, random_seed=None)
st1 = _stats(t1, total_budget, period_days)
_print_row("탐색1위 로직", st1)
print("\n[2] 현재 DB 실매 설정", flush=True)
t2 = run_portfolio(candles, db_engine, universe, random_seed=None)
st2 = _stats(t2, total_budget, period_days)
_print_row("DB 실매", st2)
print(f"\n[3] 무작위 진입 × {args.seeds}회 (동일 유니버스·청산·포트폴리오)", flush=True)
pnls: List[float] = []
trades_n: List[int] = []
win_rates: List[float] = []
pfs: List[float] = []
for seed in range(1, args.seeds + 1):
tr = run_portfolio(candles, rank1_engine, universe, random_seed=seed)
st = _stats(tr, total_budget, period_days)
pnls.append(float(st["total_pnl"]))
trades_n.append(int(st["total_trades"]))
win_rates.append(float(st["win_rate"]))
pfs.append(float(st["pf"]))
if seed % 25 == 0:
print(f" ... seed {seed}/{args.seeds}", flush=True)
import statistics
avg_pnl = statistics.mean(pnls)
med_pnl = statistics.median(pnls)
avg_pf = statistics.mean(pfs)
avg_wr = statistics.mean(win_rates)
avg_tr = statistics.mean(trades_n)
beat = sum(1 for p in pnls if p > st1["total_pnl"])
beat_db = sum(1 for p in pnls if p > st2["total_pnl"])
print(f"\n{'=' * 72}", flush=True)
print("📊 요약", flush=True)
_print_row("탐색1위 로직", st1)
_print_row("DB 실매", st2)
print(
f" {'무작위(평균)':<22} | 손익 {avg_pnl:>10,.0f}원 | "
f"거래 {avg_tr:>5.0f} | 승률 {avg_wr:>5.1f}% | PF {avg_pf:>5.2f}",
flush=True,
)
print(
f" {'무작위(중앙값)':<22} | 손익 {med_pnl:>10,.0f}원 | "
f"min {min(pnls):,.0f} max {max(pnls):,.0f}",
flush=True,
)
print(
f"\n 무작위 {args.seeds}회 중 탐색1위보다 나은 비율: {beat}/{args.seeds} ({100*beat/args.seeds:.0f}%)",
flush=True,
)
print(
f" 무작위 {args.seeds}회 중 DB실매보다 나은 비율: {beat_db}/{args.seeds} ({100*beat_db/args.seeds:.0f}%)",
flush=True,
)
if st1["total_pnl"] <= avg_pnl:
print("\n ⚠️ 탐색1위 로직 ≤ 무작위 평균 → TRIGGER 엣지 없음 (운/노이즈 수준)", flush=True)
else:
print(f"\n ✅ 탐색1위가 무작위 평균 대비 {st1['total_pnl']-avg_pnl:+,.0f}", flush=True)
print(f"\n⏱ 총 {time.time()-t0:.0f}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())