"""Strategy C 백테스트 — 상위 N종목 × 최근 3개월 시뮬레이션. 실행: python backtest.py # 손절선 비교 (1% vs 1.5% vs 2%), 15분봉 python backtest.py --10m # 10분봉, 손절선 비교 python backtest.py --15m # 15분봉, 손절선 비교 python backtest.py --20m # 20분봉, 손절선 비교 python backtest.py --interval-cmp # 봉주기 비교 (10m vs 15m vs 20m), 손절 1.5% 고정 python backtest.py --btc-cmp # BTC 필터 유무 비교 (없음 / 2h하락 / MA20 / 둘다) python backtest.py --vol # 변동성 돌파 전략 (K=0.3/0.5/0.7, 일봉 기반) python backtest.py --hard-stop-cmp # 하드스탑 비교 (트레일링 1.5% 고정, 하드 1.5/3/5/없음) python backtest.py --top50-cmp # 종목수(20vs50) + 거래대금 급증 비교 (2x/3x/4x) python backtest.py --trend-cmp # 추세 상한선 비교 (+5/7/10/15% 상한) python backtest.py --ticker-cmp # 종목 승률 필터 비교 (SQL 즉시 분석) python backtest.py --tp-cmp # 부분 익절 구조 비교 (없음 / +5% 50% / +3% 50%) 캐시: Oracle ADB backtest_ohlcv / backtest_daily / backtest_trades 테이블 시뮬레이션 결과는 backtest_trades에 파라미터 해시로 저장 — 동일 파라미터 재실행 시 즉시 반환 """ import os as _os, sys as _sys _sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) from __future__ import annotations import os import sys import time from contextlib import contextmanager from datetime import datetime, timedelta from typing import Optional import hashlib import json import oracledb import pandas as pd import numpy as np import pyupbit import requests from dotenv import load_dotenv load_dotenv() MIN_CANDLES = 100 # DB에 이 수 이상의 봉이 있으면 API 재호출 없이 DB 사용 # ── 파라미터 ──────────────────────────────────────────────────────────────── TOP_N = 20 MONTHS = 3 MA_PERIOD = 20 VOLUME_MULT = 2.0 STOP_LOSS = 0.05 # 비교 대상 — run_scenario() 에서 오버라이드 TIME_STOP_H = 8 TIME_STOP_GAIN = 0.03 PER_POSITION = 3_333_333 FEE = 0.0005 CANDLES_PER_PAGE = 200 EXCLUDE = {"KRW-USDT", "KRW-USDC", "KRW-DAI", "KRW-BUSD", "KRW-USD1", "KRW-USDE"} # ── 봉주기별 설정 ───────────────────────────────────────────────────────── INTERVAL_CONFIG = { "minute10": {"label": "10분봉", "trend": 0.007, "max_pages": 70}, "minute15": {"label": "15분봉", "trend": 0.010, "max_pages": 50}, "minute20": {"label": "20분봉", "trend": 0.013, "max_pages": 35}, "minute60": {"label": "1시간봉", "trend": 0.030, "max_pages": 30}, } # ── 실행 모드 결정 ──────────────────────────────────────────────────────── COMPARE_INTERVALS = "--interval-cmp" in sys.argv # 봉주기 비교 모드 COMPARE_BTC = "--btc-cmp" in sys.argv # BTC 필터 비교 모드 COMPARE_VOL = "--vol" in sys.argv # 변동성 돌파 비교 모드 COMPARE_HARDSTOP = "--hard-stop-cmp" in sys.argv # 하드 스탑 비교 모드 COMPARE_TOP50 = "--top50-cmp" in sys.argv # 종목수 + 거래대금 급증 비교 모드 COMPARE_TREND = "--trend-cmp" in sys.argv # 추세 상한선 비교 모드 COMPARE_TICKER = "--ticker-cmp" in sys.argv # 종목 승률 필터 비교 모드 COMPARE_WF = "--walkforward-cmp" in sys.argv # walk-forward 필터 비교 모드 COMPARE_TIMESTOP = "--timestop-cmp" in sys.argv # 타임스탑 조건 비교 모드 COMPARE_COMBO = "--combo-cmp" in sys.argv # 추세+거래량 조합 비교 모드 COMPARE_TP = "--tp-cmp" in sys.argv # 부분 익절 구조 비교 모드 if "--10m" in sys.argv: DEFAULT_INTERVAL = "minute10" elif "--15m" in sys.argv: DEFAULT_INTERVAL = "minute15" elif "--20m" in sys.argv: DEFAULT_INTERVAL = "minute20" else: DEFAULT_INTERVAL = "minute15" # 기본값: 15분봉 # ── 1. 데이터 수집 ──────────────────────────────────────────────────────────── def get_top_tickers(n: int = TOP_N) -> list[str]: """24시간 거래대금 기준 상위 N개 KRW 종목 (6개월 이상 데이터 있는 것만).""" all_tickers = pyupbit.get_tickers(fiat="KRW") resp = requests.get( "https://api.upbit.com/v1/ticker", params={"markets": ",".join(all_tickers[:100])}, timeout=10, ) data = [d for d in resp.json() if d["market"] not in EXCLUDE] data.sort(key=lambda x: x.get("acc_trade_price_24h", 0), reverse=True) result = [] for d in data: if len(result) >= n: break ticker = d["market"] daily = pyupbit.get_ohlcv(ticker, interval="day", count=200) if daily is None or len(daily) < 180: print(f" {ticker} 스킵 (데이터 {0 if daily is None else len(daily)}일)") time.sleep(0.1) continue result.append(ticker) time.sleep(0.1) return result def fetch_ohlcv(ticker: str, months: int = MONTHS, interval: str = DEFAULT_INTERVAL, max_pages: int = 50) -> pd.DataFrame: """N개월치 OHLCV 수집 (200개씩 역방향 페이징).""" start = datetime.now() - timedelta(days=30 * months) frames: list[pd.DataFrame] = [] to = datetime.now() for _ in range(max_pages): df = pyupbit.get_ohlcv(ticker, interval=interval, count=CANDLES_PER_PAGE, to=to) if df is None or df.empty: break frames.append(df) if df.index[0] <= start: break to = df.index[0] - timedelta(seconds=1) time.sleep(0.12) if not frames: return pd.DataFrame() result = pd.concat(frames).sort_index() result = result[~result.index.duplicated(keep="last")] return result[result.index >= start].copy() def fetch_daily(ticker: str) -> pd.DataFrame: """일봉 200개 수집 (MA 계산용).""" df = pyupbit.get_ohlcv(ticker, interval="day", count=200) return df if df is not None else pd.DataFrame() # ── Oracle DB 캐시 ──────────────────────────────────────────────────────────── _db_pool: Optional[oracledb.ConnectionPool] = None def _get_pool() -> oracledb.ConnectionPool: global _db_pool if _db_pool is None: kwargs: dict = dict( user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], dsn=os.environ["ORACLE_DSN"], min=1, max=3, increment=1, ) wallet = os.environ.get("ORACLE_WALLET") if wallet: kwargs["config_dir"] = wallet _db_pool = oracledb.create_pool(**kwargs) return _db_pool @contextmanager def _conn(): pool = _get_pool() conn = pool.acquire() try: yield conn conn.commit() except Exception: conn.rollback() raise finally: pool.release(conn) def ensure_tables() -> None: """백테스트 캐시 테이블 없으면 생성.""" ddl_ohlcv = """ CREATE TABLE backtest_ohlcv ( ticker VARCHAR2(20) NOT NULL, interval_cd VARCHAR2(20) NOT NULL, ts TIMESTAMP NOT NULL, open_p NUMBER(20,8), high_p NUMBER(20,8), low_p NUMBER(20,8), close_p NUMBER(20,8), volume_p NUMBER(30,8), fetched_at TIMESTAMP DEFAULT SYSTIMESTAMP, CONSTRAINT pk_bt_ohlcv PRIMARY KEY (ticker, interval_cd, ts) ) """ ddl_daily = """ CREATE TABLE backtest_daily ( ticker VARCHAR2(20) NOT NULL, ts DATE NOT NULL, open_p NUMBER(20,8), high_p NUMBER(20,8), low_p NUMBER(20,8), close_p NUMBER(20,8), volume_p NUMBER(30,8), fetched_at TIMESTAMP DEFAULT SYSTIMESTAMP, CONSTRAINT pk_bt_daily PRIMARY KEY (ticker, ts) ) """ ddl_trades = """ CREATE TABLE backtest_trades ( id VARCHAR2(36) DEFAULT SYS_GUID() PRIMARY KEY, params_hash VARCHAR2(32) NOT NULL, ticker VARCHAR2(20) NOT NULL, interval_cd VARCHAR2(20) NOT NULL, stop_loss NUMBER(8,5) NOT NULL, trend_min NUMBER(8,5) NOT NULL, trend_max NUMBER(8,5) NOT NULL, volume_mult NUMBER(6,2) NOT NULL, entry_ts TIMESTAMP NOT NULL, exit_ts TIMESTAMP NOT NULL, buy_price NUMBER(20,8) NOT NULL, sell_price NUMBER(20,8) NOT NULL, pnl_pct NUMBER(10,4) NOT NULL, elapsed_h NUMBER(8,2) NOT NULL, reason VARCHAR2(30) NOT NULL, created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL ) """ ddl_trades_idx = """ CREATE INDEX idx_bt_trades_hash ON backtest_trades (params_hash) """ with _conn() as conn: for ddl in (ddl_ohlcv, ddl_daily, ddl_trades): try: conn.cursor().execute(ddl) except oracledb.DatabaseError as e: if e.args[0].code != 955: # ORA-00955: 이미 존재 raise try: conn.cursor().execute(ddl_trades_idx) except oracledb.DatabaseError as e: if e.args[0].code not in (955, 1408): # 이미 존재 raise def _is_fresh(ticker: str, interval_cd: str) -> bool: """DB에 해당 ticker+interval 데이터가 3개월치 충분히 있는지 확인. 한 번 수집된 데이터는 API 재호출 없이 DB에서만 사용한다. """ start = datetime.now() - timedelta(days=30 * MONTHS) sql = """ SELECT COUNT(*) FROM backtest_ohlcv WHERE ticker = :t AND interval_cd = :i AND ts >= :s """ with _conn() as conn: cur = conn.cursor() cur.execute(sql, {"t": ticker, "i": interval_cd, "s": start}) row = cur.fetchone() return bool(row and row[0] >= MIN_CANDLES) def _is_daily_fresh(ticker: str) -> bool: """일봉 캐시 확인 (데이터 충분히 있으면 API 재호출 안 함).""" sql = "SELECT COUNT(*) FROM backtest_daily WHERE ticker = :t" with _conn() as conn: cur = conn.cursor() cur.execute(sql, {"t": ticker}) row = cur.fetchone() return bool(row and row[0] >= 30) def load_ohlcv_from_db(ticker: str, interval_cd: str, months: int) -> pd.DataFrame: """DB에서 OHLCV 로드.""" start = datetime.now() - timedelta(days=30 * months) sql = """ SELECT ts, open_p, high_p, low_p, close_p, volume_p FROM backtest_ohlcv WHERE ticker = :t AND interval_cd = :i AND ts >= :s ORDER BY ts """ with _conn() as conn: cur = conn.cursor() cur.execute(sql, {"t": ticker, "i": interval_cd, "s": start}) rows = cur.fetchall() if not rows: return pd.DataFrame() idx = pd.DatetimeIndex([r[0] for r in rows]) df = pd.DataFrame( [(float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5])) for r in rows], columns=["open", "high", "low", "close", "volume"], index=idx, ) df.index.name = None return df def load_daily_from_db(ticker: str) -> pd.DataFrame: """DB에서 일봉 로드.""" sql = """ SELECT ts, open_p, high_p, low_p, close_p, volume_p FROM backtest_daily WHERE ticker = :t ORDER BY ts """ with _conn() as conn: cur = conn.cursor() cur.execute(sql, {"t": ticker}) rows = cur.fetchall() if not rows: return pd.DataFrame() idx = pd.DatetimeIndex([r[0] for r in rows]) df = pd.DataFrame( [(float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5])) for r in rows], columns=["open", "high", "low", "close", "volume"], index=idx, ) df.index.name = None return df def save_ohlcv_to_db(ticker: str, interval_cd: str, df: pd.DataFrame) -> None: """OHLCV DataFrame을 DB에 저장 (중복 키 무시 — 기존 데이터 유지).""" rows = [ (ticker, interval_cd, ts.to_pydatetime(), float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"]), float(row["volume"])) for ts, row in df.iterrows() ] sql_ins = """ INSERT INTO backtest_ohlcv (ticker, interval_cd, ts, open_p, high_p, low_p, close_p, volume_p) VALUES (:1, :2, :3, :4, :5, :6, :7, :8) """ with _conn() as conn: conn.cursor().executemany(sql_ins, rows, batcherrors=True) def save_daily_to_db(ticker: str, df: pd.DataFrame) -> None: """일봉 DataFrame을 DB에 저장 (중복 키 무시).""" rows = [ (ticker, ts.to_pydatetime().date(), float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"]), float(row["volume"])) for ts, row in df.iterrows() ] sql_ins = """ INSERT INTO backtest_daily (ticker, ts, open_p, high_p, low_p, close_p, volume_p) VALUES (:1, :2, :3, :4, :5, :6, :7) """ with _conn() as conn: conn.cursor().executemany(sql_ins, rows, batcherrors=True) def fetch_all_data(tickers: list[str], interval: str) -> dict: """모든 종목의 OHLCV + daily 데이터를 수집. DB 캐시(24h 이내)가 있으면 DB에서 로드, 없으면 API → DB 저장. Returns: {ticker: {"ohlcv": DataFrame, "daily": DataFrame}} """ cfg = INTERVAL_CONFIG[interval] max_pages = cfg["max_pages"] ensure_tables() result = {} for i, ticker in enumerate(tickers, 1): # ── OHLCV ────────────────────────────────────────────────────────── if _is_fresh(ticker, interval): ohlcv = load_ohlcv_from_db(ticker, interval, MONTHS) src_ohlcv = "DB캐시" else: ohlcv = fetch_ohlcv(ticker, MONTHS, interval, max_pages) if not ohlcv.empty: save_ohlcv_to_db(ticker, interval, ohlcv) src_ohlcv = "API→DB저장" time.sleep(0.3) # ── Daily ─────────────────────────────────────────────────────────── if _is_daily_fresh(ticker): daily = load_daily_from_db(ticker) src_daily = "DB캐시" else: daily = fetch_daily(ticker) if not daily.empty: save_daily_to_db(ticker, daily) src_daily = "API→DB저장" time.sleep(0.1) if ohlcv.empty or daily.empty: print(f" [{i}/{len(tickers)}] {ticker} → 데이터 없음, 스킵") continue print(f" [{i}/{len(tickers)}] {ticker} " f"(OHLCV:{src_ohlcv} | Daily:{src_daily} | {len(ohlcv)}봉)") result[ticker] = {"ohlcv": ohlcv, "daily": daily} return result # ── 시뮬레이션 결과 DB 캐시 ────────────────────────────────────────────────── def _params_hash(params: dict) -> str: """파라미터 딕셔너리 → SHA256 hex (32자).""" s = json.dumps(params, sort_keys=True, default=str) return hashlib.sha256(s.encode()).hexdigest()[:32] def _trades_cached(params_hash: str) -> bool: sql = "SELECT COUNT(*) FROM backtest_trades WHERE params_hash = :h" with _conn() as conn: cur = conn.cursor() cur.execute(sql, {"h": params_hash}) return bool(cur.fetchone()[0] > 0) def save_trades_to_db(trades: list[dict], params: dict) -> None: """시뮬레이션 결과 bulk insert.""" if not trades: return ph = _params_hash(params) sql = """ INSERT INTO backtest_trades (params_hash, ticker, interval_cd, stop_loss, trend_min, trend_max, volume_mult, entry_ts, exit_ts, buy_price, sell_price, pnl_pct, elapsed_h, reason) VALUES (:ph, :ticker, :icd, :sl, :tmin, :tmax, :vmult, :entry, :exit, :bp, :sp, :pnl, :el, :reason) """ rows = [ { "ph": ph, "ticker": t["ticker"], "icd": params["interval_cd"], "sl": params["stop_loss"], "tmin": params["trend_min_gain"], "tmax": params["trend_max_gain"], "vmult": params["volume_mult"], "entry": t["entry"], "exit": t["exit"], "bp": t["buy_price"], "sp": t["sell_price"], "pnl": t["pnl_pct"], "el": t["elapsed_h"], "reason": t["reason"], } for t in trades ] with _conn() as conn: conn.cursor().executemany(sql, rows) print(f" → DB 저장: {len(rows)}건 (hash={ph[:8]}...)") def load_trades_from_db(params: dict) -> Optional[list[dict]]: """캐시된 결과 반환. 없으면 None.""" ph = _params_hash(params) sql = """ SELECT ticker, entry_ts, exit_ts, buy_price, sell_price, pnl_pct, elapsed_h, reason FROM backtest_trades WHERE params_hash = :h ORDER BY entry_ts """ with _conn() as conn: cur = conn.cursor() cur.execute(sql, {"h": ph}) rows = cur.fetchall() if not rows: return None return [ { "ticker": r[0], "entry": r[1], "exit": r[2], "buy_price": float(r[3]), "sell_price": float(r[4]), "pnl_pct": float(r[5]), "elapsed_h": float(r[6]), "reason": r[7], } for r in rows ] def build_daily_features(daily: pd.DataFrame) -> pd.DataFrame: """MA20, 평균 거래량 계산 (look-ahead 방지: shift(1) 사용).""" d = daily.copy() d["ma20"] = d["close"].rolling(MA_PERIOD).mean().shift(1) d["vol_avg"] = d["volume"].rolling(MA_PERIOD).mean().shift(1) d["prev_vol"] = d["volume"].shift(1) d.index = pd.Index([dt.date() for dt in d.index]) return d def fetch_btc_data(interval: str) -> dict: """BTC OHLCV + 일봉 데이터 로드 (DB 캐시 우선). Returns: {"ohlcv": DataFrame, "daily": DataFrame} """ ticker = "KRW-BTC" cfg = INTERVAL_CONFIG[interval] ensure_tables() if _is_fresh(ticker, interval): ohlcv = load_ohlcv_from_db(ticker, interval, MONTHS) print(f" BTC OHLCV: DB캐시 ({len(ohlcv)}봉)") else: ohlcv = fetch_ohlcv(ticker, MONTHS, interval, cfg["max_pages"]) if not ohlcv.empty: save_ohlcv_to_db(ticker, interval, ohlcv) print(f" BTC OHLCV: API→DB저장 ({len(ohlcv)}봉)") time.sleep(0.3) if _is_daily_fresh(ticker): daily = load_daily_from_db(ticker) print(f" BTC Daily: DB캐시 ({len(daily)}일)") else: daily = fetch_daily(ticker) if not daily.empty: save_daily_to_db(ticker, daily) print(f" BTC Daily: API→DB저장 ({len(daily)}일)") return {"ohlcv": ohlcv, "daily": daily} def build_btc_context(btc_data: dict, lookback_candles: int = 8) -> dict: """BTC 필터용 시계열 인덱스 준비. Args: lookback_candles: 단기 하락 판단 기준봉 수 (15분봉 기준 8봉 = 2시간) Returns: { "close": Series (timestamp → BTC 종가), "2h_change": Series (timestamp → 2h 전 대비 변화율), "daily_ma20": dict (date → BTC 일봉 MA20), } """ ohlcv = btc_data["ohlcv"] daily = btc_data["daily"].copy() btc_close = ohlcv["close"] btc_2h_change = btc_close.pct_change(periods=lookback_candles) daily["ma20"] = daily["close"].rolling(20).mean().shift(1) daily.index = pd.Index([dt.date() for dt in daily.index]) btc_daily_ma20 = daily["ma20"].to_dict() return { "close": btc_close, "2h_change": btc_2h_change, "daily_ma20": btc_daily_ma20, } # ── 2. 시뮬레이션 ───────────────────────────────────────────────────────────── def simulate( ticker: str, hourly: pd.DataFrame, daily_feat: pd.DataFrame, stop_loss: float, trend_min_gain: float, trend_max_gain: float = 999.0, # 추세 상한선 (이 % 초과 급등 시 진입 차단) reentry_above_sell: bool = False, btc_ctx: Optional[dict] = None, btc_drop_threshold: float = -0.015, btc_use_ma_filter: bool = False, hard_stop: Optional[float] = None, # 매수가 기준 하드 스탑 (None=트레일링과 동일) volume_mult: float = VOLUME_MULT, # 거래대금 급증 기준 배수 wf_min_wr: float = 0.0, # walk-forward: 직전 N건 승률 임계값 (0=비활성) wf_window: int = 5, # walk-forward: 승률 계산 윈도우 크기 time_stop_h: float = TIME_STOP_H, # 타임스탑 기준 시간 (0=비활성) time_stop_gain: float = TIME_STOP_GAIN, # 타임스탑 최소 수익률 partial_tp_pct: float = 0.0, # 부분 익절 트리거 수익률 (0=비활성) partial_tp_ratio: float = 0.5, # 부분 익절 비율 (기본 50%) ) -> list[dict]: """단일 종목 전략 시뮬레이션. stop_loss : 트레일링 스탑 (최고가 대비 하락률) hard_stop : 하드 스탑 (매수가 대비 하락률). None이면 stop_loss 값 사용. volume_mult : 진입 조건 — 전일 거래량 > N일 평균 × volume_mult wf_min_wr : walk-forward 필터 — 직전 wf_window건 승률이 이 값 미만이면 진입 차단 윈도우가 채워지기 전(워밍업)에는 필터 미적용 time_stop_h : 타임스탑 기준 시간 (0 이하 = 비활성) partial_tp_pct : 이 수익률 도달 시 partial_tp_ratio 만큼 익절 (0=비활성) partial_tp_ratio: 부분 익절 비율 (0~1). 나머지는 트레일링 스탑으로 유지 """ _hard_stop = hard_stop if hard_stop is not None else stop_loss trades: list[dict] = [] pos: Optional[dict] = None last_sell_price: Optional[float] = None wf_history: list[bool] = [] # True=수익, False=손실 (직전 wf_window건) closes = hourly["close"].values times = hourly.index.to_pydatetime() for i in range(1, len(hourly)): current = closes[i] t = times[i] date = t.date() # ── 포지션 관리 (매도 우선) ────────────────────────────────────────── if pos is not None: if current > pos["peak"]: pos["peak"] = current peak = pos["peak"] bp = pos["buy_price"] drop_pk = (peak - current) / peak drop_buy = (bp - current) / bp pnl = (current - bp) / bp elapsed = (t - pos["entry"]).total_seconds() / 3600 # ── 부분 익절 트리거 ────────────────────────────────────────────── if partial_tp_pct > 0 and not pos.get("partial_done") and pnl >= partial_tp_pct: pos["partial_done"] = True pos["partial_price"] = current pos["partial_pnl"] = ( current * (1 - FEE) - bp * (1 + FEE) ) / (bp * (1 + FEE)) * 100 reason = None if drop_pk >= stop_loss: reason = "trailing_stop" elif _hard_stop < 999 and drop_buy >= _hard_stop: reason = "hard_stop" elif time_stop_h > 0 and elapsed >= time_stop_h and pnl < time_stop_gain: reason = "time_stop" if reason: # 나머지 포지션 청산 pnl rem_pnl = ( current * (1 - FEE) - bp * (1 + FEE) ) / (bp * (1 + FEE)) * 100 # 부분 익절이 실행됐으면 가중 평균 pnl 계산 if partial_tp_pct > 0 and pos.get("partial_done"): net_pnl = ( partial_tp_ratio * pos["partial_pnl"] + (1 - partial_tp_ratio) * rem_pnl ) else: net_pnl = rem_pnl trades.append({ "ticker": ticker, "entry": pos["entry"], "exit": t, "buy_price": round(bp, 4), "sell_price": round(current, 4), "pnl_pct": round(net_pnl, 3), "elapsed_h": round(elapsed, 1), "reason": reason, }) wf_history.append(net_pnl > 0) # walk-forward 이력 업데이트 last_sell_price = current pos = None continue # ── 매수 신호 ──────────────────────────────────────────────────────── if pos is None: if reentry_above_sell and last_sell_price is not None: if current < last_sell_price: continue prev_close = closes[i - 1] if prev_close <= 0: continue gain_pct = (current - prev_close) / prev_close if gain_pct < trend_min_gain: continue if gain_pct > trend_max_gain: continue # ── Walk-forward 승률 필터 ──────────────────────────────────────── # 워밍업(window 미달) 동안은 필터 미적용, 이후 직전 N건 승률 체크 if wf_min_wr > 0 and len(wf_history) >= wf_window: recent_wr = sum(wf_history[-wf_window:]) / wf_window if recent_wr < wf_min_wr: continue if date not in daily_feat.index: continue dr = daily_feat.loc[date] if pd.isna(dr["ma20"]) or pd.isna(dr["vol_avg"]) or dr["vol_avg"] <= 0: continue if not (current > dr["ma20"] and dr["prev_vol"] > dr["vol_avg"] * volume_mult): continue # ── BTC 전체 흐름 필터 ──────────────────────────────────────────── if btc_ctx is not None: # 1) BTC 2시간 단기 하락 필터 btc_2h_chg = btc_ctx["2h_change"].asof(t) if not pd.isna(btc_2h_chg) and btc_2h_chg < btc_drop_threshold: continue # 2) BTC 일봉 MA20 필터 if btc_use_ma_filter: btc_ma20 = btc_ctx["daily_ma20"].get(date) btc_price = btc_ctx["close"].asof(t) if btc_ma20 and not pd.isna(btc_ma20) and btc_price < btc_ma20: continue bp = current * (1 + FEE) pos = { "buy_price": bp, "peak": current, "entry": t, "partial_done": False, "partial_price": None, "partial_pnl": None, } # 기간 종료 시 미청산 포지션 강제 종료 if pos is not None: current = closes[-1] elapsed = (times[-1] - pos["entry"]).total_seconds() / 3600 bp = pos["buy_price"] rem_pnl = ( current * (1 - FEE) - bp * (1 + FEE) ) / (bp * (1 + FEE)) * 100 if partial_tp_pct > 0 and pos.get("partial_done"): net_pnl = ( partial_tp_ratio * pos["partial_pnl"] + (1 - partial_tp_ratio) * rem_pnl ) else: net_pnl = rem_pnl trades.append({ "ticker": ticker, "entry": pos["entry"], "exit": times[-1], "buy_price": round(bp, 4), "sell_price": round(current, 4), "pnl_pct": round(net_pnl, 3), "elapsed_h": round(elapsed, 1), "reason": "open_at_end", }) return trades # ── 3. 결과 리포트 ──────────────────────────────────────────────────────────── def report(all_trades: list[dict], label: str = "") -> None: if not all_trades: print(f"\n{label} — 거래 없음") return df = pd.DataFrame(all_trades) n = len(df) wins = int((df["pnl_pct"] > 0).sum()) losses = n - wins wr = wins / n * 100 avg_pnl = df["pnl_pct"].mean() avg_win = df[df["pnl_pct"] > 0]["pnl_pct"].mean() if wins > 0 else 0.0 avg_loss = df[df["pnl_pct"] < 0]["pnl_pct"].mean() if losses > 0 else 0.0 rr = abs(avg_win / avg_loss) if avg_loss != 0 else float("inf") capital = float(PER_POSITION) peak_cap = capital mdd = 0.0 for pnl in df["pnl_pct"]: capital *= 1 + pnl / 100 if capital > peak_cap: peak_cap = capital dd = (peak_cap - capital) / peak_cap if dd > mdd: mdd = dd total_return = (capital - PER_POSITION) / PER_POSITION * 100 if n > 0: span_days = (df["exit"].max() - df["entry"].min()).total_seconds() / 86400 trades_per_month = n / (span_days / 30) if span_days > 0 else 0 else: trades_per_month = 0 bar = "=" * 60 print(f"\n{bar}") print(f" {label} | {MONTHS}개월 | 상위 {TOP_N}종목") print(bar) print(f" 총 거래수 : {n}건 ({trades_per_month:.1f}건/월)") print(f" 승률 : {wr:.1f}% ({wins}승 {losses}패)") print(f" 평균 수익률 : {avg_pnl:+.2f}%") print(f" 평균 수익 (win) : {avg_win:+.2f}%") print(f" 평균 손실 (loss) : {avg_loss:+.2f}%") print(f" 손익비 : {rr:.2f} (>1 이면 유리)") print(f" 누적 수익률 : {total_return:+.1f}% " f"({PER_POSITION:,}원 → {capital:,.0f}원)") print(f" 최대 낙폭 (MDD) : -{mdd * 100:.1f}%") print(bar) print("\n [청산 사유별]") for reason, grp in df.groupby("reason"): w = int((grp["pnl_pct"] > 0).sum()) print(f" {reason:15s}: {len(grp):3d}건 | " f"승률 {w/len(grp)*100:.0f}% | 평균 {grp['pnl_pct'].mean():+.2f}%") print("\n [종목별]") for ticker, grp in df.groupby("ticker"): w = int((grp["pnl_pct"] > 0).sum()) print(f" {ticker:12s}: {len(grp):3d}건 | " f"승률 {w/len(grp)*100:.0f}% | 평균 {grp['pnl_pct'].mean():+.2f}%") print(f"\n [최근 거래 20건]") print(f" {'종목':<12} {'진입':<18} {'청산':<18} " f"{'수익률':>7} {'보유':>6} {'사유'}") print(f" {'-'*75}") for _, row in df.tail(20).iterrows(): entry_str = row["entry"].strftime("%m/%d %H:%M") exit_str = row["exit"].strftime("%m/%d %H:%M") sign = "✅" if row["pnl_pct"] > 0 else "❌" print(f" {row['ticker']:<12} {entry_str:<18} {exit_str:<18} " f"{row['pnl_pct']:>+6.2f}% {row['elapsed_h']:>5.1f}h " f"{row['reason']} {sign}") print(f"\n{bar}\n") def compare_report(scenarios: list[tuple], title: str = "시나리오 비교") -> None: """여러 시나리오 핵심 지표 비교. scenarios = [(label, trades), ...]""" bar = "=" * 72 def stats(trades: list[dict]) -> dict: if not trades: return {} df = pd.DataFrame(trades) n = len(df) wins = int((df["pnl_pct"] > 0).sum()) capital = float(PER_POSITION) peak_cap = capital mdd = 0.0 for pnl in df["pnl_pct"]: capital *= 1 + pnl / 100 if capital > peak_cap: peak_cap = capital dd = (peak_cap - capital) / peak_cap if dd > mdd: mdd = dd total_ret = (capital - PER_POSITION) / PER_POSITION * 100 df_w = df[df["pnl_pct"] > 0]["pnl_pct"] df_l = df[df["pnl_pct"] < 0]["pnl_pct"] return { "n": n, "wins": wins, "wr": wins / n * 100, "avg": df["pnl_pct"].mean(), "ret": total_ret, "mdd": mdd * 100, "avg_w": df_w.mean() if len(df_w) else 0, "avg_l": df_l.mean() if len(df_l) else 0, } all_stats = [(label, stats(trades)) for label, trades in scenarios] header = f"{'항목':<16}" + "".join(f"{label:>16}" for label, _ in all_stats) print(f"\n{bar}") print(f" {title} | {MONTHS}개월 | 상위 {TOP_N}종목") print(f" {header}") print(bar) for key, label_k in [("n","총 거래수"), ("wr","승률(%)"), ("avg","평균 손익(%)"), ("ret","누적 수익(%)"), ("mdd","MDD(%)"), ("avg_w","평균 수익(win)"), ("avg_l","평균 손실(loss)")]: row = f" {label_k:<16}" for _, s in all_stats: v = s.get(key, 0) row += f"{v:>+15.1f}%" if key != "n" else f"{v:>16.0f} " print(row) print(f"{bar}\n") # ── 4. 시나리오 실행 ────────────────────────────────────────────────────────── def run_scenario( data: dict, stop_loss: float, trend_min_gain: float, trend_max_gain: float = 999.0, reentry_above_sell: bool = False, btc_ctx: Optional[dict] = None, btc_drop_threshold: float = -0.015, btc_use_ma_filter: bool = False, hard_stop: Optional[float] = None, volume_mult: float = VOLUME_MULT, interval_cd: str = DEFAULT_INTERVAL, use_db_cache: bool = False, daily_features: Optional[dict] = None, # 미리 계산된 daily_feat {ticker: df} wf_min_wr: float = 0.0, # walk-forward 승률 임계값 wf_window: int = 5, # walk-forward 윈도우 time_stop_h: float = TIME_STOP_H, # 타임스탑 기준 시간 (0=비활성) time_stop_gain: float = TIME_STOP_GAIN, # 타임스탑 최소 수익률 partial_tp_pct: float = 0.0, # 부분 익절 트리거 수익률 (0=비활성) partial_tp_ratio: float = 0.5, # 부분 익절 비율 ) -> list[dict]: """주어진 파라미터로 전체 종목 시뮬레이션 (데이터는 외부에서 주입). use_db_cache=True 이면: - 동일 파라미터 결과가 DB에 있으면 즉시 반환 (재시뮬레이션 없음) - 없으면 시뮬레이션 후 DB에 저장 daily_features 를 주입하면 build_daily_features() 중복 호출 생략. """ params = { "interval_cd": interval_cd, "stop_loss": stop_loss, "trend_min_gain": trend_min_gain, "trend_max_gain": round(trend_max_gain, 4), "volume_mult": volume_mult, "reentry": reentry_above_sell, "wf_min_wr": round(wf_min_wr, 3), "wf_window": wf_window, "time_stop_h": time_stop_h, "time_stop_gain": round(time_stop_gain, 4), } if use_db_cache: cached = load_trades_from_db(params) if cached is not None: print(f" → DB 캐시 히트 ({len(cached)}건, hash={_params_hash(params)[:8]}...)") return cached all_trades: list[dict] = [] for ticker, frames in data.items(): df = (daily_features or {}).get(ticker) if df is None: df = build_daily_features(frames["daily"]) trades = simulate( ticker, frames["ohlcv"], df, stop_loss=stop_loss, trend_min_gain=trend_min_gain, trend_max_gain=trend_max_gain, reentry_above_sell=reentry_above_sell, btc_ctx=btc_ctx, btc_drop_threshold=btc_drop_threshold, btc_use_ma_filter=btc_use_ma_filter, hard_stop=hard_stop, volume_mult=volume_mult, wf_min_wr=wf_min_wr, wf_window=wf_window, time_stop_h=time_stop_h, time_stop_gain=time_stop_gain, partial_tp_pct=partial_tp_pct, partial_tp_ratio=partial_tp_ratio, ) all_trades.extend(trades) if use_db_cache: save_trades_to_db(all_trades, params) return all_trades # ── 5. 메인 ────────────────────────────────────────────────────────────────── def main_stop_loss_cmp(interval: str = DEFAULT_INTERVAL) -> None: """손절선 비교 모드: 1% vs 1.5% vs 2% (봉주기 고정).""" cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] print(f"\n{'='*60}") print(f" 손절선 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" (재진입: 직전 매도가 이상만 허용 | 모멘텀 기준: {trend*100:.1f}%)") print(f"{'='*60}") print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") data = fetch_all_data(tickers, interval) print(f" 사용 종목: {list(data.keys())}") daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()} common = dict(reentry_above_sell=True, interval_cd=interval, use_db_cache=True, daily_features=daily_features) print("\n▶ A: 손절 1% ...") trades_a = run_scenario(data, stop_loss=0.01, trend_min_gain=trend, **common) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: 손절 1.5% ...") trades_b = run_scenario(data, stop_loss=0.015, trend_min_gain=trend, **common) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: 손절 2% ...") trades_c = run_scenario(data, stop_loss=0.02, trend_min_gain=trend, **common) print(f" 완료 ({len(trades_c)}건)") compare_report([ ("손절1%", trades_a), ("손절1.5%", trades_b), ("손절2%", trades_c), ], title=f"손절선 비교 ({label}) — 1% vs 1.5% vs 2%") report(trades_a, f"손절 1% | {label}") report(trades_b, f"손절 1.5% | {label}") report(trades_c, f"손절 2% | {label}") def main_interval_cmp() -> None: """봉주기 비교 모드: 10분 vs 15분 vs 20분 (손절 1.5% 고정).""" intervals = ["minute10", "minute15", "minute20"] stop_loss = 0.015 print(f"\n{'='*60}") print(f" 봉주기 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목") print(f" 손절 {stop_loss*100:.1f}% 고정 | (10분봉 vs 15분봉 vs 20분봉)") print(f"{'='*60}") print("\n▶ 종목 목록 조회 중...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") all_scenario_trades = [] all_scenario_data = {} for interval in intervals: cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] print(f"\n▶ {label} 데이터 로드 중 (DB 캐시 24h 유효)...") data = fetch_all_data(tickers, interval) all_scenario_data[interval] = data print(f" 사용 종목: {list(data.keys())} ({len(data)}개)") print(f" 시뮬레이션 중 (손절={stop_loss*100:.1f}%, 모멘텀={trend*100:.1f}%)...") trades = run_scenario(data, stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True) all_scenario_trades.append((label, trades)) print(f" 완료 ({len(trades)}건)") compare_report( all_scenario_trades, title=f"봉주기 비교 (손절 {stop_loss*100:.1f}% 고정) — 10분 vs 15분 vs 20분" ) for (label, trades), interval in zip(all_scenario_trades, intervals): cfg = INTERVAL_CONFIG[interval] report(trades, f"{label} | 손절 {stop_loss*100:.1f}%") # ── 변동성 돌파 전략 ────────────────────────────────────────────────────────── def simulate_vol( ticker: str, daily: pd.DataFrame, K: float = 0.5, ma_period: int = 5, use_ma_filter: bool = False, ) -> list[dict]: """변동성 돌파 전략 시뮬레이션 (일봉 기반). 진입: 당일 고가 ≥ 전일종가 + K × 전일변동폭 → 목표가에 매수 청산: 익일 시가에 전량 매도 """ if len(daily) < ma_period + 2: return [] closes = daily["close"].values highs = daily["high"].values lows = daily["low"].values opens = daily["open"].values times = daily.index.to_pydatetime() ma = pd.Series(closes).rolling(ma_period).mean().values trades: list[dict] = [] for i in range(1, len(daily) - 1): # i+1 필요 (익일 시가 매도) prev_range = highs[i - 1] - lows[i - 1] if prev_range <= 0: continue target = closes[i - 1] + K * prev_range # MA 필터: 전일 종가 > MA(5) 인 상승장에서만 진입 if use_ma_filter: if np.isnan(ma[i - 1]) or closes[i - 1] < ma[i - 1]: continue # 당일 목표가 돌파 여부 if highs[i] < target: continue buy_price = target * (1 + FEE) sell_price = opens[i + 1] * (1 - FEE) pnl = (sell_price - buy_price) / buy_price * 100 trades.append({ "ticker": ticker, "entry": times[i], "exit": times[i + 1], "buy_price": round(buy_price, 4), "sell_price": round(sell_price, 4), "pnl_pct": round(pnl, 3), "elapsed_h": 24.0, "reason": "vol_breakout", }) return trades def run_vol_scenario( data: dict, K: float, use_ma_filter: bool = False, ) -> list[dict]: """변동성 돌파 전체 종목 시뮬레이션.""" all_trades: list[dict] = [] for ticker, frames in data.items(): trades = simulate_vol(ticker, frames["daily"], K=K, use_ma_filter=use_ma_filter) all_trades.extend(trades) return all_trades def main_vol_cmp() -> None: """변동성 돌파 전략 K값 비교.""" print(f"\n{'='*60}") print(f" 변동성 돌파 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | 일봉") print(f" 진입: 목표가 돌파 시 매수 | 청산: 익일 시가") print(f"{'='*60}") # 일봉만 필요하므로 daily 캐시 사용 print("\n▶ 종목 목록 조회 중...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") ensure_tables() data: dict = {} for i, ticker in enumerate(tickers, 1): if _is_daily_fresh(ticker): daily = load_daily_from_db(ticker) src = "DB캐시" else: daily = fetch_daily(ticker) if not daily.empty: save_daily_to_db(ticker, daily) src = "API→DB저장" time.sleep(0.1) if daily.empty: continue start = datetime.now() - timedelta(days=30 * MONTHS) daily = daily[daily.index >= start] print(f" [{i}/{len(tickers)}] {ticker} ({src} | {len(daily)}일)") data[ticker] = {"daily": daily} print(f"\n 사용 종목: {len(data)}개") print("\n▶ A: K=0.3 ...") trades_a = run_vol_scenario(data, K=0.3) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: K=0.5 ...") trades_b = run_vol_scenario(data, K=0.5) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: K=0.7 ...") trades_c = run_vol_scenario(data, K=0.7) print(f" 완료 ({len(trades_c)}건)") print("\n▶ D: K=0.5 + MA5 필터 ...") trades_d = run_vol_scenario(data, K=0.5, use_ma_filter=True) print(f" 완료 ({len(trades_d)}건)") compare_report([ ("K=0.3", trades_a), ("K=0.5", trades_b), ("K=0.7", trades_c), ("K=0.5+MA5", trades_d), ], title="변동성 돌파 비교 (K=0.3/0.5/0.7, 일봉)") for lbl, trades in [ ("K=0.3", trades_a), ("K=0.5", trades_b), ("K=0.7", trades_c), ("K=0.5+MA5", trades_d), ]: report(trades, lbl) def main_top50_cmp(interval: str = DEFAULT_INTERVAL) -> None: """종목수(Top20 vs Top50) + 거래대금 급증 배수 비교.""" cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] stop_loss = 0.015 print(f"\n{'='*60}") print(f" 종목수/거래대금 비교 백테스트 | {MONTHS}개월 | {label}") print(f" 손절 {stop_loss*100:.1f}% 고정 | Top20 vs Top50, 거래대금 2x/3x/4x") print(f"{'='*60}") common = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True) # ── Top 20 데이터 (DB 캐시 대부분 존재) ────────────────────────────────── print("\n▶ Top 20 종목 데이터 로드 중...") tickers_20 = get_top_tickers(20) print(f" → {tickers_20}") data_20 = fetch_all_data(tickers_20, interval) print(f" 사용 종목: {len(data_20)}개") # ── Top 50 데이터 (추가 30개는 API 수집 필요할 수 있음) ────────────────── print("\n▶ Top 50 종목 데이터 로드 중...") tickers_50 = get_top_tickers(50) print(f" → {tickers_50}") data_50 = fetch_all_data(tickers_50, interval) print(f" 사용 종목: {len(data_50)}개") # ── 시나리오 실행 ───────────────────────────────────────────────────────── print("\n▶ A: Top20 / 거래대금 2배 (현재 설정) ...") trades_a = run_scenario(data_20, **common, volume_mult=2.0) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: Top50 / 거래대금 2배 ...") trades_b = run_scenario(data_50, **common, volume_mult=2.0) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: Top50 / 거래대금 3배 ...") trades_c = run_scenario(data_50, **common, volume_mult=3.0) print(f" 완료 ({len(trades_c)}건)") print("\n▶ D: Top50 / 거래대금 4배 ...") trades_d = run_scenario(data_50, **common, volume_mult=4.0) print(f" 완료 ({len(trades_d)}건)") compare_report([ ("Top20/Vol×2", trades_a), ("Top50/Vol×2", trades_b), ("Top50/Vol×3", trades_c), ("Top50/Vol×4", trades_d), ], title=f"종목수/거래대금 비교 ({label}, 손절 {stop_loss*100:.1f}%)") for lbl, trades in [ ("Top20 / 거래대금 2배 (현재)", trades_a), ("Top50 / 거래대금 2배", trades_b), ("Top50 / 거래대금 3배", trades_c), ("Top50 / 거래대금 4배", trades_d), ]: report(trades, lbl) def main_hard_stop_cmp(interval: str = DEFAULT_INTERVAL) -> None: """하드 스탑 비교 모드 (트레일링 1.5% 고정, 하드 스탑 가변).""" cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] trailing = 0.015 print(f"\n{'='*60}") print(f" 하드스탑 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" 트레일링 {trailing*100:.1f}% 고정 | 하드스탑 가변") print(f"{'='*60}") print("\n▶ 종목 데이터 로드 중...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") data = fetch_all_data(tickers, interval) print(f" 사용 종목: {len(data)}개") common = dict(stop_loss=trailing, trend_min_gain=trend, reentry_above_sell=True) print("\n▶ A: 하드스탑 1.5% (트레일링과 동일, 현재 설정) ...") trades_a = run_scenario(data, **common, hard_stop=0.015) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: 하드스탑 3% ...") trades_b = run_scenario(data, **common, hard_stop=0.03) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: 하드스탑 5% ...") trades_c = run_scenario(data, **common, hard_stop=0.05) print(f" 완료 ({len(trades_c)}건)") print("\n▶ D: 하드스탑 없음 (트레일링만) ...") trades_d = run_scenario(data, **common, hard_stop=999) print(f" 완료 ({len(trades_d)}건)") compare_report([ ("하드1.5%(현재)", trades_a), ("하드3%", trades_b), ("하드5%", trades_c), ("하드없음", trades_d), ], title=f"하드스탑 비교 ({label}, 트레일링 {trailing*100:.1f}% 고정)") for lbl, trades in [ ("하드스탑 1.5% (현재)", trades_a), ("하드스탑 3%", trades_b), ("하드스탑 5%", trades_c), ("하드스탑 없음", trades_d), ]: report(trades, lbl) def main_btc_cmp(interval: str = DEFAULT_INTERVAL) -> None: """BTC 전체 흐름 필터 비교 모드.""" cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] stop_loss = 0.015 print(f"\n{'='*60}") print(f" BTC 필터 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" 손절 {stop_loss*100:.1f}% 고정") print(f"{'='*60}") print("\n▶ 종목 데이터 로드 중...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") data = fetch_all_data(tickers, interval) print(f" 사용 종목: {len(data)}개") print("\n▶ BTC 데이터 로드 중...") btc_raw = fetch_btc_data(interval) btc_ctx = build_btc_context(btc_raw, lookback_candles=8) # 8봉 = 2시간 (15분봉 기준) common = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True) print("\n▶ A: BTC 필터 없음 ...") trades_a = run_scenario(data, **common) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: BTC 2h 하락 -1.5% 차단 ...") trades_b = run_scenario(data, **common, btc_ctx=btc_ctx, btc_drop_threshold=-0.015) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: BTC MA20 아래 차단 ...") trades_c = run_scenario(data, **common, btc_ctx=btc_ctx, btc_drop_threshold=-9999, # 하락 필터 OFF btc_use_ma_filter=True) print(f" 완료 ({len(trades_c)}건)") print("\n▶ D: BTC 2h 하락 + MA20 동시 적용 ...") trades_d = run_scenario(data, **common, btc_ctx=btc_ctx, btc_drop_threshold=-0.015, btc_use_ma_filter=True) print(f" 완료 ({len(trades_d)}건)") compare_report([ ("필터없음", trades_a), ("BTC2h하락", trades_b), ("BTCMA20", trades_c), ("2h+MA20", trades_d), ], title=f"BTC 전체흐름 필터 비교 ({label}, 손절{stop_loss*100:.1f}%)") for label_s, trades in [ ("A: 필터 없음", trades_a), ("B: BTC 2h 하락 차단", trades_b), ("C: BTC MA20 차단", trades_c), ("D: 2h 하락 + MA20", trades_d), ]: report(trades, label_s) def main_trend_cmp(interval: str = DEFAULT_INTERVAL) -> None: """추세 상한선 비교: 상한 없음 / +5% / +7% / +10%.""" cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] stop_loss = 0.015 # 트레일링 스탑 고정 print(f"\n{'='*60}") print(f" 추세 상한선 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" (트레일링={stop_loss*100:.1f}%, 하한={trend*100:.1f}%, 재진입차단O)") print(f"{'='*60}") print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") data = fetch_all_data(tickers, interval) print(f" 사용 종목: {list(data.keys())}") daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()} common = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True, interval_cd=interval, use_db_cache=True, daily_features=daily_features) print("\n▶ A: 상한선 없음 (현행) ...") trades_a = run_scenario(data, **common) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: 상한 +5% ...") trades_b = run_scenario(data, **common, trend_max_gain=0.05) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: 상한 +7% ...") trades_c = run_scenario(data, **common, trend_max_gain=0.07) print(f" 완료 ({len(trades_c)}건)") print("\n▶ D: 상한 +10% ...") trades_d = run_scenario(data, **common, trend_max_gain=0.10) print(f" 완료 ({len(trades_d)}건)") print("\n▶ E: 상한 +15% ...") trades_e = run_scenario(data, **common, trend_max_gain=0.15) print(f" 완료 ({len(trades_e)}건)") compare_report([ ("상한없음(현행)", trades_a), ("상한+5%", trades_b), ("상한+7%", trades_c), ("상한+10%", trades_d), ("상한+15%", trades_e), ], title=f"추세 상한선 비교 ({label}, 손절{stop_loss*100:.1f}%)") for label_s, trades in [ ("A: 상한 없음", trades_a), ("B: 상한 +5%", trades_b), ("C: 상한 +7%", trades_c), ("D: 상한 +10%", trades_d), ("E: 상한 +15%", trades_e), ]: report(trades, label_s) def main_ticker_walkforward_cmp(interval: str = DEFAULT_INTERVAL) -> None: """Walk-forward 종목 필터 비교. 각 거래 시점에서 '직전 N건 승률'만 참조 → look-ahead bias 없음. 워밍업(N건 미달) 기간에는 필터 미적용. 테스트 조합: A: 필터 없음 (baseline) B: 직전 5건 승률 ≥ 30% C: 직전 5건 승률 ≥ 40% D: 직전 10건 승률 ≥ 30% E: 직전 10건 승률 ≥ 40% """ cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] stop_loss = 0.015 print(f"\n{'='*60}") print(f" Walk-Forward 필터 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" (손절={stop_loss*100:.1f}%, 하한={trend*100:.1f}% / look-ahead bias 없음)") print(f"{'='*60}") print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") data = fetch_all_data(tickers, interval) print(f" 사용 종목: {list(data.keys())}") daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()} base = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True, interval_cd=interval, use_db_cache=True, daily_features=daily_features) scenarios = [ ("A: 필터없음", dict(**base)), ("B: W5≥30%", dict(**base, wf_min_wr=0.30, wf_window=5)), ("C: W5≥40%", dict(**base, wf_min_wr=0.40, wf_window=5)), ("D: W10≥30%", dict(**base, wf_min_wr=0.30, wf_window=10)), ("E: W10≥40%", dict(**base, wf_min_wr=0.40, wf_window=10)), ] results = [] for sc_label, sc_params in scenarios: print(f"\n▶ {sc_label} ...") trades = run_scenario(data, **sc_params) print(f" 완료 ({len(trades)}건)") results.append((sc_label, trades)) compare_report(results, title=f"Walk-Forward 필터 비교 ({label})") for sc_label, trades in results: report(trades, sc_label) def main_ticker_filter_cmp(interval: str = DEFAULT_INTERVAL) -> None: """종목 승률 필터 비교 — 기존 DB 캐시 기반, 순수 SQL 분석. 먼저 기본 시나리오(상한없음, 손절1.5%)를 DB에 캐시해두고, 이후 SQL로 win_rate 임계값별 누적 수익을 즉시 계산. ※ 전체 기간 승률로 필터링하므로 look-ahead bias 있음 — 방향성 확인용. """ cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] stop_loss = 0.015 params = { "interval_cd": interval, "stop_loss": stop_loss, "trend_min_gain": trend, "trend_max_gain": round(999.0, 4), "volume_mult": VOLUME_MULT, "reentry": True, } ph = _params_hash(params) print(f"\n{'='*60}") print(f" 종목 승률 필터 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" (손절={stop_loss*100:.1f}%, 하한={trend*100:.1f}% / look-ahead 주의)") print(f"{'='*60}") ensure_tables() # DB에 결과 없으면 시뮬레이션 if not _trades_cached(ph): print("\n▶ DB 캐시 없음 — 시뮬레이션 실행 중...") tickers = get_top_tickers(TOP_N) data = fetch_all_data(tickers, interval) daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()} run_scenario(data, stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True, interval_cd=interval, use_db_cache=True, daily_features=daily_features) else: print(f"\n▶ DB 캐시 히트 (hash={ph[:8]}...) — 시뮬레이션 생략") # ── 순수 SQL 분석 ────────────────────────────────────────────────────────── with _conn() as conn: cur = conn.cursor() # 전체 요약 cur.execute(""" SELECT COUNT(*), ROUND(SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1), ROUND(AVG(pnl_pct), 3), ROUND(SUM(pnl_pct), 2) FROM backtest_trades WHERE params_hash = :h """, {"h": ph}) total_n, total_wr, total_avg, total_sum = cur.fetchone() # 티커별 성과 (SQL) cur.execute(""" SELECT ticker, COUNT(*) as trades, ROUND(SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1) as win_rate, ROUND(AVG(pnl_pct), 3) as avg_pnl, ROUND(SUM(pnl_pct), 2) as total_pnl FROM backtest_trades WHERE params_hash = :h GROUP BY ticker ORDER BY avg_pnl DESC """, {"h": ph}) ticker_rows = cur.fetchall() print(f"\n▶ 티커별 성과 (전체 {total_n}건 | 승률 {total_wr}% | 합산 {total_sum:+.1f}%)") print(f" {'종목':<12} {'건수':>4} {'승률':>6} {'평균':>7} {'합산':>8}") print(f" {'-'*45}") for row in ticker_rows: flag = "✅" if float(row[3]) > 0 else "❌" print(f" {row[0]:<12} {row[1]:>4}건 {row[2]:>5.0f}% {row[3]:>+6.2f}% {row[4]:>+7.1f}% {flag}") # 승률 임계값별 비교 (순수 SQL — 즉시 결과) print(f"\n▶ 승률 임계값별 필터 비교 (SQL)") print(f" {'조건':<20} {'거래수':>5} {'승률':>6} {'누적합산':>9}") print(f" {'-'*45}") for min_wr in [0, 20, 25, 30, 35, 40]: cur.execute(""" WITH good AS ( SELECT ticker FROM backtest_trades WHERE params_hash = :h GROUP BY ticker HAVING SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*) >= :wr ) SELECT COUNT(*), ROUND(SUM(CASE WHEN t.pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1), ROUND(SUM(t.pnl_pct), 2) FROM backtest_trades t WHERE t.params_hash = :h AND t.ticker IN (SELECT ticker FROM good) """, {"h": ph, "wr": min_wr}) r = cur.fetchone() n_r, wr_r, sum_r = r[0], r[1], r[2] label_s = "필터 없음(전체)" if min_wr == 0 else f"승률>{min_wr}% 종목만" print(f" {label_s:<20} {n_r:>5}건 {wr_r:>5.1f}% {sum_r:>+8.1f}%") # 어떤 종목이 포함/제외되는지 (30% 기준) cur.execute(""" SELECT ticker, ROUND(SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1) as wr FROM backtest_trades WHERE params_hash = :h GROUP BY ticker ORDER BY wr """, {"h": ph}) print(f"\n [30% 기준 제외 대상]") for row in cur.fetchall(): if float(row[1]) < 30: print(f" ✗ {row[0]:<12} 승률 {row[1]:.0f}%") def main_timestop_cmp(interval: str = DEFAULT_INTERVAL) -> None: """타임스탑 조건 비교: 트레일링 1.5%+8h(현행) vs 10%+8h vs 10%+24h vs 10%+없음.""" cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] print(f"\n{'='*60}") print(f" 타임스탑 조건 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" (A) 트레일링 1.5% + 타임스탑 8h ← 현재 설정") print(f" (B) 트레일링 10% + 타임스탑 8h") print(f" (C) 트레일링 10% + 타임스탑 24h") print(f" (D) 트레일링 10% + 타임스탑 없음") print(f"{'='*60}") print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") data = fetch_all_data(tickers, interval) print(f" 사용 종목: {list(data.keys())}") daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()} common = dict( reentry_above_sell=True, interval_cd=interval, use_db_cache=True, daily_features=daily_features, trend_min_gain=trend, ) print("\n▶ A: 트레일링 1.5% + 타임스탑 8h (현재 설정) ...") trades_a = run_scenario(data, stop_loss=0.015, time_stop_h=8, **common) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: 트레일링 10% + 타임스탑 8h ...") trades_b = run_scenario(data, stop_loss=0.10, time_stop_h=8, **common) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: 트레일링 10% + 타임스탑 24h ...") trades_c = run_scenario(data, stop_loss=0.10, time_stop_h=24, **common) print(f" 완료 ({len(trades_c)}건)") print("\n▶ D: 트레일링 10% + 타임스탑 없음 ...") trades_d = run_scenario(data, stop_loss=0.10, time_stop_h=0, **common) print(f" 완료 ({len(trades_d)}건)") compare_report([ ("1.5%+8h(현행)", trades_a), ("10%+8h", trades_b), ("10%+24h", trades_c), ("10%+타임없음", trades_d), ], title=f"트레일링 + 타임스탑 조합 비교 ({label})") report(trades_a, f"A: 트레일링 1.5% + 타임스탑 8h | {label}") report(trades_b, f"B: 트레일링 10% + 타임스탑 8h | {label}") report(trades_c, f"C: 트레일링 10% + 타임스탑 24h | {label}") report(trades_d, f"D: 트레일링 10% + 타임스탑 없음 | {label}") def main_combo_cmp(interval: str = DEFAULT_INTERVAL) -> None: """추세 임계값 + 거래량 배수 조합 비교. 목적: 거래 빈도를 줄여 수수료 부담을 낮추는 최적 조합 탐색. A (현행): trend=1.0%, vol=2x B: trend=1.5%, vol=2x (추세만 강화) C: trend=1.0%, vol=3x (거래량만 강화) D: trend=1.5%, vol=3x (둘 다 강화) E: trend=2.0%, vol=3x (최강 강화) """ cfg = INTERVAL_CONFIG[interval] label = cfg["label"] base_trend = cfg["trend"] # 15분봉 기준 1.0% combos = [ ("A 현행 1.0%/2x", base_trend, 2.0), ("B 추세1.5%/2x", base_trend * 1.5, 2.0), ("C 추세1.0%/3x", base_trend, 3.0), ("D 추세1.5%/3x", base_trend * 1.5, 3.0), ("E 추세2.0%/3x", base_trend * 2.0, 3.0), ] print(f"\n{'='*64}") print(f" 추세+거래량 조합 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" 목적: 거래 빈도↓ → 수수료 부담↓ → 순수익↑") print(f" FEE = 0.05% × 2 = 0.1%/건 | 현행 ~85건/월 = 월 8.5% 수수료") print(f"{'='*64}") print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...") tickers = get_top_tickers(TOP_N) data = fetch_all_data(tickers, interval) print(f" 사용 종목: {list(data.keys())}") daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()} results = [] for combo_label, trend, vol in combos: print(f"\n▶ {combo_label} (trend={trend*100:.1f}%, vol={vol:.0f}x) ...") trades = run_scenario( data, stop_loss=0.015, trend_min_gain=trend, volume_mult=vol, reentry_above_sell=True, interval_cd=interval, use_db_cache=True, daily_features=daily_features, ) n = len(trades) fee_pct = n * 0.1 # 총 수수료 부담 (%) print(f" 완료 ({n}건 | 예상 수수료 {fee_pct:.1f}%)") results.append((combo_label, trades)) compare_report(results, title=f"추세+거래량 조합 비교 ({label})") # 수수료 분석 추가 출력 print(" [수수료 분석]") print(f" {'조합':<20} {'거래수':>6} {'월평균':>6} {'총수수료':>8} {'수수료 전':>10} {'수수료 후':>10}") print(f" {'-'*64}") for (clabel, trades) in results: if not trades: continue df = pd.DataFrame(trades) n = len(df) span_days = (df["exit"].max() - df["entry"].min()).total_seconds() / 86400 per_month = n / (span_days / 30) if span_days > 0 else 0 fee_total = n * 0.1 net_pnl = df["pnl_pct"].sum() gross_pnl = net_pnl + fee_total # 수수료 전 추정 print(f" {clabel:<20} {n:>6}건 {per_month:>5.0f}/월 {fee_total:>7.1f}% " f"{gross_pnl:>+9.1f}% {net_pnl:>+9.1f}%") print() for clabel, trades in results: report(trades, clabel) def main_tp_cmp(interval: str = DEFAULT_INTERVAL) -> None: """부분 익절 구조 비교: A(없음) vs B(+5% 50% 익절) vs C(+3% 50% 익절). 트레일링 스탑·타임스탑 파라미터는 현재 봇 운영 기준과 동일하게 고정. """ cfg = INTERVAL_CONFIG[interval] label = cfg["label"] trend = cfg["trend"] print(f"\n{'='*60}") print(f" 부분 익절 구조 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}") print(f" (트레일링 1.5% | 타임스탑 8h/3% | 모멘텀 {trend*100:.1f}%)") print(f"{'='*60}") print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...") tickers = get_top_tickers(TOP_N) print(f" → {tickers}") data = fetch_all_data(tickers, interval) print(f" 사용 종목: {list(data.keys())}") daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()} common = dict( stop_loss=0.015, trend_min_gain=trend, interval_cd=interval, daily_features=daily_features, time_stop_h=8.0, time_stop_gain=0.03, ) print("\n▶ A: 부분 익절 없음 (현행) ...") trades_a = run_scenario(data, partial_tp_pct=0.0, **common) print(f" 완료 ({len(trades_a)}건)") print("\n▶ B: +5% 도달 시 50% 익절 ...") trades_b = run_scenario(data, partial_tp_pct=0.05, partial_tp_ratio=0.5, **common) print(f" 완료 ({len(trades_b)}건)") print("\n▶ C: +3% 도달 시 50% 익절 ...") trades_c = run_scenario(data, partial_tp_pct=0.03, partial_tp_ratio=0.5, **common) print(f" 완료 ({len(trades_c)}건)") compare_report([ ("A: 익절없음", trades_a), ("B: +5%→50%익절", trades_b), ("C: +3%→50%익절", trades_c), ], title=f"부분 익절 구조 비교 ({label})") report(trades_a, f"A: 부분 익절 없음 | {label}") report(trades_b, f"B: +5% 50% 익절 | {label}") report(trades_c, f"C: +3% 50% 익절 | {label}") def main() -> None: if COMPARE_TP: main_tp_cmp(DEFAULT_INTERVAL) elif COMPARE_COMBO: main_combo_cmp(DEFAULT_INTERVAL) elif COMPARE_TIMESTOP: main_timestop_cmp(DEFAULT_INTERVAL) elif COMPARE_TOP50: main_top50_cmp(DEFAULT_INTERVAL) elif COMPARE_HARDSTOP: main_hard_stop_cmp(DEFAULT_INTERVAL) elif COMPARE_VOL: main_vol_cmp() elif COMPARE_BTC: main_btc_cmp(DEFAULT_INTERVAL) elif COMPARE_TREND: main_trend_cmp(DEFAULT_INTERVAL) elif COMPARE_WF: main_ticker_walkforward_cmp(DEFAULT_INTERVAL) elif COMPARE_TICKER: main_ticker_filter_cmp(DEFAULT_INTERVAL) elif COMPARE_INTERVALS: main_interval_cmp() else: main_stop_loss_cmp(DEFAULT_INTERVAL) if __name__ == "__main__": main()