feat: add velocity entry, fast-poll thread, tighten BEAR threshold

- Add velocity-based entry signal in strategy.py (VELOCITY_THRESHOLD=0.10,
  VELOCITY_MIN_MOVE=0.5%, VELOCITY_MIN_AGE_M=5)
- Add fast-poll thread in daemon/runner.py (SIGNAL_POLL_INTERVAL=15s)
  for sub-minute velocity event detection
- Add vol_ratio tiered condition and get_active_signals() to strategy.py
- Change BEAR_THRESHOLD -1.0 → -0.5 in market_regime.py to catch
  slow downtrends earlier (weighted 2h score)
- Expand sell_reason VARCHAR2(500) in price_db.py DDL
- Add velocity_backtest.py and sim10m.py for strategy experimentation
- Update STRATEGY.md: correct regime algorithm description (weighted 2h
  score, not BTC 1h ±5%), add fast-poll/velocity sections, add backtest
  section D, add change history table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-03 10:17:08 +09:00
parent 612055162e
commit 673ce08d84
7 changed files with 986 additions and 36 deletions

308
sim10m.py Normal file
View File

@@ -0,0 +1,308 @@
"""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()