Files
auto-stock/backend/app/services/technical_indicators.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:58:18 +09:00

439 lines
18 KiB
Python
Raw Permalink 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.
"""
기술적 지표 분석 모듈
- 이동평균(MA/EMA), 볼린저 밴드, RSI, MACD, 스토캐스틱, ATR, ADX, OBV 등 계산
- 급등 과열 필터(analyze_overheat): 단기 급등 종목 매수 차단
- 추세 필터(_passes_uptrend): MA 정배열 기반 상승 추세 확인
- 커스텀 신호(analyze_custom_signals): 이격도 필터 포함 이동평균 기반 신호
- 모든 함수는 chart_data(KIS API 응답 리스트) 또는 순수 수치 리스트를 입력으로 받음
"""
import logging
from typing import Optional
from app.core.config import settings
from app.services.mean_reversion_strategy import calculate_adx, calculate_rsi, calculate_atr, calculate_bollinger
# §8.5-1 지표 계산 단일 출처. analyze_* 는 Wilder 가 아닌 SMA 계열을 쓰던 곳이 있어
# 알고리즘 이름이 명시된 함수를 직접 선택해 호출한다(수치 보존).
from app.services.indicators_core import bollinger_sma, rsi_sma
logger = logging.getLogger(__name__)
# Trend filter defaults (configurable via settings)
TREND_FILTER_ENABLED = getattr(settings, "TREND_FILTER_ENABLED", True)
TREND_MA_SHORT = int(getattr(settings, "TREND_MA_SHORT", 20))
TREND_MA_LONG = int(getattr(settings, "TREND_MA_LONG", 60))
TREND_TOLERANCE_PCT = float(getattr(settings, "TREND_TOLERANCE_PCT", 0.005))
# 급등 과열 필터 defaults
OVERHEAT_FILTER_ENABLED = getattr(settings, "OVERHEAT_FILTER_ENABLED", True)
OVERHEAT_3DAY_MAX = float(getattr(settings, "OVERHEAT_3DAY_MAX", 15.0))
OVERHEAT_5DAY_MAX = float(getattr(settings, "OVERHEAT_5DAY_MAX", 20.0))
OVERHEAT_10DAY_MAX = float(getattr(settings, "OVERHEAT_10DAY_MAX", 30.0))
OVERHEAT_DISPARITY_MAX = float(getattr(settings, "OVERHEAT_DISPARITY_MAX", 108.0))
def _ma(values: list, window: int) -> Optional[float]:
"""단순 이동평균(SMA) 계산. 데이터 부족 시 None 반환."""
try:
if len(values) < window:
return None
return sum(values[-window:]) / window
except Exception:
return None
def _ema(values: list, window: int) -> Optional[float]:
"""EMA(Exponential Moving Average) 계산.
SMA 시드 → 이후 지수 가중 평균 순차 적용.
values 길이가 window 미만이면 None 반환.
"""
try:
if len(values) < window:
return None
k = 2.0 / (window + 1)
ema = sum(values[:window]) / window
for v in values[window:]:
ema = v * k + ema * (1 - k)
return ema
except Exception:
return None
def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]:
"""MA 정배열 기반 상승 추세 확인.
MA_SHORT >= MA_LONG AND 현재가 >= MA_SHORT (허용 오차 tol 적용).
데이터 부족 시 None 반환.
"""
if not closes or len(closes) < max(ma_short, ma_long):
return None
ma_s = _ma(closes, ma_short)
ma_l = _ma(closes, ma_long)
close = closes[-1]
if ma_s is None or ma_l is None:
return None
return (ma_s >= ma_l * (1 - tol)) and (close >= ma_s * (1 - tol))
def analyze_overheat(closes: list, ma20: Optional[float]) -> dict:
"""급등 과열 필터 - 단기 급등 종목 매수 차단"""
details = {}
reasons = []
if len(closes) >= 4:
gain_3d = ((closes[-1] - closes[-4]) / closes[-4]) * 100 if closes[-4] > 0 else 0
details["gain_3d"] = round(gain_3d, 1)
if gain_3d > OVERHEAT_3DAY_MAX:
reasons.append(f"3일 +{gain_3d:.1f}% (>{OVERHEAT_3DAY_MAX}%)")
if len(closes) >= 6:
gain_5d = ((closes[-1] - closes[-6]) / closes[-6]) * 100 if closes[-6] > 0 else 0
details["gain_5d"] = round(gain_5d, 1)
if gain_5d > OVERHEAT_5DAY_MAX:
reasons.append(f"5일 +{gain_5d:.1f}% (>{OVERHEAT_5DAY_MAX}%)")
if len(closes) >= 11:
gain_10d = ((closes[-1] - closes[-11]) / closes[-11]) * 100 if closes[-11] > 0 else 0
details["gain_10d"] = round(gain_10d, 1)
if gain_10d > OVERHEAT_10DAY_MAX:
reasons.append(f"10일 +{gain_10d:.1f}% (>{OVERHEAT_10DAY_MAX}%)")
if ma20 and ma20 > 0:
disparity = (closes[-1] / ma20) * 100
details["disparity_20"] = round(disparity, 1)
if disparity > OVERHEAT_DISPARITY_MAX:
reasons.append(f"이격도 {disparity:.1f}% (>{OVERHEAT_DISPARITY_MAX}%)")
blocked = len(reasons) > 0
reason = "급등과열: " + ", ".join(reasons) if blocked else ""
return {"blocked": blocked, "reason": reason, "details": details}
def analyze_custom_signals(chart_data):
"""커스텀 매매 공식 기반 신호 분석 - 이격도 필터 적용"""
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
if len(closes) < 120:
return None
ma20 = sum(closes[-20:]) / 20
ma60 = sum(closes[-60:]) / 60
ma120 = sum(closes[-120:]) / 120
close = closes[-1]
disparity = (close / ma20) * 100 if ma20 else 100
if len(closes) >= 4:
price_3days_ago = closes[-4]
recent_gain = ((close - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0
if recent_gain > 10:
return {"signal": None, "description": f"급등 종목 추격 금지 (3일 +{recent_gain:.1f}%)"}
if ma20 > ma60 > ma120 and close > ma20:
if disparity <= 102:
return {"signal": "buy", "description": "정배열 눌림목 매수 (이격도 안정)"}
else:
return {"signal": None, "description": f"정배열이나 이격 과열 ({disparity:.1f}%)"}
elif ma20 < ma60 < ma120 and close < ma20:
return {"signal": "sell", "description": "역배열 하락 매도타점"}
recent_lows = [float(item.get("stck_lwpr", 0)) for item in chart_data[-5:]]
if any(low < ma20 for low in recent_lows) and close > ma20:
if disparity <= 101:
return {"signal": "buy", "description": "눌림목 진입 매수타점"}
else:
return {"signal": None, "description": f"눌림목이나 이격 과열 ({disparity:.1f}%)"}
stop_loss_line = ma60
take_profit_line = ma20 * 1.05
if close < stop_loss_line:
return {"signal": "exit", "description": "손절라인 이탈"}
if close > take_profit_line:
return {"signal": "take_profit", "description": "익절라인 돌파"}
return None
def analyze_macd(chart_data: list) -> dict:
"""MACD 골든크로스/데드크로스 신호 분석.
EMA12, EMA26으로 MACD 라인 계산 후 Signal(EMA9)과 교차 여부 판단.
Returns: {"signal": "buy"/"sell"/None, "description": str}
"""
if len(chart_data) < 26:
return {"signal": None, "description": "데이터 부족"}
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-26:]]
if len(prices) < 26:
return {"signal": None, "description": "데이터 부족"}
def calculate_ema(prices, period):
ema = [sum(prices[:period]) / period]
multiplier = 2 / (period + 1)
for price in prices[period:]:
ema.append((price - ema[-1]) * multiplier + ema[-1])
return ema
ema12 = calculate_ema(prices, 12)
ema26 = calculate_ema(prices, 26)
if len(ema12) < 2 or len(ema26) < 2:
return {"signal": None, "description": "데이터 부족"}
macd = [ema12[i] - ema26[i] for i in range(len(ema26))]
signal = calculate_ema(macd, 9)
if len(signal) < 2:
return {"signal": None, "description": "데이터 부족"}
if macd[-2] <= signal[-2] and macd[-1] > signal[-1]:
return {"signal": "buy", "description": "MACD 골든크로스"}
elif macd[-2] >= signal[-2] and macd[-1] < signal[-1]:
return {"signal": "sell", "description": "MACD 데드크로스"}
return {"signal": None, "description": "신호 없음"}
def analyze_rsi(chart_data: list) -> dict:
"""RSI 분석 + 볼린저 하단 결합 진입 신호
[2026-03 수정] RSI < 30 구간에서도 반등 확인 시 BUY 신호 발생
- 기존: rsi < 30 → signal=None (항상 반등 대기만)
- 개선: rsi < 30 AND rsi > rsi_prev(반등 시작) AND BB 하단 근처 → BUY
→ 이유: RSI 30 이하에서 반등이 시작된 시점이 핵심 진입 기회이며,
30~45 구간으로 올라오기까지 기다리면 이미 3~5% 상승 후 진입
"""
rsi_period = settings.RSI_PERIOD # 기본값 14
if len(chart_data) < rsi_period + 1:
return {"signal": None, "description": "데이터 부족"}
# [2026-09-27 §8.5-1 중복 통합] 인라인 RSI 계산 → indicators_core.rsi_sma()
# 이 경로는 Wilder 평활이 아닌 "마지막 period개 diff 단순평균" 방식이다.
# 입력을 rsi_period+1개로 먼저 잘라 넘기므로 통합 전 수치와 동일하다.
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+1):]]
rsi = rsi_sma(prices, rsi_period)
# 직전 RSI 계산 (반등 확인용) — 한 봉 앞선 창(window)에 같은 방식 적용
if len(chart_data) >= rsi_period + 2:
prev_prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+2):-(1)]]
rsi_prev = rsi_sma(prev_prices, rsi_period)
else:
rsi_prev = rsi
# 방어적 가드: 위 가드(len >= rsi_period+1)로 인해 도달 불가 경로지만,
# None 비교로 인한 TypeError를 원천 차단한다.
if rsi is None:
return {"signal": None, "description": "데이터 부족"}
if rsi_prev is None:
rsi_prev = rsi
# [2026-03] RSI < 30 구간: 반등 시작 + BB 하단 근처 시 BUY
if rsi < 30:
rsi_rebounding = rsi > rsi_prev # RSI 바닥 찍고 반등 중
if rsi_rebounding:
# 볼린저 하단 근처 확인 (-3% ~ +1.5%)
# [2026-09-27 §8.5-1 중복 통합] 인라인 BB → indicators_core.bollinger_sma()
# 여기는 settings 값이 아닌 20봉·2σ **하드코딩**이 기존 동작이므로 그대로 유지.
bb_lower = None
if len(chart_data) >= 20:
bb_closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-20:]]
_bb = bollinger_sma(bb_closes, 20, 2.0)
bb_lower = _bb[2] if _bb else None
close = float(chart_data[-1].get("stck_clpr", 0))
if bb_lower and bb_lower * 0.97 <= close <= bb_lower * 1.015:
return {
"signal": "buy",
"description": f"RSI 바닥 탈출 반등 ({rsi_prev:.1f}→{rsi:.1f}) + BB하단"
}
else:
# BB 하단 근처는 아니지만 RSI 반등 중: 약한 신호
return {
"signal": None,
"description": f"RSI 과매도 반등 중 ({rsi_prev:.1f}→{rsi:.1f}) - BB하단 대기"
}
else:
# RSI 여전히 하락 중 → 낙하하는 칼 잡기 방지
return {"signal": None, "description": f"RSI 과매도 심화 ({rsi:.1f}) - 반등 대기"}
# 기존 30~45 반등 구간 로직 유지
elif 30 <= rsi <= 45 and rsi > rsi_prev:
return {"signal": "buy", "description": f"RSI 바닥 탈출 반등 ({rsi:.1f})"}
elif rsi > 70:
return {"signal": "sell", "description": f"RSI 과매수 ({rsi:.1f})"}
elif 45 <= rsi <= 60:
return {"signal": "buy", "description": f"RSI 안정 상승 ({rsi:.1f})"}
return {"signal": None, "description": f"RSI 중립 ({rsi:.1f})"}
def analyze_bollinger(chart_data: list) -> dict:
"""볼린저 밴드 신호 분석.
현재가가 하한선 이하 → buy, 상한선 이상 → sell.
Returns: {"signal": "buy"/"sell"/None, "description": str}
"""
bb_period = settings.BOLLINGER_PERIOD # 기본값 20
bb_stddev = settings.BOLLINGER_STDDEV # 기본값 2.0
if len(chart_data) < bb_period:
return {"signal": None, "description": "데이터 부족"}
# [2026-09-27 §8.5-1 중복 통합] 인라인 BB → indicators_core.bollinger_sma()
# σ배수는 settings.BOLLINGER_STDDEV(기본 2.0)를 그대로 전달 — 기존 동작 보존.
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-bb_period:]]
_bands = bollinger_sma(prices, bb_period, bb_stddev)
if _bands is None:
return {"signal": None, "description": "데이터 부족"}
upper_band, ma_bb, lower_band = _bands
current_price = prices[-1]
if current_price < lower_band:
return {"signal": "buy", "description": "볼린저 하한선 터치"}
elif current_price > upper_band:
return {"signal": "sell", "description": "볼린저 상한선 돌파"}
return {"signal": None, "description": "볼린저 중립"}
def analyze_moving_average(chart_data: list) -> dict:
"""이동평균선(5/20/60일) 정배열/역배열 신호 분석.
MA5 > MA20 정배열 → buy, MA5 < MA20 < MA60 역배열 → sell.
Returns: {"signal": "buy"/"sell"/None, "description": str}
"""
ma_long = settings.MA_LONG_PERIOD # 기본값 60
ma_short = settings.MA_MEDIUM_PERIOD # 기본값 20
ma_quick = settings.MA_SHORT_PERIOD # 기본값 5
if len(chart_data) < ma_long:
return {"signal": None, "description": "데이터 부족"}
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-ma_long:]]
ma5 = sum(prices[-ma_quick:]) / ma_quick
ma20 = sum(prices[-ma_short:]) / ma_short
ma60 = sum(prices[-ma_long:]) / ma_long
current_price = prices[-1]
if ma5 > ma20 and current_price > ma5:
return {"signal": "buy", "description": "5일선 > 20일선"}
elif ma20 > ma60 and current_price > ma20:
return {"signal": "buy", "description": "20일선 > 60일선"}
elif ma5 < ma20 < ma60 and current_price < ma5:
return {"signal": "sell", "description": "이동평균 역배열"}
return {"signal": None, "description": "이동평균 중립"}
def analyze_stochastic(chart_data: list) -> dict:
"""스토캐스틱 %K 과매도/과매수 신호 분석.
%K < 30 → buy, %K > 70 → sell (14봉 기준 Fast Stochastic).
Returns: {"signal": "buy"/"sell"/None, "description": str}
"""
if len(chart_data) < 14:
return {"signal": None, "description": "데이터 부족"}
recent_data = chart_data[-14:]
highs = [float(item.get("stck_hgpr", 0)) for item in recent_data]
lows = [float(item.get("stck_lwpr", 0)) for item in recent_data]
closes = [float(item.get("stck_clpr", 0)) for item in recent_data]
highest_high = max(highs)
lowest_low = min(lows)
current_close = closes[-1]
if highest_high == lowest_low:
return {"signal": None, "description": "데이터 부족"}
k = ((current_close - lowest_low) / (highest_high - lowest_low)) * 100
if k < 30:
return {"signal": "buy", "description": f"스토캐스틱 과매도 (%K: {k:.1f})"}
elif k > 70:
return {"signal": "sell", "description": f"스토캐스틱 과매수 (%K: {k:.1f})"}
return {"signal": None, "description": f"스토캐스틱 중립 (%K: {k:.1f})"}
def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict:
"""변동성 돌파 전략 신호 분석.
목표가 = 당일 시가 + 전일 변동폭 × k (기본 0.5).
현재가 >= 목표가 → buy.
Returns: {"signal": "buy"/None, "description": str}
"""
if len(chart_data) < 2:
return {"signal": None, "description": "데이터 부족"}
today = chart_data[-1]
yesterday = chart_data[-2]
current_price = float(today.get("stck_clpr", 0))
open_price = float(today.get("stck_oprc", 0))
prev_high = float(yesterday.get("stck_hgpr", 0))
prev_low = float(yesterday.get("stck_lwpr", 0))
prev_range = prev_high - prev_low
if prev_range == 0 or open_price == 0:
return {"signal": None, "description": "데이터 부족"}
target_price = open_price + (prev_range * k)
if current_price >= target_price:
return {"signal": "buy", "description": f"변동성 돌파 발생 (목표가: {target_price:.0f})"}
return {"signal": None, "description": "변동성 돌파 미달"}
def analyze_adx(chart_data: list) -> dict:
"""ADX(Average Directional Index) 추세 강도 신호 분석.
ADX < 20 → 횡보장(평균회귀 유리) buy, ADX > 30 → 강추세(평균회귀 위험) sell.
Returns: {"signal": "buy"/"sell"/None, "description": str, "adx": float}
"""
if len(chart_data) < 29:
return {"signal": None, "description": "데이터 부족", "adx": None}
highs = [float(item.get("stck_hgpr", 0)) for item in chart_data]
lows = [float(item.get("stck_lwpr", 0)) for item in chart_data]
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
adx = calculate_adx(highs, lows, closes)
if adx is None:
return {"signal": None, "description": "ADX 계산 불가", "adx": None}
if adx < 20:
return {"signal": "buy", "description": f"ADX({adx:.0f}<20) 횡보장 — 평균회귀 유리", "adx": adx}
if adx > 30:
return {"signal": "sell", "description": f"ADX({adx:.0f}>30) 강추세 — 평균회귀 위험", "adx": adx}
return {"signal": None, "description": f"ADX({adx:.0f}) 중립", "adx": adx}
def analyze_obv(chart_data: list) -> dict:
"""OBV(On-Balance Volume) 기반 거래량 신호 분석.
최근 5봉 거래량 추세 확인: 거래량 1.5배 증가 + 상승 → buy,
거래량 0.5배 감소 + 하락 → sell.
Returns: {"signal": "buy"/"sell"/None, "description": str}
"""
if len(chart_data) < 2:
return {"signal": None, "description": "데이터 부족"}
volumes = [int(item.get("acml_vol", 0)) for item in chart_data[-5:]]
closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-5:]]
if len(volumes) < 2 or len(closes) < 2:
return {"signal": None, "description": "데이터 부족"}
if volumes[-1] > volumes[-2] * 1.5 and closes[-1] > closes[-2]:
return {"signal": "buy", "description": "거래량 증가와 상승"}
elif volumes[-1] < volumes[-2] * 0.5 and closes[-1] < closes[-2]:
return {"signal": "sell", "description": "거래량 감소와 하락"}
return {"signal": None, "description": "거래량 중립"}