"""F&G 구간별 맞춤 파라미터 백테스트 핵심 가설: 극공포 구간은 시장이 불안정 → 더 엄격한 진입 기준 필요 탐욕 구간은 상승 모멘텀이 지속 → 다소 느슨한 기준도 가능 테스트 방식: 각 F&G 구간마다 다른 파라미터 조합을 적용하고 성과 비교. 구간별 최적 파라미터 도출 → 실제 전략에 반영 결과를 Oracle DB에 저장. 데이터: 1년치 1h 캔들 (배치 수집) """ from __future__ import annotations import datetime import json import sys import time import urllib.request import pandas as pd import pyupbit from dataclasses import dataclass # ── DB 저장 ───────────────────────────────────────────────── try: from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk DB_ENABLED = True except Exception as e: print(f" [DB 비활성화] {e}") DB_ENABLED = False TICKERS = [ "KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE", "KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK", "KRW-SUI", "KRW-HBAR", "KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-KAVA", "KRW-KNC", ] # ── F&G 구간별 파라미터 조합 ───────────────────────────────── # (fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min) ADAPTIVE_CONFIGS = [ # 기준선 (F&G 무관, 단일 파라미터) (None, None, "기준선(전체/현행파라미터)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0), # ── 극공포 (0~25) 구간 ── 엄격한 기준 ── # 극공포에서는 변동성 급증이 흔함 → 볼륨 기준 올리고, 모멘텀 강화 (None, 25, "극공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0), (None, 25, "극공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0), (None, 25, "극공포/매우엄격(3x+5%+1%스탑)", 3.0, 2.0, 6, 5.0, 3.0, 0.010, 24, 3.0), (None, 25, "극공포/넓은스탑(2x+3%+2%스탑)", 2.0, 2.0, 8, 3.0, 3.0, 0.020, 24, 3.0), (None, 25, "극공포/짧은신호(3x+4%+4h유효)", 3.0, 2.0, 4, 4.0, 3.0, 0.015, 24, 3.0), # ── 공포 (26~45) ── 중간 기준 ── (26, 45, "공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0), (26, 45, "공포/약강화(2.5x vol+3.5%mom)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0), (26, 45, "공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0), # ── 중립 이상 (46~100) ── 완화된 기준 가능 ── (46, None, "중립이상/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0), (46, None, "중립이상/완화(1.5x vol+2.5%mom)",1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0), (46, None, "중립이상/엄격(2.5x+3.5%)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0), # ── 탐욕+ (56~100) ── (56, None, "탐욕이상/기준", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0), (56, None, "탐욕이상/완화(1.5x+2.5%)", 1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0), ] # ── 데이터 수집 ────────────────────────────────────────────── def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None: all_dfs = [] end = datetime.datetime.now() batch = 1440 # 60일치씩 prev_oldest = None while True: df = pyupbit.get_ohlcv( ticker, interval="minute60", count=batch, to=end.strftime("%Y-%m-%d %H:%M:%S"), ) if df is None or df.empty: break all_dfs.append(df) oldest = df.index[0] # 상장 초기 종목: oldest가 진전되지 않으면 더 이상 오래된 데이터 없음 if prev_oldest is not None and oldest >= prev_oldest: break prev_oldest = oldest cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days) if oldest <= cutoff: break end = oldest time.sleep(0.12) if not all_dfs: return None combined = pd.concat(all_dfs).sort_index() combined = combined[~combined.index.duplicated(keep="last")] cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days) return combined[combined.index >= cutoff] def load_fng() -> dict[str, int]: url = "https://api.alternative.me/fng/?limit=400&format=json" with urllib.request.urlopen(url, timeout=10) as r: data = json.loads(r.read()) return { datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"): int(d["value"]) for d in data["data"] } def fng_val(fng_map, ts) -> int: return fng_map.get(ts.strftime("%Y-%m-%d"), 50) # ── 시뮬레이션 ────────────────────────────────────────────── @dataclass class Trade: pnl: float h: int fng: int exit: str def simulate( df, fng_map, fng_lo=None, fng_hi=None, vol_mult=2.0, quiet_2h=2.0, sig_to_h=8, mom_thr=3.0, sig_cancel=3.0, trail_stop=0.015, time_h=24, time_min=3.0, ) -> list[Trade]: closes = df["close"].values vols = df["volume"].values idx = df.index trades: list[Trade] = [] sig_px = sig_i = None pos_buy = pos_peak = pos_i = pos_fng = None for i in range(7, len(closes) - max(time_h + 4, 10)): if pos_buy is not None: cur = closes[i] if cur > pos_peak: pos_peak = cur if (pos_peak - cur) / pos_peak >= trail_stop: trades.append(Trade((cur - pos_buy) / pos_buy * 100, i - pos_i, pos_fng, "trail")) pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None continue if i - pos_i >= time_h: pnl = (cur - pos_buy) / pos_buy * 100 if pnl < time_min: trades.append(Trade(pnl, i - pos_i, pos_fng, "time")) pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None continue continue if sig_px is not None: if i - sig_i > sig_to_h: sig_px = sig_i = None elif (closes[i] - sig_px) / sig_px * 100 < -sig_cancel: sig_px = sig_i = None if sig_px is None: vol_avg = vols[i - 6:i - 1].mean() if vol_avg <= 0: continue if vols[i - 1] / vol_avg >= vol_mult: if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < quiet_2h: sig_px = closes[i] sig_i = i continue fv = fng_val(fng_map, idx[i]) if fng_lo is not None and fv < fng_lo: continue if fng_hi is not None and fv > fng_hi: continue if (closes[i] - sig_px) / sig_px * 100 >= mom_thr: pos_buy = pos_peak = closes[i] pos_i = i pos_fng = fv sig_px = sig_i = None return trades def stats(trades): if not trades: return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0, avg_win=0, avg_loss=0, max_dd=0) wins = [t for t in trades if t.pnl > 0] losses = [t for t in trades if t.pnl <= 0] aw = sum(t.pnl for t in wins) / len(wins) if wins else 0 al = sum(t.pnl for t in losses) / len(losses) if losses else 0 cum = pk = max_dd = 0.0 for t in trades: cum += t.pnl if cum > pk: pk = cum if pk - cum > max_dd: max_dd = pk - cum return dict( n=len(trades), wr=len(wins) / len(trades) * 100, avg_pnl=sum(t.pnl for t in trades) / len(trades), total_pnl=sum(t.pnl for t in trades), rr=abs(aw / al) if al else 0, avg_win=aw, avg_loss=al, max_dd=max_dd, ) def main(): print("F&G 데이터 로드...") fng_map = load_fng() print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...") datasets = {} for i, tk in enumerate(TICKERS): try: df = fetch_1y(tk, total_days=365) if df is not None and len(df) > 100: datasets[tk] = df sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ") except Exception as e: sys.stderr.write(f"\r {tk} 실패: {e} ") sys.stderr.write("\n") print(f" 완료: {len(datasets)}개 종목\n") # ── DB 준비 ─────────────────────────────────────────── run_id = None if DB_ENABLED: ensure_tables() params = { "tickers": len(datasets), "days": 365, "candle": "1h", "stop": "trail+time", } run_id = insert_run( run_name="fng_adaptive_1y", description="F&G 구간별 맞춤 파라미터 1년 백테스트", params=params, ) print(f" DB run_id: {run_id}\n") # ── 결과 출력 ───────────────────────────────────────── print("=" * 92) print(" F&G 구간별 맞춤 파라미터 성과 비교 (1년치 / 1h 캔들)") print("=" * 92) print(f" {'조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8} " f"{'손익비':>5} {'총PnL':>9} {'MaxDD':>7}") print(" " + "-" * 86) best_by_zone: dict[str, tuple] = {} for cfg in ADAPTIVE_CONFIGS: fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min = cfg all_trades: list[Trade] = [] per_ticker: dict[str, list[Trade]] = {} for tk, df in datasets.items(): t = simulate( df, fng_map, fng_lo=fng_lo, fng_hi=fng_hi, vol_mult=vol_mult, quiet_2h=quiet_2h, sig_to_h=sig_to_h, mom_thr=mom_thr, sig_cancel=sig_cancel, trail_stop=trail_stop, time_h=time_h, time_min=time_min, ) all_trades.extend(t) per_ticker[tk] = t s = stats(all_trades) # 구분선 (기준선 다음) if label == "극공포/기준(2x vol+3%mom)": print() if s["n"] == 0: print(f" {label:<42} 거래 없음") continue marker = " ★" if s["avg_pnl"] > 0 else "" print( f" {label:<42} {s['n']:>5}건 {s['wr']:>5.1f}% " f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} " f"{s['total_pnl']:>+8.1f}% -{s['max_dd']:>5.1f}%{marker}" ) # DB 저장 if DB_ENABLED and run_id: insert_result(run_id, label, s, fng_lo, fng_hi) for tk, t_list in per_ticker.items(): insert_trades_bulk(run_id, label, tk, t_list) # 구간별 최고 avg_pnl 추적 zone_key = label.split("/")[0] if zone_key not in best_by_zone or s["avg_pnl"] > best_by_zone[zone_key][1]: best_by_zone[zone_key] = (label, s["avg_pnl"], s) # ── 구간별 최적 요약 ────────────────────────────────── print() print(" ★ 구간별 최적 파라미터:") print(f" {'구간':<14} {'최적 조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8}") print(" " + "-" * 72) for zone, (label, best_pnl, s) in best_by_zone.items(): if s["n"] > 0: print(f" {zone:<14} {label:<42} {s['n']:>5}건 {s['wr']:>5.1f}% {best_pnl:>+7.3f}%") if DB_ENABLED and run_id: print(f"\n [DB 저장 완료] run_id: {run_id}") if __name__ == "__main__": main()