## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 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>
439 lines
18 KiB
Python
439 lines
18 KiB
Python
"""
|
||
기술적 지표 분석 모듈
|
||
|
||
- 이동평균(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": "거래량 중립"}
|