"""1분봉 N봉 연속 상승 vol spike 전략 파라미터 스윕. N=2/3/4 연속 조건 (양봉 + 상승 + vol spike) 모두 Oracle SQL에서 처리. Python은 ATR 파라미터 + VOL 임계값 스윕만 담당. 시그널 조건 (N_BARS=N): 봉[n-(N-1)]~봉[n]: 모두 vol_ratio >= VOL_MIN, 양봉, 연속 상승 진입: 봉[n+1] close 즉시 추적: 1분봉 trail stop (ATR) + time stop / 월별 배치 """ import sys, os, itertools from datetime import datetime, timedelta sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from dotenv import load_dotenv load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) import pandas as pd import oracledb import time as _time # ── 고정 파라미터 ───────────────────────────────────────────────────────────── VOL_LOOKBACK = 61 ATR_LOOKBACK = 28 TS_N = 240 TIME_STOP_PCT = 0.0 / 100 FEE = 0.0005 BUDGET = 15_000_000 MAX_POS = 3 PER_POS = BUDGET // MAX_POS # ── 스윕 파라미터 ───────────────────────────────────────────────────────────── VOL_SWEEP = [1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 25.0] ATR_SWEEP = { "ATR_MULT": [1.5, 2.0, 2.5, 3.0], "ATR_MIN": [0.005, 0.010, 0.015], "ATR_MAX": [0.020, 0.025, 0.030], } N_BARS_LIST = [2, 3, 4] VOL_MIN = min(VOL_SWEEP) # SQL pre-filter (모든 봉 >= 1.5x) (모든 봉에 적용) # ── 시뮬 구간 ───────────────────────────────────────────────────────────────── SIM_START = datetime(2025, 8, 1) SIM_END = datetime(2026, 3, 4) WARMUP_MINS = 120 TICKERS = [ 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', ] _TK = ",".join(f"'{t}'" for t in TICKERS) def _months(start: datetime, end: datetime): m = start.replace(day=1) while m < end: nxt = (m + timedelta(days=32)).replace(day=1) if nxt > end: nxt = end yield m, nxt m = nxt def _get_conn(): kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], dsn=os.environ["ORACLE_DSN"]) wallet = os.environ.get("ORACLE_WALLET") if wallet: kwargs["config_dir"] = wallet return oracledb.connect(**kwargs) # ── N별 SQL 생성 ────────────────────────────────────────────────────────────── # 공통 CTE: base(TR + LAG N개), indicators(vol_ratio N개 + ATR) # signals: N봉 연속 조건 모두 SQL에서 처리 # vol_ratio 컬럼은 Python VOL 스윕용으로 모두 반환 def build_sql(n: int) -> str: """n봉 연속 조건을 SQL로 구현. 반환 컬럼에 vol_ratio 0~n-1 포함.""" # LAG 컬럼 정의 (base CTE) lag_cols = "\n".join( f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n" f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n" f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i}," for i in range(1, n) ) # indicators CTE: vol_ratio 0 ~ n-1 vr_cols = [] vr_cols.append(f""" volume_p / NULLIF( AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 ) vol_ratio_0,""") for i in range(1, n): vr_cols.append(f""" prev_vol_{i} / NULLIF( AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 ) vol_ratio_{i},""") vr_cols_str = "\n".join(vr_cols) # pass-through LAG 컬럼 from base → indicators lag_passthrough = "\n".join( f" prev_close_{i}, prev_open_{i}," for i in range(1, n) ) # signals WHERE 조건: 현재봉 + 이전 n-1봉 all 양봉 + 연속 상승 + vol cond_lines = [ " AND vol_ratio_0 >= :min_vol", " AND close_p > open_p", # 현재봉 양봉 ] for i in range(1, n): cond_lines.append(f" AND vol_ratio_{i} >= :min_vol") cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 양봉 if i == 1: cond_lines.append(f" AND close_p > prev_close_{i}") # 현재봉 > 1봉전 else: cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 상승 연속 cond_str = "\n".join(cond_lines) # SELECT: vol_ratio 0~n-1 반환 (Python VOL 스윕용) vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n)) return f""" WITH base AS ( SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, {lag_cols} GREATEST( high_p - low_p, ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)) ) tr FROM backtest_ohlcv WHERE interval_cd = 'minute1' AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS') AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') AND ticker IN ({_TK}) ), indicators AS ( SELECT ticker, ts, open_p, close_p, {lag_passthrough} {vr_cols_str} AVG(tr) OVER (PARTITION BY ticker ORDER BY ts ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(prev_close_1, 0) atr_raw FROM base ), signals AS ( SELECT ticker, ts sig_ts, close_p sig_price, {vr_select}, atr_raw FROM indicators WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS') AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') {cond_str} ), entry_cands AS ( SELECT s.ticker, s.sig_ts, {vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw, e.ts entry_ts, e.close_p entry_price, ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn FROM signals s JOIN backtest_ohlcv e ON e.ticker = s.ticker AND e.interval_cd = 'minute1' AND e.ts > s.sig_ts AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE ), entries AS ( SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price FROM entry_cands WHERE rn = 1 ), post_entry AS ( SELECT e.ticker, e.sig_ts, e.entry_ts, e.entry_price, {vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw, b.close_p bar_price, ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n, MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak FROM entries e JOIN backtest_ohlcv b ON b.ticker = e.ticker AND b.interval_cd = 'minute1' AND b.ts >= e.entry_ts AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE ) SELECT ticker, sig_ts, entry_ts, entry_price, {vr_select}, atr_raw, bar_n, bar_price, running_peak FROM post_entry WHERE bar_n <= :ts_n + 1 ORDER BY ticker, entry_ts, bar_n """ # ── 월별 데이터 로드 ────────────────────────────────────────────────────────── print(f"N봉 연속 vol spike 전략 (VOL>={VOL_MIN}x, N={N_BARS_LIST})\n", flush=True) conn = _get_conn() cur = conn.cursor() cur.arraysize = 100_000 # N별로 별도 딕셔너리 ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST} t_load = _time.time() for n in N_BARS_LIST: sql = build_sql(n) print(f"── {n}봉 로드 중... ──────────────────────────", flush=True) n_total = 0 for m_start, m_end in _months(SIM_START, SIM_END): load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S') sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S') sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S') t0 = _time.time() cur.execute(sql, { "load_since": load_since, "sim_start": sim_start, "sim_end": sim_end, "min_vol": VOL_MIN, "ts_n": TS_N, }) rows = cur.fetchall() t1 = _time.time() n_new = 0 for row in rows: # ticker, sig_ts, entry_ts, entry_price, vr0..vr(n-1), atr_raw, bar_n, bar_price, peak ticker = row[0] entry_ts = row[2] entry_price= float(row[3]) vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)] atr_raw = row[4 + n] bar_price = float(row[4 + n + 2]) # bar_n is at 4+n+1 running_peak = float(row[4 + n + 3]) key = (ticker, entry_ts) if key not in ALL_ENTRIES[n]: ALL_ENTRIES[n][key] = { 'entry_price': entry_price, 'vr': vr_vals, 'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'), 'bars': [], } n_new += 1 ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak)) n_total += n_new print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True) print(f" → {n}봉 합계: {n_total}건\n", flush=True) conn.close() print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True) # ── 출구 탐색 ───────────────────────────────────────────────────────────────── def find_exit(entry_price: float, atr_stop: float, bars: list) -> float: for i, (bp, pk) in enumerate(bars): drop = (pk - bp) / pk if pk > 0 else 0.0 pnl = (bp - entry_price) / entry_price if drop >= atr_stop: return pnl * 100 if i + 1 >= TS_N and pnl < TIME_STOP_PCT: return pnl * 100 return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0 # ── 스윕 ────────────────────────────────────────────────────────────────────── atr_keys = list(ATR_SWEEP.keys()) atr_combos = list(itertools.product(*ATR_SWEEP.values())) total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos) print(f"총 {total_combos}가지 조합 스윕...\n", flush=True) t_sweep = _time.time() results = [] for n in N_BARS_LIST: entry_list = list(ALL_ENTRIES[n].values()) for vol in VOL_SWEEP: for atr_combo in atr_combos: atr_params = dict(zip(atr_keys, atr_combo)) if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']: continue atr_mult = atr_params['ATR_MULT'] atr_min = atr_params['ATR_MIN'] atr_max = atr_params['ATR_MAX'] trades = [] for e in entry_list: # Python: vol 임계값 최종 필터 (모든 봉 vol >= vol) if min(e['vr']) < vol: continue ar = e['atr_raw'] atr_s = (atr_min if (ar != ar) else max(atr_min, min(atr_max, ar * atr_mult))) pnl_pct = find_exit(e['entry_price'], atr_s, e['bars']) krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2 trades.append((pnl_pct, krw)) if not trades: results.append({'N_BARS': n, 'VOL': vol, **atr_params, 'trades': 0, 'wins': 0, 'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0}) continue wins = sum(1 for p, _ in trades if p > 0) results.append({ 'N_BARS': n, 'VOL': vol, **atr_params, 'trades': len(trades), 'wins': wins, 'win_rate': wins / len(trades) * 100, 'avg_pnl': sum(p for p, _ in trades) / len(trades), 'total_krw': sum(k for _, k in trades), }) print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n") # ── 결과 출력 ───────────────────────────────────────────────────────────────── df_r = pd.DataFrame(results) df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False) print("=" * 105) print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} " f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}") print("=" * 105) for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1): print(f"{rank:>4} {int(row['N_BARS']):>2}봉 {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} " f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% " f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% " f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원") # N봉 × VOL별 최상위 요약 print("\n" + "─" * 85) print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)") print("─" * 85) for n in N_BARS_LIST: for vol in VOL_SWEEP: sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)] if sub.empty: continue best = sub.iloc[0] if best['trades'] == 0: continue print(f" {int(n):>2}봉 {vol:>4.0f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% " f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 " f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)") print()