시간대 혼재: 모델 기본값은 datetime.utcnow(UTC), 수동 매매 등 일부 경로는 _get_kst_now(KST)로 저장해 같은 테이블에 9시간 차가 섞였다(화면 매매시각 02:14 등). 조회 구간(utcnow - days)도 KST 저장분과 비교돼 어긋났고, 추가매수 쿨다운은 (UTC now - KST created_at)이 음수라 상시 발동했다. app.core.timeutil.now_kst() 도입 후 모델 기본값 27곳과 app/ 전역 utcnow() 호출을 모두 KST 기준으로 통일(잔존 0건, 테스트로 고정). KIS 토큰 캐시(~/.cache/auto-stock)가 컨테이너에만 있어 배포마다 사라지고 토큰을 새로 발급받다 KIS가 EGW00103(유효하지 않은 AppKey)로 거부 → 502·잔고 0 유발. 볼륨 마운트로 재배포 후에도 재사용. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
999 lines
37 KiB
Python
999 lines
37 KiB
Python
"""종목별 패턴 메모리 시스템 (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)
|