"""Volume Lead 전략: 10분봉 거래량 급증 + 횡보 감지 후 +THRESH% 상승 시 진입. 흐름: 1. 직전 완성 10분봉 거래량 > 로컬 LV봉(280분) 평균 × VOL_THRESH AND QN봉(120분) 이전 종가 대비 가격 변동 < PRICE_QUIET_PCT% (횡보 중 축적) → 신호가(signal_price) + 거래량비율(vol_ratio) 기록 * 더 강한 vol(> 기존 sig vol_ratio)이 오면 sig_p 갱신 2. signal_price 대비 +TREND_AFTER_VOL%(4.8%) 이상 상승 시 진입 3. 신호불사: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정, 만료까지 대기) 4. SIGNAL_TIMEOUT_MIN(480분=8h) 초과 시 신호 초기화 거래량 임계값 + 진입 차단 (F&G 기반 3구간): - F&G ≤ FNG_FEAR_THRESHOLD(40): VOL_THRESH_FEAR(6.0x) ← 공포/극공포 - F&G 41 ~ FNG_MAX_ENTRY(50): VOL_THRESH_NORMAL(5.0x) ← 중립 - F&G > FNG_MAX_ENTRY(50): 진입 차단 ← 탐욕/극탐욕 캔들: minute10 데이터 직접 사용 (40분봉 리샘플링 없음) """ from __future__ import annotations import logging import os import time import pyupbit from .fng import get_fng from .market import get_current_price from .notify import notify_signal, notify_watch logger = logging.getLogger(__name__) # 10분봉 직접 사용 파라미터 LOCAL_VOL_CANDLES = int(os.getenv("LOCAL_VOL_CANDLES", "28")) # 로컬 vol 평균 구간 (280분) QUIET_CANDLES = int(os.getenv("QUIET_CANDLES", "12")) # 횡보 체크 구간 (120분) PRICE_QUIET_PCT = float(os.getenv("PRICE_QUIET_PCT", "2.0")) # 횡보 기준 (%) TREND_AFTER_VOL = float(os.getenv("TREND_AFTER_VOL", "4.8")) # 진입 임계값 (신호가 대비 %) SIGNAL_TIMEOUT_MIN = int(os.getenv("SIGNAL_TIMEOUT_MIN", "480")) # 신호 유효 시간 (분=8h) # F&G 기반 거래량 임계값 + 진입 차단 VOL_THRESH_NORMAL = float(os.getenv("VOL_THRESH_NORMAL", "5.0")) # 중립 구간 (F&G 41~FNG_MAX_ENTRY) VOL_THRESH_FEAR = float(os.getenv("VOL_THRESH_FEAR", "6.0")) # 공포/극공포 (F&G ≤ FNG_FEAR_THRESHOLD) FNG_FEAR_THRESHOLD = int(os.getenv("FNG_FEAR_THRESHOLD", "40")) # 공포 기준 (이하 → FEAR 임계값) FNG_MAX_ENTRY = int(os.getenv("FNG_MAX_ENTRY", "50")) # 진입 허용 최대 F&G (초과 → 차단) # 관찰 알림 (신호 임계값에 근접했지만 미달인 종목) WATCH_VOL_THRESH = float(os.getenv("WATCH_VOL_THRESH", "4.0")) # 관찰 시작 임계값 WATCH_COOLDOWN_MIN = int(os.getenv("WATCH_COOLDOWN_MIN", "30")) # 같은 종목 재알림 최소 간격 (분) WATCH_VOL_JUMP = float(os.getenv("WATCH_VOL_JUMP", "0.5")) # 쿨다운 무시 vol 상승폭 # 10분봉 조회 수 _FETCH_10M = LOCAL_VOL_CANDLES + QUIET_CANDLES + 5 # 45봉 # 축적 신호 상태: ticker → {"price": float, "time": float(unix), "vol_ratio": float} _accum_signals: dict[str, dict] = {} # 관찰 알림 상태: ticker → {"time": float, "vol_ratio": float} _watch_notified: 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 should_buy(ticker: str) -> bool: """Volume Lead 전략 (10분봉 직접 감지). 1단계: F&G 값으로 vol 임계값 동적 설정 (≤40→6x, >40→5x) 2단계: 10분봉 거래량 급증 + QN봉 횡보 → 신호가 기록 (더 강한 vol이면 갱신) 3단계: 신호가 대비 +TREND_AFTER_VOL% 상승 확인 시 진입 신호불사: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정) """ fng = get_fng() # F&G 탐욕/극탐욕 구간 진입 차단 if fng > FNG_MAX_ENTRY: logger.debug(f"[F&G차단] {ticker} F&G={fng} > {FNG_MAX_ENTRY} (탐욕) → 진입 금지") return False # F&G 구간별 vol 임계값 vth = VOL_THRESH_FEAR if fng <= FNG_FEAR_THRESHOLD else VOL_THRESH_NORMAL current = get_current_price(ticker) if not current: return False now = time.time() # ── 신호 만료 체크 ──────────────────────────────────── sig = _accum_signals.get(ticker) if sig is not None: age_min = (now - sig["time"]) / 60 if age_min > SIGNAL_TIMEOUT_MIN: del _accum_signals[ticker] sig = None logger.debug(f"[축적타임아웃] {ticker} {age_min:.0f}분 경과 → 신호 초기화") # ── 10분봉 데이터 조회 ──────────────────────────────── try: df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=_FETCH_10M) except Exception: return False if df10 is None or len(df10) < LOCAL_VOL_CANDLES + QUIET_CANDLES: return False # ── 거래량 비율 계산 (직전 완성봉 기준) ─────────────── vol_prev = float(df10["volume"].iloc[-2]) vol_avg = float(df10["volume"].iloc[-(LOCAL_VOL_CANDLES + 2):-2].mean()) vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0 # ── 횡보 체크 (QN봉 이전 종가 기준) ────────────────── close_qn = float(df10["close"].iloc[-(QUIET_CANDLES + 1)]) chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0 # ── 관찰 알림: WATCH_VOL_THRESH ≤ vol_r < vth + 횡보 ── if WATCH_VOL_THRESH <= vol_r < vth and chg < PRICE_QUIET_PCT: prev = _watch_notified.get(ticker) age_min = (now - prev["time"]) / 60 if prev else 999.0 vol_jump = vol_r - prev["vol_ratio"] if prev else vol_r if prev is None or age_min >= WATCH_COOLDOWN_MIN or vol_jump >= WATCH_VOL_JUMP: _watch_notified[ticker] = {"time": now, "vol_ratio": vol_r} logger.info( f"[관찰] {ticker} vol={vol_r:.2f}x (신호기준={vth:.1f}x) + 횡보({chg:.1f}%) | F&G={fng}" ) notify_watch(ticker, current, vol_r, vth, chg, fng=fng) elif vol_r < WATCH_VOL_THRESH: _watch_notified.pop(ticker, None) # ── vol 스파이크 + 횡보 → 신호 설정/갱신 ──────────── if vol_r >= vth and chg < PRICE_QUIET_PCT: if sig is None or vol_r > sig.get("vol_ratio", 0.0): _accum_signals[ticker] = {"price": current, "time": now, "vol_ratio": vol_r} sig = _accum_signals[ticker] logger.info( f"[축적감지] {ticker} 10m vol={vol_r:.2f}x ≥ {vth:.1f}x + 횡보({chg:.1f}%) " f"→ 신호가={current:,.2f}원 (F&G={fng})" ) notify_signal(ticker, current, vol_r, fng=fng) if sig is None: logger.debug( f"[축적✗] {ticker} vol={vol_r:.2f}x (기준={vth:.1f}x) / 횡보={chg:.1f}%" ) return False # ── 진입 확인: 신호가 대비 +TREND_AFTER_VOL% 이상 ── signal_price = sig["price"] vol_ratio = sig["vol_ratio"] move_pct = (current - signal_price) / signal_price * 100 age_min = (now - sig["time"]) / 60 if move_pct >= TREND_AFTER_VOL: del _accum_signals[ticker] logger.info( f"[축적진입] {ticker} 신호가={signal_price:,.2f}원 → 현재={current:,.2f}원 " f"(+{move_pct:.1f}% ≥ {TREND_AFTER_VOL}% | 거래량={vol_ratio:.2f}x | F&G={fng})" ) return True # 신호불사: 가격 하락해도 신호 유지 (sig_p 고정, 만료까지 대기) logger.debug( f"[축적대기] {ticker} 신호가={signal_price:,.2f} 현재={current:,.2f} " f"({move_pct:+.1f}% / 목표={TREND_AFTER_VOL}% | " f"거래량={vol_ratio:.2f}x | 경과={age_min:.0f}분)" ) return False