"""1분봉 볼륨 가속 전략 파라미터 스윕. 시그널 조건: N봉 연속으로 가격 AND 거래량이 함께 증가 봉[n-(N-1)] < 봉[n-(N-2)] < ... < 봉[n] - 각 봉: 양봉 (close > open) - 가격 연속 상승: close[k] > close[k-1] - 볼륨 연속 증가: vol_ratio[k] > vol_ratio[k-1] - 현재봉(가장 강한 봉) vol_ratio >= VOL_MIN (pre-filter) 진입: 봉[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_ratio 최솟값 — 가속의 "끝점" 기준 VOL_SWEEP = [2.0, 3.0, 4.0, 5.0, 8.0, 10.0, 15.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 # ── 시뮬 구간 ───────────────────────────────────────────────────────────────── 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 생성 ────────────────────────────────────────────────────────────── # 조건: 가격 연속 상승 + 볼륨 연속 증가 (+ 양봉) # vol_ratio_0 > vol_ratio_1 > ... > vol_ratio_(n-1) # close_0 > close_1 > ... > close_(n-1) # 모두 Oracle SQL에서 처리 def build_sql(n: int) -> str: 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) ) 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) lag_passthrough = "\n".join( f" prev_close_{i}, prev_open_{i}," for i in range(1, n) ) # 조건: 양봉 + 가격 연속 상승 + 볼륨 연속 증가 cond_lines = [ " AND vol_ratio_0 >= :min_vol", # pre-filter: 현재봉(최강봉) " AND close_p > open_p", # 현재봉 양봉 ] for i in range(1, n): cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 이전봉 양봉 if i == 1: cond_lines.append(f" AND close_p > prev_close_{i}") # 가격 상승 cond_lines.append(f" AND vol_ratio_0 > vol_ratio_{i}") # 볼륨 증가 else: cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 가격 상승 cond_lines.append(f" AND vol_ratio_{i-1} > vol_ratio_{i}") # 볼륨 증가 cond_str = "\n".join(cond_lines) 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"볼륨 가속 전략 (현재봉 VOL>={VOL_MIN}x, N={N_BARS_LIST}, 가격·볼륨 동시 가속)\n", flush=True) conn = _get_conn() cur = conn.cursor() cur.arraysize = 100_000 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 = 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]) 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, 'vr0': vr_vals[0], # 현재봉 vol (가장 강한 봉) '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: # 현재봉(최강봉) vol 기준으로 필터 if e['vr0'] < 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.1f}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.1f}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()