"""레짐 기반 1년 시뮬레이션 — BULL 진입 vs Bear차단 vs 필터없음. sim_45m40.py 검증된 코어 로직 기반. 데이터: data/sim1y_cache.pkl (10분봉 1년치) data/fng_1y.json (F&G 1년치) 비교 구성: 1. 필터 없음 — 레짐/F&G 무관 진입 2. BEAR 차단 — 레짐 score < -0.5% 이면 차단 (현재 전략) 3. BULL 진입만 — 레짐 score ≥ 1.5% 일 때만 진입 ← 사용자 제안 4. BULL 진입 + F&G≥41 — BULL 조건에 F&G 필터 추가 """ import os as _os, sys as _sys _sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) import json import pickle from pathlib import Path import pandas as pd from dotenv import load_dotenv load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") # ── 데이터 경로 ─────────────────────────────────────────── CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl" FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json" TOP_N = 20 # ── 전략 파라미터 (sim_45m40.py 동일) ──────────────────── BUDGET = 15_000_000 MIN_BUDGET = BUDGET * 3 // 10 MAX_POS = 3 FEE = 0.0005 TIME_STOP_MIN_PCT = 3.0 ATR_MULT = 1.5 ATR_MIN = 0.010 ATR_MAX = 0.020 VOL_MULT_DEFAULT = 2.0 # 기본 (NEUTRAL / 필터없음) VOL_MULT_BULL = 1.5 # BULL 레짐 완화 QUIET_PCT = 2.0 THRESH = 4.8 # sim_45m40.py 기준값 # 40분봉 봉수 환산 LOCAL_VOL_N = 7 # 5h QUIET_N = 3 # 2h SIGNAL_TO_N = 12 # 8h ATR_N = 7 TS_N = 12 # 8h (타임스탑) REGIME_N = 3 # 2h (레짐 추세) # 레짐 임계 BEAR_THRESHOLD = -0.5 BULL_THRESHOLD = 1.5 # 레짐 계산 가중치 REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30, "KRW-SOL": 0.15, "KRW-XRP": 0.15} # WF 파라미터 WF_WINDOW = 4 WF_MIN_WIN_RATE = 0.01 WF_SHADOW_WINS = 2 # ── 리샘플링 ───────────────────────────────────────────── def resample_40m(df: pd.DataFrame) -> pd.DataFrame: return ( df.resample("40min") .agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) .dropna(subset=["close"]) ) # ── 레짐 시리즈 ────────────────────────────────────────── def build_regime_series(dfs40: dict) -> pd.Series: weighted = None for ticker, w in REGIME_WEIGHTS.items(): if ticker not in dfs40: continue pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100 weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0) return weighted if weighted is not None else pd.Series(dtype=float) # ── ATR ────────────────────────────────────────────────── def calc_atr(df: pd.DataFrame, buy_idx: int) -> float: sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx] if len(sub) < 3: return ATR_MIN try: avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean() return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT))) except Exception: return ATR_MIN # ── 포지션 시뮬 ────────────────────────────────────────── def simulate_pos(df: pd.DataFrame, buy_idx: int, buy_price: float, stop_pct: float): peak = buy_price for i in range(buy_idx + 1, len(df)): row = df.iloc[i] ts = df.index[i] if row["high"] > peak: peak = row["high"] if row["low"] <= peak * (1 - stop_pct): sp = peak * (1 - stop_pct) pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100 return pnl > 0, ts, pnl pnl_now = (row["close"] - buy_price) / buy_price * 100 if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT: pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100 return pnl > 0, ts, pnl last = df.iloc[-1]["close"] pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100 return pnl > 0, df.index[-1], pnl # ── 전략 실행 ───────────────────────────────────────────── def run_strategy(df: pd.DataFrame, ticker: str, regime_series: pd.Series, fng_map: dict, mode: str) -> list: """ mode: 'none' — 레짐/F&G 필터 없음 'bear_off' — BEAR 차단만 (score < BEAR_THRESHOLD 시 스킵) 'bull_only'— BULL 진입만 (score >= BULL_THRESHOLD 일 때만) 'bull_fng' — BULL + F&G≥41 """ trades = [] sig_i = sig_p = None in_pos = False buy_idx = buy_price = stop_pct = None i = max(LOCAL_VOL_N + 2, QUIET_N + 1) while i < len(df): ts = df.index[i] row = df.iloc[i] cur = row["close"] # ── 포지션 보유 중 ──────────────────────────────── if in_pos: is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct) next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df)) trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker)) in_pos = False sig_i = sig_p = None i = next_i continue # ── 레짐 스코어 계산 ───────────────────────────── score = 0.0 if not regime_series.empty and ts in regime_series.index: v = regime_series.loc[ts] score = float(v) if not pd.isna(v) else 0.0 # ── 모드별 진입 필터 ───────────────────────────── if mode == "bear_off": if score < BEAR_THRESHOLD: sig_i = sig_p = None i += 1 continue vol_mult = VOL_MULT_BULL if score >= BULL_THRESHOLD else VOL_MULT_DEFAULT elif mode == "bull_only": if score < BULL_THRESHOLD: sig_i = sig_p = None i += 1 continue vol_mult = VOL_MULT_BULL elif mode == "bull_fng": if score < BULL_THRESHOLD: sig_i = sig_p = None i += 1 continue date_str = ts.strftime("%Y-%m-%d") fv = fng_map.get(date_str, 50) if fng_map else 50 if fv < 41: sig_i = sig_p = None i += 1 continue vol_mult = VOL_MULT_BULL else: # 'none' vol_mult = VOL_MULT_DEFAULT # ── 신호 타임아웃 ───────────────────────────────── if sig_i is not None and (i - sig_i) > SIGNAL_TO_N: sig_i = sig_p = None # ── 신호 있음: 진입 체크 ────────────────────────── if sig_i is not None: move_pct = (cur - sig_p) / sig_p * 100 if cur < sig_p: sig_i = sig_p = None elif move_pct >= THRESH: in_pos = True buy_idx = i buy_price = cur stop_pct = calc_atr(df, i) sig_i = sig_p = None i += 1 continue # ── 신호 없음: 축적 조건 체크 ──────────────────── vol_p = df.iloc[i - 1]["volume"] vol_avg = df.iloc[i - 1 - LOCAL_VOL_N:i - 1]["volume"].mean() vol_r = vol_p / vol_avg if vol_avg > 0 else 0 close_qh = df.iloc[i - QUIET_N]["close"] chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999 quiet = chg_qh < QUIET_PCT spike = vol_r >= vol_mult if quiet and spike: if sig_i is None: sig_i = i sig_p = cur else: if sig_i is not None and cur < sig_p: sig_i = sig_p = None i += 1 return trades # ── WF 필터 ────────────────────────────────────────────── def apply_wf(trades: list) -> tuple: history = [] shadow_streak = 0 blocked = False accepted = [] blocked_cnt = 0 for trade in trades: is_win = int(trade[0]) if not blocked: accepted.append(trade) history.append(is_win) if len(history) >= WF_WINDOW: wr = sum(history[-WF_WINDOW:]) / WF_WINDOW if wr < WF_MIN_WIN_RATE: blocked = True shadow_streak = 0 else: blocked_cnt += 1 if is_win: shadow_streak += 1 if shadow_streak >= WF_SHADOW_WINS: blocked = False history = [] shadow_streak = 0 else: shadow_streak = 0 return accepted, blocked_cnt # ── MAX_POSITIONS ──────────────────────────────────────── def apply_max_positions(all_trades: list) -> tuple: open_exits, accepted, skipped = [], [], [] for trade in all_trades: buy_dt, sell_dt = trade[2], trade[3] open_exits = [s for s in open_exits if s > buy_dt] if len(open_exits) < MAX_POS: open_exits.append(sell_dt) accepted.append(trade) else: skipped.append(trade) return accepted, skipped # ── 복리 시뮬 ──────────────────────────────────────────── def run_compound(accepted: list) -> dict: portfolio = float(BUDGET) total_krw = 0.0 monthly = {} trade_log = [] for is_win, pnl, buy_dt, sell_dt, ticker in accepted: pos_size = max(portfolio, MIN_BUDGET) / MAX_POS krw_profit = pos_size * pnl / 100 portfolio = max(portfolio + krw_profit, MIN_BUDGET) total_krw += krw_profit ym = buy_dt.strftime("%Y-%m") if ym not in monthly: monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0} monthly[ym]["trades"] += 1 monthly[ym]["wins"] += int(is_win) monthly[ym]["pnl_krw"] += krw_profit trade_log.append({"buy_dt": buy_dt, "sell_dt": sell_dt, "ticker": ticker, "is_win": is_win, "pnl_pct": pnl, "portfolio": portfolio}) wins = sum(1 for t in accepted if t[0]) return { "portfolio": portfolio, "total_krw": total_krw, "roi_pct": (portfolio - BUDGET) / BUDGET * 100, "total": len(accepted), "wins": wins, "wr": wins / len(accepted) * 100 if accepted else 0, "monthly": monthly, "trade_log": trade_log, } # ── 결과 출력 ──────────────────────────────────────────── def print_result(label: str, result: dict, skipped: int, wf_blocked: int): r = result peak = BUDGET max_dd = 0.0 for t in r["trade_log"]: peak = max(peak, t["portfolio"]) dd = (peak - t["portfolio"]) / peak * 100 max_dd = max(max_dd, dd) monthly_krw = [m["pnl_krw"] for m in r["monthly"].values()] avg_m = sum(monthly_krw) / len(monthly_krw) if monthly_krw else 0 total = r["total"] wins = r["wins"] print(f"\n{'─'*60}") print(f" [{label}]") print(f" 진입: {total}건 (WF차단: {wf_blocked} / MAX_POS스킵: {skipped})") if total: print(f" 승패: {wins}승 {total-wins}패 (승률 {r['wr']:.1f}%)") print(f" 초기 예산: {BUDGET:>15,}원") print(f" 최종 자산: {r['portfolio']:>15,.0f}원") print(f" 순수익: {r['total_krw']:>+15,.0f}원") print(f" 수익률: {r['roi_pct']:>+14.2f}%") print(f" 최대 낙폭: {-max_dd:>+14.2f}%") print(f" 월평균 수익: {avg_m:>+13,.0f}원") def print_monthly(result: dict): print(f" {'월':^8} │ {'거래':>4} {'승률':>5} │ {'월수익(KRW)':>13} {'누적수익(KRW)':>14}") cum = 0.0 for ym, m in sorted(result["monthly"].items()): wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0 cum += m["pnl_krw"] print(f" {ym:^8} │ {m['trades']:>4}건 {wr:>4.0f}% │" f" {m['pnl_krw']:>+13,.0f}원 {cum:>+14,.0f}원") # ── 메인 ───────────────────────────────────────────────── def main(): # ── 데이터 로드 ─────────────────────────────────────── if not CACHE_FILE.exists(): print(f"[오류] 캐시 없음: {CACHE_FILE}") print(" 먼저 tests/collect_1y_data.py 를 실행하세요.") return print("캐시 로드 중...") cache = pickle.load(open(CACHE_FILE, "rb")) all_tickers = list(cache["10m"].keys())[:TOP_N] tickers = [t for t in all_tickers if len(cache["10m"][t]) > 500] print(f" 유효 종목: {len(tickers)}개") # F&G 로드 fng_map: dict = {} if FNG_FILE.exists(): fng_map = json.load(open(FNG_FILE)) fng_dates = sorted(fng_map.keys()) print(f" F&G: {fng_dates[0]} ~ {fng_dates[-1]} ({len(fng_map)}일)") else: print(" [경고] F&G 데이터 없음") # 리샘플링 dfs40 = {t: resample_40m(cache["10m"][t]) for t in tickers} # 레짐 시리즈 regime_series = build_regime_series(dfs40) # 기간 정보 sample = next(iter(dfs40.values())) start_dt = sample.index[0].strftime("%Y-%m-%d") end_dt = sample.index[-1].strftime("%Y-%m-%d") # 레짐 분포 계산 if not regime_series.empty: valid = regime_series.dropna() bull_cnt = (valid >= BULL_THRESHOLD).sum() bear_cnt = (valid < BEAR_THRESHOLD).sum() neut_cnt = len(valid) - bull_cnt - bear_cnt total_cnt = len(valid) print(f"\n 레짐 분포 ({total_cnt}봉 기준):") print(f" BULL (≥{BULL_THRESHOLD}%) : {bull_cnt:>6}봉 ({bull_cnt/total_cnt*100:.1f}%)") print(f" NEUTRAL : {neut_cnt:>6}봉 ({neut_cnt/total_cnt*100:.1f}%)") print(f" BEAR (<{BEAR_THRESHOLD}%) : {bear_cnt:>6}봉 ({bear_cnt/total_cnt*100:.1f}%)") # F&G 분포 (해당 기간) if fng_map: period_fng = {k: v for k, v in fng_map.items() if start_dt <= k <= end_dt} zones = {"극공포(≤25)": 0, "공포(26~40)": 0, "중립+(≥41)": 0} for v in period_fng.values(): if v <= 25: zones["극공포(≤25)"] += 1 elif v <= 40: zones["공포(26~40)"] += 1 else: zones["중립+(≥41)"] += 1 tot = sum(zones.values()) print(f"\n F&G 분포 (동 기간 {tot}일):") for name, cnt in zones.items(): print(f" {name:12} {cnt:>3}일 ({cnt/tot*100:.1f}%)") print(f"\n{'='*60}") print(f" 레짐 BULL 진입 시뮬 | 1년 | {len(tickers)}종목 | 40분봉") print(f" 기간: {start_dt} ~ {end_dt}") print(f"{'='*60}") # ── 4가지 시뮬 실행 ─────────────────────────────────── CONFIGS = [ ("none", "필터 없음"), ("bear_off", "BEAR 차단 (현재)"), ("bull_only","BULL 진입만"), ("bull_fng", "BULL + F&G≥41"), ] results = {} for mode, label in CONFIGS: all_trades = [] wf_total = 0 for ticker in tickers: df40 = dfs40[ticker] raw = run_strategy(df40, ticker, regime_series, fng_map, mode) filtered, blocked = apply_wf(raw) wf_total += blocked all_trades.extend(filtered) all_trades.sort(key=lambda x: x[2]) accepted, skipped = apply_max_positions(all_trades) result = run_compound(accepted) results[label] = result print_result(label, result, len(skipped), wf_total) # ── 요약 비교 ───────────────────────────────────────── print(f"\n{'='*60}") print(f" 요약 비교") print(f"{'='*60}") print(f" {'구성':<22} {'진입':>5} {'승률':>6} {'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>7}") print(f" {'─'*58}") for mode, label in CONFIGS: r = results[label] total = r["total"] if total == 0: print(f" {label:<22} {'진입없음':>34}") continue peak = BUDGET max_dd = 0.0 for t in r["trade_log"]: peak = max(peak, t["portfolio"]) dd = (peak - t["portfolio"]) / peak * 100 max_dd = max(max_dd, dd) print( f" {label:<22} {total:>5}건 {r['wr']:>5.1f}% " f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}원 -{max_dd:.1f}%" ) # ── 월별 상세 (BULL 진입만) ─────────────────────────── print(f"\n{'='*60}") print(f" 월별 상세 — BULL 진입만") print(f"{'='*60}") print_monthly(results["BULL 진입만"]) print(f"\n 월별 상세 — BEAR 차단 (현재)") print_monthly(results["BEAR 차단 (현재)"]) print(f"{'='*60}") if __name__ == "__main__": main()