"""최근 N일 볼륨 가속 시그널 확인 + ATR trail stop 시뮬레이션. 4봉 연속 가격+볼륨 가속 시그널 발생 후 실제 trail stop 로직을 돌려 진입가·청산가·PNL을 표시. 최적 파라미터 (sweep_volaccel 기준): N봉=4, VOL≥5.0x, ATR_MULT=1.5, ATR_MIN=1.5%, ATR_MAX=2.0% """ import sys, os 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 oracledb # ── 파라미터 ────────────────────────────────────────────────────────────────── VOL_LOOKBACK = 61 ATR_LOOKBACK = 28 LOOKBACK_DAYS = 3 # 오늘 기준 며칠 전까지 시그널 탐색 VOL_MIN_LIST = [8.0] VOL_MIN = min(VOL_MIN_LIST) # ATR 비교 목록: (MULT, MIN%, MAX%) — 타이트→여유 순 ATR_SCENARIOS = [ (1.0, 0.010, 0.020), # 타이트: 1.0×ATR, 1.0~2.0% (1.5, 0.015, 0.025), # 기존최적: 1.5×ATR, 1.5~2.5% (1.0, 0.020, 0.030), # 고정 2.0~3.0% (1.0, 0.030, 0.050), # 느슨: 3.0~5.0% ] ATR_MULT = ATR_SCENARIOS[0][0] ATR_MIN_R = ATR_SCENARIOS[0][1] ATR_MAX_R = ATR_SCENARIOS[0][2] MAX_TRAIL_BARS = 240 BUDGET = 15_000_000 MAX_POS = 3 PER_POS = BUDGET // MAX_POS FEE = 0.0005 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) # ── 시그널 SQL (4봉 가속 조건 전부 Oracle SQL에서 처리) ─────────────────────── SIGNAL_SQL = f""" WITH base AS ( SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1, LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1, LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1, LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2, LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2, LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2, LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3, LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3, LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3, 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(:warmup_since, 'YYYY-MM-DD HH24:MI:SS') AND ticker IN ({_TK}) ), indicators AS ( SELECT ticker, ts, open_p, close_p, volume_p / NULLIF( AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 ) vr0, prev_vol_1 / NULLIF( AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 ) vr1, prev_vol_2 / NULLIF( AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 ) vr2, prev_vol_3 / NULLIF( AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 ) vr3, prev_close_1, prev_open_1, prev_close_2, prev_open_2, prev_close_3, prev_open_3, 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 ) SELECT ticker, ts sig_ts, close_p sig_price, vr0, vr1, vr2, vr3, atr_raw, prev_close_3, prev_close_2, prev_close_1, close_p FROM indicators WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS') AND vr0 >= :min_vol -- 4봉 연속: 양봉 AND close_p > open_p AND prev_close_1 > prev_open_1 AND prev_close_2 > prev_open_2 AND prev_close_3 > prev_open_3 -- 4봉 연속: 가격 가속 AND close_p > prev_close_1 AND prev_close_1 > prev_close_2 AND prev_close_2 > prev_close_3 -- 4봉 연속: 볼륨 가속 AND vr0 > vr1 AND vr1 > vr2 AND vr2 > vr3 ORDER BY ticker, ts """ 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) def simulate_trail(cur, ticker: str, entry_ts, entry_price: float, atr_raw: float, mult: float = None, min_r: float = None, max_r: float = None) -> dict: """entry_ts 봉부터 trail stop 시뮬. 상세 결과 dict 반환.""" m = mult if mult is not None else ATR_MULT n = min_r if min_r is not None else ATR_MIN_R x = max_r if max_r is not None else ATR_MAX_R ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0 atr_stop = max(n, min(x, ar * m)) if ar > 0 else x cur.execute( """SELECT ts, close_p FROM backtest_ohlcv WHERE ticker=:t AND interval_cd='minute1' AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""", {"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1} ) bars = cur.fetchall() if not bars: return dict(status="데이터없음", exit_ts=entry_ts, exit_price=entry_price, peak=entry_price, peak_pct=0.0, pnl=0.0, atr_stop=atr_stop, held_bars=0) running_peak = entry_price for i, (ts, close_p) in enumerate(bars): close_p = float(close_p) running_peak = max(running_peak, close_p) drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0 if drop >= atr_stop: pnl = (close_p - entry_price) / entry_price * 100 peak_pct = (running_peak - entry_price) / entry_price * 100 return dict(status="손절", exit_ts=ts, exit_price=close_p, peak=running_peak, peak_pct=peak_pct, pnl=pnl, atr_stop=atr_stop, held_bars=i + 1) last_ts, last_price = bars[-1][0], float(bars[-1][1]) pnl = (last_price - entry_price) / entry_price * 100 peak_pct = (running_peak - entry_price) / entry_price * 100 status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중" return dict(status=status, exit_ts=last_ts, exit_price=last_price, peak=running_peak, peak_pct=peak_pct, pnl=pnl, atr_stop=atr_stop, held_bars=len(bars)) def apply_pos_limit(sim_results: list, vol_thr: float) -> tuple: """VOL 필터 + MAX_POS 동시 포지션 제한 적용. (taken, skipped) 반환.""" filtered = [r for r in sim_results if r['vr'][3] >= vol_thr] open_positions = [] taken, skipped = [], [] for r in filtered: open_positions = [ex for ex in open_positions if ex > r['entry_ts']] if len(open_positions) < MAX_POS: open_positions.append(r['exit_ts']) taken.append(r) else: skipped.append(r) return taken, skipped def print_detail(vol_thr: float, taken: list, skipped: list): div = "─" * 120 print(f"\n{'━'*120}") print(f" 4봉 VOL≥{vol_thr:.0f}x | ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%] " f"| 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}개") print(f"{'━'*120}\n") total_krw, total_wins = 0.0, 0 for i, r in enumerate(taken, 1): vr = r['vr'] entry_str = str(r['entry_ts'])[:16] exit_str = str(r['exit_ts'])[:16] krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 total_krw += krw won = r['pnl'] > 0 if won: total_wins += 1 pr = r['prices'] # 봉별 가격 변화율 (봉1→2, 2→3, 3→4) pchg = [ (pr[1] - pr[0]) / pr[0] * 100 if pr[0] else 0, (pr[2] - pr[1]) / pr[1] * 100 if pr[1] else 0, (pr[3] - pr[2]) / pr[2] * 100 if pr[2] else 0, ] sign = "▲" if won else "▼" print(f" #{i:02d} {r['ticker']:12s} [{sign}]") print(f" 가격흐름 {pr[0]:,.0f} → {pr[1]:,.0f} ({pchg[0]:+.2f}%)" f" → {pr[2]:,.0f} ({pchg[1]:+.2f}%)" f" → {pr[3]:,.0f} ({pchg[2]:+.2f}%)") print(f" 볼륨흐름 {vr[0]:.1f}x → {vr[1]:.1f}x → {vr[2]:.1f}x → {vr[3]:.1f}x " f"(ATR손절 {r['atr_stop']*100:.1f}%)") print(f" 진입 {entry_str} @ {r['entry_price']:>13,.0f}원") print(f" 고점 {r['peak']:>13,.0f}원 ({r['peak_pct']:>+.2f}%까지 상승)") print(f" 청산 {exit_str} @ {r['exit_price']:>13,.0f}원" f" ({r['status']}, {r['held_bars']}봉 보유)") print(f" 손익 {r['pnl']:>+.2f}% → {krw:>+,.0f}원") print() if skipped: print(f" ── 스킵 {len(skipped)}건 (포지션 한도 초과) ──") for r in skipped: vr = r['vr'] krw_if = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 print(f" SKIP {r['ticker']:12s} 진입 {str(r['entry_ts'])[:16]} " f"vol {vr[3]:.1f}x → {r['pnl']:>+.2f}% ({krw_if:>+,.0f}원 기회손실)") print() n = len(taken) win_rate = total_wins / n * 100 if n else 0 ret_rate = total_krw / BUDGET * 100 print(div) print(f" ◆ 실거래 {n}건 | 승률 {win_rate:.0f}% ({total_wins}/{n}) | " f"합산 {total_krw:>+,.0f}원 | 수익률 {ret_rate:>+.2f}%") print(div) def main(): now = datetime.now() check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') print(f"=== 4봉 볼륨 가속 시뮬 — VOL {VOL_MIN_LIST} 비교 ===") print(f"ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%], " f"자본 {BUDGET/10000:.0f}만원 / 포지션당 {PER_POS/10000:.0f}만원 / 동시 {MAX_POS}개") print(f"조회 기간: {check_since[:10]} ~ 현재\n") conn = _get_conn() cur = conn.cursor() cur.arraysize = 10_000 # ── 1회 조회 (VOL_MIN = 가장 낮은 값) ──────────────────────────────────────── cur.execute(SIGNAL_SQL, { "warmup_since": warmup_since, "check_since": check_since, "min_vol": VOL_MIN, }) rows = cur.fetchall() if not rows: print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음") conn.close() return # 진입봉 조회 signals = [] for row in rows: ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \ p3, p2, p1, p0 = row cur.execute( """SELECT close_p, ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd='minute1' AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE ORDER BY ts FETCH FIRST 1 ROWS ONLY""", {"t": ticker, "sig": sig_ts} ) entry_row = cur.fetchone() if not entry_row: continue entry_price, entry_ts = float(entry_row[0]), entry_row[1] signals.append({ 'ticker': ticker, 'sig_ts': sig_ts, 'entry_ts': entry_ts, 'entry_price': entry_price, 'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]], 'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]], 'atr_raw': float(atr_raw) if atr_raw else 0.0, }) if not signals: print("진입봉을 찾을 수 없음") conn.close() return # ── trail stop 시뮬 — 시나리오별 ───────────────────────────────────────────── signals.sort(key=lambda x: x['entry_ts']) vol_thr = VOL_MIN_LIST[0] # 각 시나리오에 맞는 bars를 재사용하기 위해 기본(가장 느슨한) 시나리오로 bars 캐시 # (atr_stop 달라도 bars 조회는 동일하므로 1회만 fetch) print(f"시그널 {len(signals)}건 trail stop 시뮬 중 ({len(ATR_SCENARIOS)}가지 ATR 비교)...", flush=True) # 시나리오별 결과 수집 scenario_results = [] for mult, min_r, max_r in ATR_SCENARIOS: sim = [] for s in signals: trail = simulate_trail(cur, s['ticker'], s['entry_ts'], s['entry_price'], s['atr_raw'], mult=mult, min_r=min_r, max_r=max_r) sim.append({**s, **trail}) taken, skipped = apply_pos_limit(sim, vol_thr) scenario_results.append((mult, min_r, max_r, taken, skipped)) # ── 시나리오 요약 비교표 ────────────────────────────────────────────────────── print(f"\n{'━'*95}") print(f" {'ATR 설정':22s} {'거래':>4s} {'승률':>5s} {'합산손익':>12s} {'수익률':>6s} {'평균보유(분)':>10s}") print(f"{'━'*95}") best_krw = max(sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken) for _, _, _, taken, _ in scenario_results) for mult, min_r, max_r, taken, skipped in scenario_results: n = len(taken) if n == 0: continue wins = sum(1 for r in taken if r['pnl'] > 0) total = sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken) avg_held = sum(r['held_bars'] for r in taken) / n ret = total / BUDGET * 100 label = f"{mult:.1f}×ATR [{min_r*100:.1f}~{max_r*100:.1f}%]" star = " ★" if abs(total - best_krw) < 1 else "" print(f" {label:22s} {n:>4d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 {ret:>+5.2f}%" f" {avg_held:>6.0f}봉{star}") print(f"{'━'*95}") # ── 최적 시나리오 건별 상세 출력 ───────────────────────────────────────────── best_idx = max(range(len(scenario_results)), key=lambda i: sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in scenario_results[i][3])) bm, bn, bx, best_taken, best_skipped = scenario_results[best_idx] print(f"\n[최적 시나리오 건별 상세: {bm:.1f}×ATR {bn*100:.1f}~{bx*100:.1f}%]") print_detail(vol_thr, best_taken, best_skipped) conn.close() if __name__ == '__main__': main()