Files
upbit-trader/tests/sim10m.py
joungmin 6b2c962ed8 refactor: reorganize project structure into tests/, data/, logs/
- 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>
2026-03-03 16:08:50 +09:00

309 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()