- Move all backtest/simulation scripts to tests/ - Add sys.path.insert to each script for correct import resolution - Move pkl cache files to data/ (git-ignored) - Move log files to logs/ (git-ignored) - Update main.py: trading.log path → logs/trading.log - Add ecosystem.config.js: pm2 log paths → logs/pm2*.log - Update .gitignore: ignore data/ and logs/ instead of *.pkl/*.log - core/fng.py: increase cache TTL 3600→86400s (API updates daily at KST 09:00) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""sim10m.py — 10분봉 + 극단적 거래량 즉시 진입 전략 시뮬.
|
||
|
||
기존 전략 (1h봉 vol-lead) vs 신규 전략 (10분봉 + 100x 이상 거래량 즉시 진입) 비교.
|
||
데이터: 최근 45일 Upbit API
|
||
"""
|
||
|
||
import pickle
|
||
import time
|
||
import sys
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
|
||
import pandas as pd
|
||
import pyupbit
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
|
||
# ── 파라미터 ──────────────────────────────────────────────
|
||
SIM_DAYS = 45
|
||
TOP30_FILE = Path("top30_tickers.pkl")
|
||
CACHE_FILE = Path("sim10m_cache.pkl")
|
||
TOP_N = 20
|
||
|
||
FEE = 0.0005
|
||
TIME_STOP_MIN_PCT = 3.0
|
||
ATR_MULT = 1.5
|
||
ATR_MIN = 0.010
|
||
ATR_MAX = 0.020
|
||
|
||
# A: 기존 1h봉
|
||
A_LOCAL_VOL = 5 # 봉수 (= 5h)
|
||
A_VOL_MULT = 2.0
|
||
A_QUIET_PCT = 2.0
|
||
A_THRESH = 4.8
|
||
A_SIGNAL_TO = 8 # 신호 유효 봉수 (= 8h)
|
||
A_ATR_CANDLES = 5
|
||
A_TIME_STOP = 8 # 타임스탑 봉수 (= 8h)
|
||
|
||
# B: 신규 10분봉
|
||
B_LOCAL_VOL = 30 # 봉수 (5h = 30 × 10min)
|
||
B_VOL_MULT = 2.0
|
||
B_EXTREME_VOL = 100 # 이 이상 → 횡보 조건 면제, 다음 봉 즉시 진입
|
||
B_QUIET_CANDLES = 12 # 2h = 12봉
|
||
B_QUIET_PCT = 2.0
|
||
B_THRESH = 4.8
|
||
B_SIGNAL_TO = 48 # 신호 유효 봉수 (8h = 48봉)
|
||
B_ATR_CANDLES = 30 # ATR 봉수 (5h = 30봉)
|
||
B_TIME_STOP = 48 # 타임스탑 봉수 (8h = 48봉)
|
||
|
||
|
||
# ── 데이터 로드 ──────────────────────────────────────────
|
||
def fetch_ohlcv(ticker, interval, days):
|
||
"""OHLCV 과거 데이터 페이징 로드."""
|
||
target_start = datetime.now() - timedelta(days=days)
|
||
all_dfs, to, prev_oldest = [], None, None
|
||
while True:
|
||
kwargs = dict(ticker=ticker, interval=interval, count=200)
|
||
if to:
|
||
kwargs["to"] = to
|
||
df = pyupbit.get_ohlcv(**kwargs)
|
||
if df is None or df.empty:
|
||
break
|
||
df.index = df.index.tz_localize(None)
|
||
oldest = df.index[0]
|
||
# 더 이상 과거로 못 가면 중단 (상장일에 도달)
|
||
if prev_oldest is not None and oldest >= prev_oldest:
|
||
all_dfs.append(df)
|
||
break
|
||
all_dfs.append(df)
|
||
prev_oldest = oldest
|
||
if oldest <= target_start:
|
||
break
|
||
to = oldest.strftime("%Y-%m-%d %H:%M:%S")
|
||
time.sleep(0.15)
|
||
if not all_dfs:
|
||
return None
|
||
result = pd.concat(all_dfs).sort_index().drop_duplicates()
|
||
return result[result.index >= target_start]
|
||
|
||
|
||
def load_data(tickers):
|
||
if CACHE_FILE.exists():
|
||
print(f"캐시 로드: {CACHE_FILE}")
|
||
return pickle.load(open(CACHE_FILE, "rb"))
|
||
|
||
data = {"1h": {}, "10m": {}}
|
||
for idx, ticker in enumerate(tickers, 1):
|
||
print(f" [{idx}/{len(tickers)}] {ticker}...", end=" ", flush=True)
|
||
df1h = fetch_ohlcv(ticker, "minute60", SIM_DAYS)
|
||
df10m = fetch_ohlcv(ticker, "minute10", SIM_DAYS)
|
||
n1h = len(df1h) if df1h is not None else 0
|
||
n10m = len(df10m) if df10m is not None else 0
|
||
print(f"1h={n1h}봉 10m={n10m}봉")
|
||
if df1h is not None and n1h >= 50: data["1h"][ticker] = df1h
|
||
if df10m is not None and n10m >= 200: data["10m"][ticker] = df10m
|
||
|
||
pickle.dump(data, open(CACHE_FILE, "wb"))
|
||
print(f"캐시 저장: {CACHE_FILE}\n")
|
||
return data
|
||
|
||
|
||
# ── ATR 계산 (시뮬용) ─────────────────────────────────────
|
||
def calc_atr(df, buy_idx, n):
|
||
sub = df.iloc[max(0, buy_idx - n - 1):buy_idx]
|
||
if len(sub) < 3:
|
||
return ATR_MIN
|
||
try:
|
||
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-n:].mean()
|
||
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
|
||
except Exception:
|
||
return ATR_MIN
|
||
|
||
|
||
# ── 포지션 시뮬 ──────────────────────────────────────────
|
||
def simulate_pos(df, buy_idx, buy_price, stop_pct, time_stop_candles):
|
||
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, sp, ts, f"트레일링({pnl:+.1f}%)", pnl
|
||
pnl_now = (row["close"] - buy_price) / buy_price * 100
|
||
if (i - buy_idx) >= time_stop_candles and pnl_now < TIME_STOP_MIN_PCT:
|
||
pnl = (row["close"] * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
|
||
return pnl > 0, row["close"], ts, "타임스탑", pnl
|
||
last = df.iloc[-1]["close"]
|
||
pnl = (last * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
|
||
return pnl > 0, last, df.index[-1], "데이터종료", pnl
|
||
|
||
|
||
# ── 전략 A: 기존 1h봉 vol-lead ──────────────────────────
|
||
def run_a(df):
|
||
trades = []
|
||
sig_i = sig_p = None
|
||
in_pos = False
|
||
buy_idx = buy_price = stop_pct = None
|
||
i = max(A_LOCAL_VOL + 2, 3)
|
||
|
||
while i < len(df):
|
||
if in_pos:
|
||
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, A_TIME_STOP)
|
||
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, "일반"))
|
||
in_pos = False
|
||
sig_i = sig_p = None
|
||
i = next_i
|
||
continue
|
||
|
||
close = df.iloc[i]["close"]
|
||
chg_2h = abs(close - df.iloc[i-2]["close"]) / df.iloc[i-2]["close"] * 100
|
||
quiet = chg_2h < A_QUIET_PCT
|
||
vol_p = df.iloc[i-1]["volume"]
|
||
vol_avg = df.iloc[i-A_LOCAL_VOL-1:i-1]["volume"].mean()
|
||
spike = vol_avg > 0 and vol_p >= vol_avg * A_VOL_MULT
|
||
|
||
if quiet and spike:
|
||
if sig_i is None: sig_i, sig_p = i, close
|
||
else:
|
||
if sig_i is not None and close < sig_p: sig_i = sig_p = None
|
||
if sig_i is not None and (i - sig_i) > A_SIGNAL_TO:
|
||
sig_i = sig_p = None
|
||
if sig_i is not None and (close - sig_p) / sig_p * 100 >= A_THRESH:
|
||
in_pos = True; buy_idx = i; buy_price = close
|
||
stop_pct = calc_atr(df, i, A_ATR_CANDLES)
|
||
sig_i = sig_p = None
|
||
i += 1
|
||
return trades
|
||
|
||
|
||
# ── 전략 B: 10분봉 + 극단적 거래량 즉시 진입 ────────────
|
||
def run_b(df):
|
||
# trade tuple: (is_win, pnl, buy_dt, sell_dt, entry_type)
|
||
# entry_type: '일반' | '극단'
|
||
trades = []
|
||
sig_i = sig_p = None
|
||
extreme_pending = False
|
||
in_pos = False
|
||
buy_idx = buy_price = stop_pct = None
|
||
i = max(B_LOCAL_VOL + 2, B_QUIET_CANDLES + 1)
|
||
|
||
while i < len(df):
|
||
if in_pos:
|
||
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, B_TIME_STOP)
|
||
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, entry_type))
|
||
in_pos = False
|
||
sig_i = sig_p = None
|
||
extreme_pending = False
|
||
i = next_i
|
||
continue
|
||
|
||
close = df.iloc[i]["close"]
|
||
vol_p = df.iloc[i-1]["volume"]
|
||
vol_avg = df.iloc[i-B_LOCAL_VOL-1:i-1]["volume"].mean()
|
||
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
|
||
|
||
# 극단적 거래량 → 다음 봉 진입 대기 설정
|
||
if not extreme_pending and vol_r >= B_EXTREME_VOL:
|
||
extreme_pending = True
|
||
i += 1
|
||
continue
|
||
|
||
# 극단적 거래량 다음 봉 → 즉시 진입
|
||
if extreme_pending:
|
||
in_pos = True; buy_idx = i; buy_price = close
|
||
stop_pct = calc_atr(df, i, B_ATR_CANDLES)
|
||
entry_type = "극단"
|
||
extreme_pending = False
|
||
sig_i = sig_p = None
|
||
i += 1
|
||
continue
|
||
|
||
# 일반 vol-lead
|
||
close_2h = df.iloc[i - B_QUIET_CANDLES]["close"]
|
||
chg_2h = abs(close - close_2h) / close_2h * 100
|
||
quiet = chg_2h < B_QUIET_PCT
|
||
spike = vol_r >= B_VOL_MULT
|
||
|
||
if quiet and spike:
|
||
if sig_i is None: sig_i, sig_p = i, close
|
||
else:
|
||
if sig_i is not None and close < sig_p: sig_i = sig_p = None
|
||
if sig_i is not None and (i - sig_i) > B_SIGNAL_TO:
|
||
sig_i = sig_p = None
|
||
if sig_i is not None and (close - sig_p) / sig_p * 100 >= B_THRESH:
|
||
in_pos = True; buy_idx = i; buy_price = close
|
||
stop_pct = calc_atr(df, i, B_ATR_CANDLES)
|
||
entry_type = "일반"
|
||
sig_i = sig_p = None
|
||
i += 1
|
||
return trades
|
||
|
||
|
||
# ── 통계 계산 ─────────────────────────────────────────────
|
||
def calc_stats(trades):
|
||
if not trades:
|
||
return {"n": 0, "wr": 0.0, "cum": 0.0, "dd": 0.0}
|
||
wins = sum(1 for t in trades if t[0])
|
||
cum = peak = dd = 0.0
|
||
for t in sorted(trades, key=lambda x: x[2]):
|
||
cum += t[1]
|
||
peak = max(peak, cum)
|
||
dd = max(dd, peak - cum)
|
||
return {"n": len(trades), "wr": wins / len(trades) * 100, "cum": cum, "dd": dd}
|
||
|
||
|
||
# ── 메인 ─────────────────────────────────────────────────
|
||
def main():
|
||
top30 = pickle.load(open(TOP30_FILE, "rb"))
|
||
tickers = top30[:TOP_N]
|
||
|
||
print(f"{'='*60}")
|
||
print(f"10분봉 전략 시뮬 | 최근 {SIM_DAYS}일 | {TOP_N}종목")
|
||
print(f" A: 기존 1h봉 vol-lead")
|
||
print(f" B: 10분봉 vol-lead + 극단거래량({B_EXTREME_VOL}x) 즉시 진입")
|
||
print(f"{'='*60}\n")
|
||
|
||
print(f"데이터 로드 중...")
|
||
data = load_data(tickers)
|
||
|
||
v1h = [t for t in tickers if t in data["1h"] and len(data["1h"][t]) >= 50]
|
||
v10m = [t for t in tickers if t in data["10m"] and len(data["10m"][t]) >= 200]
|
||
print(f"유효: 1h={len(v1h)}종목 / 10m={len(v10m)}종목\n")
|
||
|
||
# 전략 A
|
||
a_all = []
|
||
for t in v1h:
|
||
a_all.extend(run_a(data["1h"][t]))
|
||
|
||
# 전략 B
|
||
b_all = []
|
||
for t in v10m:
|
||
b_all.extend(run_b(data["10m"][t]))
|
||
|
||
b_extreme = [t for t in b_all if t[4] == "극단"]
|
||
b_normal = [t for t in b_all if t[4] == "일반"]
|
||
|
||
sa = calc_stats(a_all)
|
||
sb = calc_stats(b_all)
|
||
sbe = calc_stats(b_extreme)
|
||
sbn = calc_stats(b_normal)
|
||
|
||
print(f"{'='*65}")
|
||
print(f"{'전략':18} {'거래수':>6} {'승률':>6} {'누적PnL%':>10} {'최대낙폭%':>10}")
|
||
print(f"{'─'*65}")
|
||
print(f"{'A (1h vol-lead)':18} {sa['n']:>6}건 {sa['wr']:>5.1f}% {sa['cum']:>+9.2f}% {-sa['dd']:>+9.2f}%")
|
||
print(f"{'B 전체 (10m)':18} {sb['n']:>6}건 {sb['wr']:>5.1f}% {sb['cum']:>+9.2f}% {-sb['dd']:>+9.2f}%")
|
||
print(f"{' └ 극단거래량':18} {sbe['n']:>6}건 {sbe['wr']:>5.1f}% {sbe['cum']:>+9.2f}% {-sbe['dd']:>+9.2f}%")
|
||
print(f"{' └ 일반vol-lead':18} {sbn['n']:>6}건 {sbn['wr']:>5.1f}% {sbn['cum']:>+9.2f}% {-sbn['dd']:>+9.2f}%")
|
||
print(f"{'='*65}")
|
||
|
||
# 극단 거래량 진입 상세
|
||
if b_extreme:
|
||
print(f"\n── 극단거래량 진입 상세 ({len(b_extreme)}건) ─────────────────────")
|
||
print(f" {'매수시각':<18} {'PnL%':>7} {'청산'}")
|
||
for t in sorted(b_extreme, key=lambda x: x[2])[:20]:
|
||
mark = "✅" if t[0] else "❌"
|
||
print(f" {str(t[2])[:16]:<18} {t[1]:>+6.1f}% {mark} {t[4]}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|