Files
upbit-trader/core/strategy.py
joungmin 673ce08d84 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>
2026-03-03 10:17:08 +09:00

233 lines
9.3 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.
"""Volume Lead 전략: 거래량 축적(급증+횡보) 감지 후 +TREND_AFTER_VOL% 상승 시 선진입.
흐름:
1. 직전 40분봉 거래량 > 로컬 5h(7봉) 평균 × VOL_MULT AND
2h 가격 변동 < PRICE_QUIET_PCT% (횡보 중 축적)
→ 신호가(signal_price) + 거래량비율(vol_ratio) 기록
2. signal_price 대비 +임계값% 이상 상승 시 진입
임계값은 vol_ratio 강도에 따라 자동 조정 (강한 신호 → 낮은 임계값 → 조기 진입)
- vol_ratio ≥ 5.0x → +1.0%
- vol_ratio ≥ 3.5x → +2.0%
- vol_ratio ≥ 2.5x → +3.0%
- 기본 → +TREND_AFTER_VOL%
3. SIGNAL_TIMEOUT_H 내 임계값 미달 또는 신호가 이하 하락 시 신호 초기화
캔들: minute10 데이터를 40분봉으로 리샘플링하여 사용
"""
from __future__ import annotations
import logging
import os
import time
import pyupbit
from .market import get_current_price
from .market_regime import get_regime
from .notify import notify_signal
from .price_db import get_price_n_hours_ago
logger = logging.getLogger(__name__)
# 축적 감지 파라미터
PRICE_QUIET_PCT = float(os.getenv("PRICE_QUIET_PCT", "2.0")) # 2h 횡보 기준 (%)
TREND_AFTER_VOL = float(os.getenv("TREND_AFTER_VOL", "5.0")) # 진입 임계값 (신호가 대비 %)
SIGNAL_TIMEOUT_H = float(os.getenv("SIGNAL_TIMEOUT_H", "8.0")) # 신호 유효 시간 (h)
# 거래량 파라미터
LOCAL_VOL_CANDLES = 7 # 5h를 40분봉으로 환산 (int(5 * 60/40) = 7)
VOLUME_MULTIPLIER = float(os.getenv("VOLUME_MULTIPLIER", "2.0"))
# 40분봉 리샘플링 파라미터
_CANDLE_MIN = 40
_FETCH_10M = (LOCAL_VOL_CANDLES + 3) * (_CANDLE_MIN // 10) # 40 개의 10분봉
# 신호 강도별 진입 임계값 단계 (vol_ratio 최소값, 진입 임계값%)
# 강한 신호일수록 낮은 임계값으로 조기 진입
_ENTRY_TIERS: list[tuple[float, float]] = [
(5.0, 1.0), # 매우 강한 신호 (≥5x) → +1.0% 즉시 진입
(3.5, 2.0), # 강한 신호 (≥3.5x) → +2.0% 조기 진입
(2.5, 3.0), # 중간 신호 (≥2.5x) → +3.0% 반조기 진입
]
# 위 조건 미충족 시 TREND_AFTER_VOL 사용
# 속도(velocity) 기반 조기 진입 파라미터
# 신호 후 가격이 빠르게 상승 중이면 거리 임계값 도달 전에 선진입
VELOCITY_THRESHOLD = float(os.getenv("VELOCITY_THRESHOLD", "0.10")) # %/분 (0.10 = 6%/h)
VELOCITY_MIN_MOVE = float(os.getenv("VELOCITY_MIN_MOVE", "0.5")) # 최소 이동 % (잡음 제거)
VELOCITY_MIN_AGE_M = float(os.getenv("VELOCITY_MIN_AGE_M", "5.0")) # 최소 경과 시간(분)
def _calc_entry_threshold(vol_ratio: float) -> float:
"""거래량 비율에 따른 진입 임계값 반환. 강한 신호일수록 낮은 값."""
for min_ratio, threshold in _ENTRY_TIERS:
if vol_ratio >= min_ratio:
return threshold
return TREND_AFTER_VOL
def _resample_40m(df):
"""minute10 DataFrame → 40분봉으로 리샘플링."""
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# 축적 신호 상태: ticker → {"price": float, "time": float(unix), "vol_ratio": float}
_accum_signals: dict[str, dict] = {}
def get_active_signals() -> dict[str, dict]:
"""현재 활성화된 신호 딕셔너리 반환 (fast-poll 루프용).
Returns:
{ticker: {"price": float, "time": float, "vol_ratio": float}}
"""
return dict(_accum_signals)
def _check_vol_spike(ticker: str, vol_mult: float) -> bool:
"""직전 완성 40분봉 거래량이 로컬 5h(7봉) 평균의 vol_mult 배 이상인지 확인."""
try:
df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=_FETCH_10M)
except Exception:
return False
if df10 is None or len(df10) < _CANDLE_MIN // 10 * 2:
return False
df = _resample_40m(df10)
if len(df) < LOCAL_VOL_CANDLES + 1:
return False
recent_vol = df["volume"].iloc[-2] # 직전 완성된 40분봉
local_avg = df["volume"].iloc[-(LOCAL_VOL_CANDLES + 1):-2].mean() # 이전 7봉(≈5h) 평균
if local_avg <= 0:
return False
ratio = recent_vol / local_avg
result = ratio >= vol_mult
if result:
logger.debug(
f"[거래량↑] {ticker} 40m={recent_vol:.0f} / 5h평균={local_avg:.0f} ({ratio:.2f}x ≥ {vol_mult}x)"
)
else:
logger.debug(
f"[거래량✗] {ticker} {ratio:.2f}x < {vol_mult}x"
)
return result
def should_buy(ticker: str) -> bool:
"""Volume Lead 전략.
1단계: 거래량 급증 + 2h 횡보 → 신호가 기록
2단계: 신호가 대비 +TREND_AFTER_VOL% 상승 확인 시 진입
"""
regime = get_regime()
vol_mult = regime["vol_mult"]
current = get_current_price(ticker)
if not current:
return False
now = time.time()
# ── 기존 신호 유효성 확인 ────────────────────────────────
sig = _accum_signals.get(ticker)
if sig is not None:
age_h = (now - sig["time"]) / 3600
if age_h > SIGNAL_TIMEOUT_H:
del _accum_signals[ticker]
sig = None
logger.debug(f"[축적타임아웃] {ticker} {age_h:.1f}h 경과 → 신호 초기화")
# ── 신호 없음: 축적 조건 체크 ────────────────────────────
if sig is None:
# 2h 가격 횡보 확인 (DB 가격 활용)
price_2h = get_price_n_hours_ago(ticker, 2)
if price_2h is None:
return False
quiet = abs(current - price_2h) / price_2h * 100 < PRICE_QUIET_PCT
if not quiet:
logger.debug(
f"[횡보✗] {ticker} 2h변동={(current - price_2h) / price_2h * 100:+.1f}% "
f"(기준={PRICE_QUIET_PCT}%)"
)
return False
# 거래량 급증 확인
if not _check_vol_spike(ticker, vol_mult):
return False
# 거래량 비율 계산 후 신호 기록
ratio = 0.0
try:
df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=_FETCH_10M)
df_h = _resample_40m(df10) if df10 is not None else None
if df_h is not None and len(df_h) >= LOCAL_VOL_CANDLES + 1:
recent_vol = df_h["volume"].iloc[-2]
local_avg = df_h["volume"].iloc[-(LOCAL_VOL_CANDLES + 1):-2].mean()
ratio = recent_vol / local_avg if local_avg > 0 else 0.0
except Exception:
pass
entry_thr = _calc_entry_threshold(ratio)
_accum_signals[ticker] = {"price": current, "time": now, "vol_ratio": ratio}
logger.info(
f"[축적감지] {ticker} 거래량 급증 + 2h 횡보 → 신호가={current:,.2f}"
f"(거래량 {ratio:.2f}x → 진입임계={entry_thr:.1f}%)"
)
notify_signal(ticker, current, ratio)
return False # 신호 첫 발생 시는 진입 안 함
# ── 신호 있음: 상승 확인 → 진입 ─────────────────────────
signal_price = sig["price"]
vol_ratio = sig.get("vol_ratio", 0.0)
entry_thr = _calc_entry_threshold(vol_ratio)
move_pct = (current - signal_price) / signal_price * 100
age_min = (now - sig["time"]) / 60
if current < signal_price:
# 신호가 이하 하락 → 축적 실패
del _accum_signals[ticker]
logger.debug(
f"[축적실패] {ticker} 신호가={signal_price:,.2f} → 현재={current:,.2f} (하락) → 초기화"
)
return False
# ── 거리 기반 진입 ─────────────────────────────────────
if move_pct >= entry_thr:
del _accum_signals[ticker]
logger.info(
f"[축적진입] {ticker} 신호가={signal_price:,.2f}원 → 현재={current:,.2f}"
f"(+{move_pct:.1f}% ≥ {entry_thr:.1f}% | 거래량={vol_ratio:.2f}x)"
)
return True
# ── 속도 기반 조기 진입 ────────────────────────────────
# 신호 후 N분 이상 경과 + 최소 이동 + 분당 상승률 기준 충족 시 선진입
if age_min >= VELOCITY_MIN_AGE_M and move_pct >= VELOCITY_MIN_MOVE:
velocity = move_pct / age_min # %/분
if velocity >= VELOCITY_THRESHOLD:
del _accum_signals[ticker]
logger.info(
f"[속도진입] {ticker} 신호가={signal_price:,.2f}원 → 현재={current:,.2f}"
f"(+{move_pct:.1f}% | {velocity:.3f}%/분 ≥ {VELOCITY_THRESHOLD}%/분 | "
f"경과={age_min:.1f}분 | 거래량={vol_ratio:.2f}x)"
)
return True
logger.debug(
f"[축적대기] {ticker} 신호가={signal_price:,.2f} 현재={current:,.2f} "
f"(+{move_pct:.1f}% / 목표={entry_thr:.1f}% | "
f"속도={move_pct/age_min:.3f}%/분 | 경과={age_min:.1f}분)"
if age_min > 0 else
f"[축적대기] {ticker} 신호가={signal_price:,.2f} 현재={current:,.2f} "
f"(+{move_pct:.1f}% / 목표={entry_thr:.1f}%)"
)
return False