"""종목별 패턴 메모리 시스템 (stock_pattern_memory.py) 각 종목의 과거 행동 패턴을 학습하고 기억하여 매매 판단에 활용한다. - 지지/저항선 자동 탐지 (피봇 포인트 기반) - RSI 반등 통계 (과거 반등 시 평균/최소 RSI) - 거래량 급증 기준 자동 산출 - 종목별 동적 RSI 임계값, 동적 손절폭 - 매매 이력 기반 종목 성격 프로필 데이터 소스: - stock_daily_prices 테이블 (일봉 OHLCV) - trade_history 테이블 (매매 이력) - Redis 캐시 (TTL 24시간) CLAUDE.md 준수: - DB 읽기: AsyncSessionLocal 직접 사용 (SELECT only) - DB 쓰기: 없음 (Redis 캐시만 사용) - JSON 직렬화: _dumps() 사용 - 설정: settings 싱글톤 참조 - 기존 함수 시그니처 변경 없음 """ import logging import math import statistics from dataclasses import dataclass, field, asdict from datetime import datetime, date, timedelta from typing import Optional, List, Dict, Tuple from app.core.config import settings from app.core.timeutil import now_kst logger = logging.getLogger(__name__) # ═════════════════════════════════════════════════════════════════════════════ # 1. StockProfile 데이터클래스 # ═════════════════════════════════════════════════════════════════════════════ @dataclass class StockProfile: """종목별 행동 패턴 프로필. build_stock_profile()로 생성되어 Redis에 캐시된다. 매매 판단 시 get_entry_confidence_boost() 등을 통해 활용. """ stock_code: str stock_name: str = "" # ── 가격 패턴 ── support_levels: List[float] = field(default_factory=list) resistance_levels: List[float] = field(default_factory=list) avg_daily_range_pct: float = 0.0 # 평균 일일 변동폭 (%) volatility_percentile: float = 50.0 # 변동성 백분위 (0~100) # ── RSI 패턴 ── rsi_bounce_avg: float = 30.0 # 과거 반등 시 평균 RSI rsi_bounce_min: float = 20.0 # 반등 최저 RSI rsi_top_avg: float = 70.0 # 과거 고점 시 평균 RSI # ── 거래량 패턴 ── volume_spike_threshold: float = 2.0 # 종목 고유 거래량 급증 기준 (배수) avg_volume_20d: float = 0.0 # ── 매매 이력 패턴 ── win_rate: float = 0.0 avg_holding_days: float = 0.0 best_entry_rsi: float = 30.0 # 최고 수익 매매의 진입 RSI avg_profit_pct: float = 0.0 avg_loss_pct: float = 0.0 consecutive_loss_count: int = 0 total_trades: int = 0 # 역대 총 매매 횟수 # ── 시장 상관관계 ── beta: float = 1.0 sector: str = "" last_updated: str = "" # ISO format string (Redis 직렬화 호환) def to_dict(self) -> dict: """Redis 저장용 dict 변환.""" return asdict(self) @classmethod def from_dict(cls, data: dict) -> "StockProfile": """Redis 로드용 dict → StockProfile 복원.""" # dataclass 필드에 없는 키는 무시 valid_keys = {f.name for f in cls.__dataclass_fields__.values()} filtered = {k: v for k, v in data.items() if k in valid_keys} return cls(**filtered) # ═════════════════════════════════════════════════════════════════════════════ # 2. 패턴 분석 함수 (순수 로직 — DB/IO 없음) # ═════════════════════════════════════════════════════════════════════════════ def analyze_support_resistance( closes: List[float], highs: List[float], lows: List[float], window: int = 60, pivot_lookback: int = 5, cluster_pct: float = 0.02, ) -> Tuple[List[float], List[float]]: """피봇 포인트 기반 지지/저항선 자동 탐지. 알고리즘: 1. 최근 window일 데이터에서 로컬 최저점/최고점(pivot) 탐지 2. pivot_lookback일 양쪽으로 최저/최고인 포인트 = 피봇 3. 가까운 피봇끼리 클러스터링 (cluster_pct 이내) 4. 클러스터 중심값이 지지선/저항선 Args: closes: 종가 리스트 (오래된 것부터) highs: 고가 리스트 lows: 저가 리스트 window: 분석 기간 (일) pivot_lookback: 피봇 판별 좌우 범위 cluster_pct: 클러스터링 기준 (2% 이내 → 동일 레벨) Returns: (support_levels, resistance_levels) 각각 최대 5개 """ if len(closes) < window or len(highs) < window or len(lows) < window: return [], [] # 최근 window일만 사용 c = closes[-window:] h = highs[-window:] lo = lows[-window:] n = len(c) # 피봇 탐지 support_pivots: List[float] = [] resistance_pivots: List[float] = [] for i in range(pivot_lookback, n - pivot_lookback): # 로컬 최저점 (지지) is_low_pivot = all(lo[i] <= lo[i - j] for j in range(1, pivot_lookback + 1)) and \ all(lo[i] <= lo[i + j] for j in range(1, pivot_lookback + 1)) if is_low_pivot: support_pivots.append(lo[i]) # 로컬 최고점 (저항) is_high_pivot = all(h[i] >= h[i - j] for j in range(1, pivot_lookback + 1)) and \ all(h[i] >= h[i + j] for j in range(1, pivot_lookback + 1)) if is_high_pivot: resistance_pivots.append(h[i]) # 클러스터링 (가까운 피봇끼리 묶기) def _cluster(pivots: List[float], max_levels: int = 5) -> List[float]: if not pivots: return [] sorted_p = sorted(pivots) clusters: List[List[float]] = [[sorted_p[0]]] for p in sorted_p[1:]: # 마지막 클러스터 중심과 비교 center = statistics.mean(clusters[-1]) if abs(p - center) / center < cluster_pct: clusters[-1].append(p) else: clusters.append([p]) # 터치 횟수(클러스터 크기) 기준 정렬 → 빈번히 테스트된 레벨 우선 clusters.sort(key=lambda cl: len(cl), reverse=True) return [round(statistics.mean(cl), 2) for cl in clusters[:max_levels]] supports = _cluster(support_pivots) resistances = _cluster(resistance_pivots) return supports, resistances def analyze_rsi_bounce_history( closes: List[float], rsi_period: int = 14, bounce_threshold: float = 5.0, ) -> Tuple[float, float, float]: """과거 RSI 반등 통계 분석. RSI가 저점을 찍고 bounce_threshold 이상 반등한 시점의 RSI 저점을 수집. Args: closes: 종가 리스트 (최소 rsi_period + 30일 이상) rsi_period: RSI 계산 기간 bounce_threshold: 반등으로 판별하는 최소 RSI 상승폭 Returns: (rsi_bounce_avg, rsi_bounce_min, rsi_top_avg) 데이터 부족 시 기본값 (30.0, 20.0, 70.0) """ if len(closes) < rsi_period + 20: return 30.0, 20.0, 70.0 # 전체 RSI 시리즈 계산 rsi_series = _calculate_rsi_series(closes, rsi_period) if len(rsi_series) < 10: return 30.0, 20.0, 70.0 # 반등 저점 탐지: RSI가 50 미만에서 로컬 최저 → 이후 bounce_threshold 이상 상승 bounce_lows: List[float] = [] top_highs: List[float] = [] for i in range(1, len(rsi_series) - 1): # 반등 저점 if rsi_series[i] < 50 and rsi_series[i] <= rsi_series[i - 1]: # 이후 5일 내 bounce_threshold 이상 상승하는지 확인 future_max = max(rsi_series[i + 1: min(i + 6, len(rsi_series))]) if future_max - rsi_series[i] >= bounce_threshold: bounce_lows.append(rsi_series[i]) # 고점 (과매수 영역) if rsi_series[i] > 50 and rsi_series[i] >= rsi_series[i - 1]: future_min = min(rsi_series[i + 1: min(i + 6, len(rsi_series))]) if rsi_series[i] - future_min >= bounce_threshold: top_highs.append(rsi_series[i]) bounce_avg = statistics.mean(bounce_lows) if bounce_lows else 30.0 bounce_min = min(bounce_lows) if bounce_lows else 20.0 top_avg = statistics.mean(top_highs) if top_highs else 70.0 return round(bounce_avg, 1), round(bounce_min, 1), round(top_avg, 1) def analyze_volume_pattern( volumes: List[float], lookback: int = 60, ) -> Tuple[float, float]: """거래량 급증 기준 자동 산출. 종목마다 평소 거래량 분산이 다르므로, 상위 10% 거래량의 배수를 기준으로 삼는다. Args: volumes: 거래량 리스트 (최소 20일) lookback: 분석 기간 Returns: (volume_spike_threshold, avg_volume_20d) volume_spike_threshold: 해당 종목의 급증 기준 배수 """ if len(volumes) < 20: return 2.0, 0.0 recent = volumes[-lookback:] if len(volumes) >= lookback else volumes avg_20d = statistics.mean(recent[-20:]) if len(recent) >= 20 else statistics.mean(recent) if avg_20d <= 0: return 2.0, 0.0 # 상위 10% 거래량의 평균 / 전체 평균 = 해당 종목의 급증 기준 sorted_vol = sorted(recent, reverse=True) top_10pct_count = max(1, len(sorted_vol) // 10) top_10pct_avg = statistics.mean(sorted_vol[:top_10pct_count]) spike_threshold = round(top_10pct_avg / avg_20d, 2) if avg_20d > 0 else 2.0 # 최소 1.5배, 최대 5.0배로 클램프 spike_threshold = max(1.5, min(5.0, spike_threshold)) return spike_threshold, round(avg_20d, 0) def calculate_stock_beta( stock_closes: List[float], index_closes: List[float], period: int = 60, ) -> float: """종목 베타 계산 (코스피200 대비). Beta = Cov(stock_returns, index_returns) / Var(index_returns) Args: stock_closes: 종목 종가 리스트 index_closes: 지수 종가 리스트 (같은 기간) period: 계산 기간 Returns: 베타 값 (데이터 부족 시 1.0) """ min_len = min(len(stock_closes), len(index_closes), period + 1) if min_len < 20: return 1.0 # 수익률 계산 s = stock_closes[-min_len:] idx = index_closes[-min_len:] s_ret = [] i_ret = [] for i in range(1, len(s)): if s[i - 1] > 0 and idx[i - 1] > 0: s_ret.append((s[i] - s[i - 1]) / s[i - 1]) i_ret.append((idx[i] - idx[i - 1]) / idx[i - 1]) if len(s_ret) < 10: return 1.0 mean_s = statistics.mean(s_ret) mean_i = statistics.mean(i_ret) cov = sum((sr - mean_s) * (ir - mean_i) for sr, ir in zip(s_ret, i_ret)) / len(s_ret) var_i = sum((ir - mean_i) ** 2 for ir in i_ret) / len(i_ret) if var_i == 0: return 1.0 return round(cov / var_i, 2) def _calculate_rsi_series(closes: List[float], period: int = 14) -> List[float]: """전체 RSI 시리즈 계산 (반등 통계용). Wilder 평활법으로 정확한 RSI 시리즈를 생성한다. """ if len(closes) < period + 1: return [] deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))] gains = [max(d, 0) for d in deltas] losses = [max(-d, 0) for d in deltas] # 초기값 (SMA) avg_gain = statistics.mean(gains[:period]) avg_loss = statistics.mean(losses[:period]) rsi_vals: List[float] = [] for i in range(period, len(deltas)): avg_gain = (avg_gain * (period - 1) + gains[i]) / period avg_loss = (avg_loss * (period - 1) + losses[i]) / period if avg_loss == 0: rsi_vals.append(100.0) else: rs = avg_gain / avg_loss rsi_vals.append(round(100 - 100 / (1 + rs), 2)) return rsi_vals def _calculate_daily_range_pct( highs: List[float], lows: List[float], closes: List[float], lookback: int = 20, ) -> float: """평균 일일 변동폭 (%) 계산. (High - Low) / Close 의 평균. """ n = min(len(highs), len(lows), len(closes), lookback) if n < 5: return 0.0 ranges = [] for i in range(-n, 0): if closes[i] > 0: ranges.append((highs[i] - lows[i]) / closes[i] * 100) return round(statistics.mean(ranges), 2) if ranges else 0.0 # ═════════════════════════════════════════════════════════════════════════════ # 3. 매매 판단 활용 함수 # ═════════════════════════════════════════════════════════════════════════════ def get_dynamic_rsi_threshold(profile: StockProfile) -> float: """종목별 동적 RSI 임계값 반환. 고정 RSI < 40 대신, 해당 종목의 역대 반등 RSI 기반으로 동적 조정. - 반등 평균 RSI에 마진(+5)을 더한 값 - 최소 settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD(30), 최대 settings.MEAN_REVERSION_RSI_THRESHOLD(40) 범위 예: 삼성전자 반등 RSI 평균 32 → threshold 37 에코프로 반등 RSI 평균 22 → threshold 30 (하한 클램프) """ if profile.total_trades < 3: # 매매 이력 부족 → 기본값 사용 return settings.MEAN_REVERSION_RSI_THRESHOLD dynamic = profile.rsi_bounce_avg + 5.0 lower = settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD # 30 upper = settings.MEAN_REVERSION_RSI_THRESHOLD # 40 return round(max(lower, min(upper, dynamic)), 1) def get_dynamic_stop_loss(profile: StockProfile) -> float: """종목 변동성에 따른 동적 손절 (%). CLAUDE.md 5.2: 안정적 종목 -6%, 변동성 종목 -10% 변동성 백분위 기반 연속 보간: - 하위 20%: -5% (좁은 손절) - 상위 20%: -10% (넓은 손절) - 중간: 선형 보간 Returns: 손절 퍼센트 (음수, 예: -6.0) """ vp = profile.volatility_percentile if vp <= 20: return -5.0 elif vp >= 80: return -10.0 else: # 20~80 구간 선형 보간: -5 ~ -10 ratio = (vp - 20) / 60.0 # 0.0 ~ 1.0 return round(-5.0 - 5.0 * ratio, 1) def is_near_support(profile: StockProfile, current_price: float, margin_pct: float = 0.03) -> bool: """현재가가 지지선 근처(3% 이내)인지 판별. 지지선 근처 = 진입에 유리한 구간 (반등 가능성 높음). """ if not profile.support_levels or current_price <= 0: return False for level in profile.support_levels: if level <= 0: continue distance_pct = (current_price - level) / level if -margin_pct <= distance_pct <= margin_pct: return True return False def is_near_resistance(profile: StockProfile, current_price: float, margin_pct: float = 0.03) -> bool: """현재가가 저항선 근처(3% 이내)인지 판별. 저항선 근처 = 추가 상승 제한 가능성 (진입 감점). """ if not profile.resistance_levels or current_price <= 0: return False for level in profile.resistance_levels: if level <= 0: continue distance_pct = (current_price - level) / level if -margin_pct <= distance_pct <= margin_pct: return True return False def get_entry_confidence_boost( profile: StockProfile, rsi: Optional[float], current_price: float, current_volume: float, ) -> float: """패턴 기반 진입 신뢰도 조정값 반환. 여러 패턴 신호를 종합하여 -0.3 ~ +0.3 범위의 보정값을 산출한다. trade_executor의 포지션 사이징에 곱셈 계수(1 + boost)로 적용. 양수(+) = 유리한 조건 → 포지션 확대 음수(-) = 불리한 조건 → 포지션 축소 요소별 가중치: 지지선 근처: +0.10 저항선 근처: -0.10 RSI 반등 패턴 일치: +0.10 높은 승률(>60%): +0.05 낮은 승률(<30%): -0.10 거래량 급증: +0.05 """ boost = 0.0 # 1. 지지/저항선 판단 if is_near_support(profile, current_price): boost += 0.10 logger.debug(f"[PATTERN] {profile.stock_code}: 지지선 근처 → +0.10") if is_near_resistance(profile, current_price): boost -= 0.10 logger.debug(f"[PATTERN] {profile.stock_code}: 저항선 근처 → -0.10") # 2. RSI 반등 패턴 일치 여부 if rsi is not None and profile.total_trades >= 3: # 현재 RSI가 과거 반등 RSI 범위(bounce_min ~ bounce_avg + 5) 안에 있으면 가점 if profile.rsi_bounce_min <= rsi <= profile.rsi_bounce_avg + 5: boost += 0.10 logger.debug( f"[PATTERN] {profile.stock_code}: RSI({rsi:.1f}) 반등 패턴 범위 " f"[{profile.rsi_bounce_min:.1f}~{profile.rsi_bounce_avg + 5:.1f}] 일치 → +0.10" ) # 3. 승률 기반 조정 if profile.total_trades >= 5: if profile.win_rate >= 0.60: boost += 0.05 elif profile.win_rate < 0.30: boost -= 0.10 logger.debug(f"[PATTERN] {profile.stock_code}: 낮은 승률({profile.win_rate:.0%}) → -0.10") # 4. 거래량 급증 확인 if profile.avg_volume_20d > 0 and current_volume > 0: vol_ratio = current_volume / profile.avg_volume_20d if vol_ratio >= profile.volume_spike_threshold: boost += 0.05 logger.debug( f"[PATTERN] {profile.stock_code}: 거래량 급증({vol_ratio:.1f}x >= " f"{profile.volume_spike_threshold:.1f}x) → +0.05" ) # 클램프: -0.3 ~ +0.3 result = round(max(-0.3, min(0.3, boost)), 2) if abs(result) > 0: logger.info( f"[PATTERN_MEMORY] {profile.stock_code}/{profile.stock_name}: " f"confidence_boost={result:+.2f} (support={is_near_support(profile, current_price)}, " f"resistance={is_near_resistance(profile, current_price)}, " f"rsi={rsi}, win_rate={profile.win_rate:.0%})" ) return result # ═════════════════════════════════════════════════════════════════════════════ # 4. 데이터 로드 및 프로필 빌드 (async — DB/Redis 접근) # ═════════════════════════════════════════════════════════════════════════════ _REDIS_KEY_PREFIX = "stock_profile:" _REDIS_TTL = 86400 # 24시간 async def _load_daily_prices(stock_code: str, days: int = 120) -> Optional[Dict]: """stock_daily_prices 테이블에서 일봉 데이터 로드. Returns: { "closes": [...], "highs": [...], "lows": [...], "volumes": [...], "stock_name": str } 데이터 부족 시 None """ try: from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice from sqlalchemy import select, desc async with AsyncSessionLocal() as session: cutoff = date.today() - timedelta(days=days) result = await session.execute( select(StockDailyPrice) .where( StockDailyPrice.stock_code == stock_code, StockDailyPrice.trade_date >= cutoff, ) .order_by(StockDailyPrice.trade_date.asc()) ) rows = result.scalars().all() if len(rows) < 20: return None return { "closes": [float(r.close_price or 0) for r in rows], "highs": [float(r.high_price or 0) for r in rows], "lows": [float(r.low_price or 0) for r in rows], "volumes": [float(r.volume or 0) for r in rows], "stock_name": rows[-1].stock_name or "", } except Exception as e: logger.warning(f"[PATTERN_MEMORY] {stock_code} 일봉 로드 실패: {e}") return None async def _load_trade_history_stats(stock_code: str) -> Dict: """trade_history 테이블에서 매매 이력 통계 로드. Returns: { "win_rate": float, "avg_holding_days": float, "best_entry_rsi": float, "avg_profit_pct": float, "avg_loss_pct": float, "consecutive_loss_count": int, "total_trades": int } """ default = { "win_rate": 0.0, "avg_holding_days": 0.0, "best_entry_rsi": 30.0, "avg_profit_pct": 0.0, "avg_loss_pct": 0.0, "consecutive_loss_count": 0, "total_trades": 0, } try: from app.core.database import AsyncSessionLocal from app.models.models import TradeHistory from sqlalchemy import select, and_ async with AsyncSessionLocal() as session: # 체결된 매도 기록만 조회 (수익률 계산용) result = await session.execute( select(TradeHistory) .where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), ) .order_by(TradeHistory.executed_at.asc()) ) sell_trades = result.scalars().all() # 매수 기록 (RSI 정보 추출용) buy_result = await session.execute( select(TradeHistory) .where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status.in_(["COMPLETED", "EXECUTED"]), ) .order_by(TradeHistory.executed_at.asc()) ) buy_trades = buy_result.scalars().all() if not sell_trades: return default # 매매별 수익률 계산 profits: List[float] = [] losses_list: List[float] = [] holding_days_list: List[float] = [] best_profit = -999.0 best_entry_rsi = 30.0 # 매수-매도 매칭 (entry_trade_id 또는 시간 순서 기반) buy_map = {} # entry_trade_id → TradeHistory for bt in buy_trades: buy_map[bt.id] = bt consecutive_loss = 0 max_consecutive_loss = 0 for st in sell_trades: entry_price = 0.0 entry_time = None entry_rsi = None # entry_trade_id로 매칭 if st.entry_trade_id and st.entry_trade_id in buy_map: bt = buy_map[st.entry_trade_id] entry_price = float(bt.executed_price or bt.order_price or 0) entry_time = bt.executed_at or bt.created_at # signals에서 RSI 추출 시도 if bt.signals and isinstance(bt.signals, dict): entry_rsi = bt.signals.get("rsi") elif bt.signals and isinstance(bt.signals, list): for sig in bt.signals: if isinstance(sig, str) and "RSI" in sig: # "RSI(28.5)<40" 형식에서 숫자 추출 try: import re match = re.search(r"RSI\(([0-9.]+)\)", sig) if match: entry_rsi = float(match.group(1)) except Exception: pass sell_price = float(st.executed_price or st.order_price or 0) if entry_price <= 0 or sell_price <= 0: continue pnl_pct = (sell_price - entry_price) / entry_price * 100 if pnl_pct > 0: profits.append(pnl_pct) consecutive_loss = 0 if pnl_pct > best_profit: best_profit = pnl_pct if entry_rsi is not None: best_entry_rsi = entry_rsi else: losses_list.append(pnl_pct) consecutive_loss += 1 max_consecutive_loss = max(max_consecutive_loss, consecutive_loss) # 보유 기간 if entry_time and (st.executed_at or st.created_at): sell_time = st.executed_at or st.created_at days_held = (sell_time - entry_time).total_seconds() / 86400 if days_held >= 0: holding_days_list.append(days_held) total = len(profits) + len(losses_list) return { "win_rate": round(len(profits) / total, 3) if total > 0 else 0.0, "avg_holding_days": round(statistics.mean(holding_days_list), 1) if holding_days_list else 0.0, "best_entry_rsi": round(best_entry_rsi, 1), "avg_profit_pct": round(statistics.mean(profits), 2) if profits else 0.0, "avg_loss_pct": round(statistics.mean(losses_list), 2) if losses_list else 0.0, "consecutive_loss_count": consecutive_loss, # 현재 연속 손실 (마지막 기준) "total_trades": total, } except Exception as e: logger.warning(f"[PATTERN_MEMORY] {stock_code} 매매 이력 로드 실패: {e}") return default async def _load_index_closes(days: int = 120) -> List[float]: """코스피200 지수 종가 로드 (베타 계산용). stock_daily_prices에서 코스피200 ETF(069500 KODEX 200) 데이터 사용. """ try: from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice from sqlalchemy import select async with AsyncSessionLocal() as session: cutoff = date.today() - timedelta(days=days) result = await session.execute( select(StockDailyPrice.close_price) .where( StockDailyPrice.stock_code == "069500", # KODEX 200 StockDailyPrice.trade_date >= cutoff, ) .order_by(StockDailyPrice.trade_date.asc()) ) rows = result.scalars().all() return [float(r) for r in rows if r] except Exception as e: logger.debug(f"[PATTERN_MEMORY] 코스피200 지수 로드 실패: {e}") return [] async def build_stock_profile(stock_code: str) -> Optional[StockProfile]: """종목별 프로필 생성 (일봉 + 매매이력 + 지수 데이터 종합). 1. stock_daily_prices에서 일봉 데이터 로드 2. 지지/저항선 분석 3. RSI 반등 통계 분석 4. 거래량 패턴 분석 5. 베타 계산 6. 매매 이력 통계 로드 7. 변동성 백분위 계산 """ daily = await _load_daily_prices(stock_code, days=180) if daily is None: logger.debug(f"[PATTERN_MEMORY] {stock_code}: 일봉 데이터 부족 → 프로필 생성 스킵") return None closes = daily["closes"] highs = daily["highs"] lows = daily["lows"] volumes = daily["volumes"] stock_name = daily["stock_name"] # 지지/저항선 supports, resistances = analyze_support_resistance(closes, highs, lows) # RSI 반등 통계 rsi_bounce_avg, rsi_bounce_min, rsi_top_avg = analyze_rsi_bounce_history(closes) # 거래량 패턴 vol_spike, avg_vol_20d = analyze_volume_pattern(volumes) # 평균 일일 변동폭 avg_range = _calculate_daily_range_pct(highs, lows, closes) # 베타 계산 index_closes = await _load_index_closes(days=180) beta = calculate_stock_beta(closes, index_closes) if index_closes else 1.0 # 매매 이력 통계 trade_stats = await _load_trade_history_stats(stock_code) # 변동성 백분위 (avg_daily_range 기준, 전체 종목 대비는 배치에서 별도 계산) # 단일 종목 빌드 시에는 range 기반 추정값 사용 # 1~2% → 하위, 3~5% → 중간, 5%+ → 상위 if avg_range <= 1.5: vol_pctl = 20.0 elif avg_range <= 3.0: vol_pctl = 50.0 elif avg_range <= 5.0: vol_pctl = 75.0 else: vol_pctl = 90.0 profile = StockProfile( stock_code=stock_code, stock_name=stock_name, support_levels=supports, resistance_levels=resistances, avg_daily_range_pct=avg_range, volatility_percentile=vol_pctl, rsi_bounce_avg=rsi_bounce_avg, rsi_bounce_min=rsi_bounce_min, rsi_top_avg=rsi_top_avg, volume_spike_threshold=vol_spike, avg_volume_20d=avg_vol_20d, win_rate=trade_stats["win_rate"], avg_holding_days=trade_stats["avg_holding_days"], best_entry_rsi=trade_stats["best_entry_rsi"], avg_profit_pct=trade_stats["avg_profit_pct"], avg_loss_pct=trade_stats["avg_loss_pct"], consecutive_loss_count=trade_stats["consecutive_loss_count"], total_trades=trade_stats["total_trades"], beta=beta, sector="", # 별도 섹터 DB 필요 시 확장 last_updated=now_kst().isoformat(), ) return profile # ═════════════════════════════════════════════════════════════════════════════ # 5. Redis 캐시 관리 # ═════════════════════════════════════════════════════════════════════════════ async def save_profile_to_cache(profile: StockProfile) -> bool: """StockProfile을 Redis에 캐시 (TTL 24시간).""" try: from app.core.redis import setex_json key = f"{_REDIS_KEY_PREFIX}{profile.stock_code}" return await setex_json(key, _REDIS_TTL, profile.to_dict()) except Exception as e: logger.debug(f"[PATTERN_MEMORY] Redis 저장 실패 {profile.stock_code}: {e}") return False async def load_profile_from_cache(stock_code: str) -> Optional[StockProfile]: """Redis에서 StockProfile 로드.""" try: from app.core.redis import get_json key = f"{_REDIS_KEY_PREFIX}{stock_code}" data = await get_json(key) if data: return StockProfile.from_dict(data) return None except Exception as e: logger.debug(f"[PATTERN_MEMORY] Redis 로드 실패 {stock_code}: {e}") return None async def get_stock_profile(stock_code: str, force_refresh: bool = False) -> Optional[StockProfile]: """종목 프로필 조회 (캐시 우선, 없으면 빌드 후 캐시). 이것이 외부에서 호출하는 주요 진입점이다. Args: stock_code: 종목 코드 force_refresh: True이면 캐시 무시하고 재빌드 Returns: StockProfile 또는 None (데이터 부족) """ if not force_refresh: cached = await load_profile_from_cache(stock_code) if cached: return cached # 캐시 미스 또는 강제 갱신 → 빌드 profile = await build_stock_profile(stock_code) if profile: await save_profile_to_cache(profile) return profile async def refresh_all_profiles(stock_codes: List[str]) -> int: """전 종목 프로필 일괄 갱신 (일일 배치에서 호출). Args: stock_codes: 갱신할 종목 코드 목록 Returns: 성공적으로 빌드된 프로필 수 """ success_count = 0 for code in stock_codes: try: profile = await build_stock_profile(code) if profile: await save_profile_to_cache(profile) success_count += 1 except Exception as e: logger.debug(f"[PATTERN_MEMORY] {code} 프로필 갱신 실패: {e}") logger.info(f"[PATTERN_MEMORY] 프로필 갱신 완료: {success_count}/{len(stock_codes)}개 성공") return success_count async def refresh_all_profiles_with_percentile(stock_codes: List[str]) -> int: """전 종목 프로필 빌드 후, 변동성 백분위를 상대 순위로 재계산. 배치에서 사용: 전체 종목의 avg_daily_range_pct를 비교하여 정확한 백분위 산출. """ profiles: List[StockProfile] = [] # 1단계: 전체 빌드 for code in stock_codes: try: profile = await build_stock_profile(code) if profile: profiles.append(profile) except Exception as e: logger.debug(f"[PATTERN_MEMORY] {code} 프로필 빌드 실패: {e}") if not profiles: return 0 # 2단계: 변동성 백분위 재계산 (상대 순위) ranges = [p.avg_daily_range_pct for p in profiles] sorted_ranges = sorted(ranges) n = len(sorted_ranges) for p in profiles: # 해당 종목의 순위 → 백분위 rank = sorted_ranges.index(p.avg_daily_range_pct) p.volatility_percentile = round(rank / n * 100, 1) if n > 1 else 50.0 p.last_updated = now_kst().isoformat() # 3단계: 캐시 저장 success = 0 for p in profiles: if await save_profile_to_cache(p): success += 1 logger.info( f"[PATTERN_MEMORY] 프로필 + 백분위 갱신 완료: " f"{success}/{len(stock_codes)}개 성공" ) return success # ═════════════════════════════════════════════════════════════════════════════ # 6. 통합 헬퍼 (trade_executor / mean_reversion_strategy 연동용) # ═════════════════════════════════════════════════════════════════════════════ async def get_pattern_adjusted_invest_amount( stock_code: str, base_invest_amount: float, rsi: Optional[float] = None, current_price: float = 0.0, current_volume: float = 0.0, ) -> float: """패턴 메모리 기반으로 투자금액을 조정. trade_executor._execute_buy()에서 포지션 사이징 후 호출. boost를 곱셈 계수(1 + boost)로 적용하되, CLAUDE.md 5.1 상한(4%) 준수. Args: stock_code: 종목 코드 base_invest_amount: 기본 포지션 사이징 결과 rsi: 현재 RSI (None이면 무시) current_price: 현재가 current_volume: 현재 거래량 Returns: 조정된 투자금액 (항상 >= 0) """ if base_invest_amount <= 0: return base_invest_amount try: profile = await get_stock_profile(stock_code) if not profile: return base_invest_amount boost = get_entry_confidence_boost(profile, rsi, current_price, current_volume) if boost == 0: return base_invest_amount adjusted = base_invest_amount * (1 + boost) # 최소 0, 최대 base의 1.5배 (CLAUDE.md 5.1 4% 상한은 호출측에서 재적용) adjusted = max(0.0, min(adjusted, base_invest_amount * 1.5)) if adjusted != base_invest_amount: logger.info( f"[PATTERN_MEMORY] {stock_code}: 투자금액 조정 " f"{base_invest_amount:,.0f} → {adjusted:,.0f} (boost={boost:+.2f})" ) return adjusted except Exception as e: logger.debug(f"[PATTERN_MEMORY] {stock_code} 투자금액 조정 실패: {e}") return base_invest_amount async def get_pattern_rsi_threshold(stock_code: str) -> float: """종목별 동적 RSI 임계값 반환. mean_reversion_strategy 연동용. 프로필 없으면 기본값(settings.MEAN_REVERSION_RSI_THRESHOLD) 반환. """ try: profile = await get_stock_profile(stock_code) if profile: return get_dynamic_rsi_threshold(profile) except Exception as e: logger.debug(f"[PATTERN_MEMORY] {stock_code} RSI 임계값 조회 실패: {e}") return settings.MEAN_REVERSION_RSI_THRESHOLD async def get_pattern_stop_loss(stock_code: str) -> float: """종목별 동적 손절폭 반환 (%). Returns: 손절 퍼센트 (음수, 예: -6.0). 프로필 없으면 기본값 -6.0. """ try: profile = await get_stock_profile(stock_code) if profile: return get_dynamic_stop_loss(profile) except Exception as e: logger.debug(f"[PATTERN_MEMORY] {stock_code} 손절폭 조회 실패: {e}") return -float(settings.STOP_LOSS_PERCENT)