전략 취약점 15건 수정 (코드 리뷰 기반)
## CRITICAL/HIGH (S1-S8) - S1: Aggressive 모드 %B 스케일링 캡 1.3 (BB 중앙선 위 매수 방지) - S2: ML scaler train-only fit (데이터 누수 제거) - S3: SIDEWAYS = ADX<20 AND slope≈0 (트렌드 초기 차단 해소) - S4: EOD 청산 실패 시 재시도 + CRITICAL 로그 - S5: 일일 손실 한도에 미실현 P&L 포함 - S6: trailing stop 고점 매 사이클 갱신 - S7: 섹터 필터 미매핑 종목 "기타" 분류 (max 3 적용) - S8: 스캐너 RSI 기준 25→46, BB 점수 -3%~+1.5% 구간 ## MEDIUM (M1-M7) - M1: 스캐너 최소 거래량 100만→10만주 - M2: ADX 히스테리시스 18/22 (국면 플리핑 방지) - M3: 분할매도 최소 수익 1.0%→0.5% - M4: ORB 갭 필터 4% + 레인지 폭 0.3% 최소 - M5: 쿨다운 스트릭 부분매도 시 미변경 - M6: MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지) - M7: RSI Wilder 스무딩으로 통일 ## 인프라/보안 - 인증 누락 엔드포인트 수정 (analysis, journals) - nginx 보안 헤더 location 블록 누락 수정 - Alembic 마이그레이션 IF NOT EXISTS 가드 추가 - SellRequest/UsOrderRequest 입력 검증 강화 - 차트 네이버 폴백 추가 (DB 데이터 없을 때) - 서버 이전 시 자동 DB 마이그레이션 + KRX 초기 수집 - CLAUDE.md 섹션 5.1 백테스트 값 동기화 (4%→6%, -6%→-7%) - MAX_SWING_POSITIONS 25→20 (CLAUDE.md 준수) - mdd_manager failed_count에서 skip 종목 제외 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,9 @@ from app.services.strategy_config_service import strategy_cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# M2: ADX hysteresis state — 직전 regime을 기억하여 ADX 경계선에서 잦은 전환 방지
|
||||
_previous_regime: Optional["MarketRegime"] = None
|
||||
|
||||
|
||||
class MarketRegime(Enum):
|
||||
BULL = "bull"
|
||||
@@ -163,11 +166,10 @@ def detect_market_regime(index_closes: List[float],
|
||||
return MarketRegime.BEAR # 데이터 부족 시 보수적
|
||||
|
||||
# CLAUDE.md 6.1: ADX < 20 → SIDEWAYS (진입 차단)
|
||||
# S3 fix: ADX 값은 먼저 계산하되, slope도 함께 확인 후 판정
|
||||
_adx_value = None
|
||||
if highs is not None and lows is not None:
|
||||
adx = calculate_adx(highs, lows, index_closes)
|
||||
if adx is not None and adx < settings.MEAN_REVERSION_ADX_SIDEWAYS:
|
||||
logger.info("시장 국면: SIDEWAYS (ADX=%.1f < %.1f) — CLAUDE.md 6.1", adx, settings.MEAN_REVERSION_ADX_SIDEWAYS)
|
||||
return MarketRegime.SIDEWAYS
|
||||
_adx_value = calculate_adx(highs, lows, index_closes)
|
||||
|
||||
ma200_current = sum(index_closes[-settings.MEAN_REVERSION_MA200_PERIOD:]) / settings.MEAN_REVERSION_MA200_PERIOD
|
||||
# [#10] 기울기 비교 기간을 5일→20일로 확대하여 SIDEWAYS 과다판정 방지
|
||||
@@ -182,28 +184,64 @@ def detect_market_regime(index_closes: List[float],
|
||||
|
||||
# CLAUDE.md 6.1: 기울기 ≈ 0 → SIDEWAYS
|
||||
slope_ratio = abs(ma200_current - ma200_prev) / ma200_prev if ma200_prev > 0 else 0
|
||||
|
||||
# S3 fix: SIDEWAYS 판정은 ADX < threshold AND slope ≈ 0 모두 충족 시에만.
|
||||
# ADX < threshold이더라도 slope가 명확하면 BULL/BEAR로 분류한다.
|
||||
# M2: ADX hysteresis — enter SIDEWAYS at ADX < 18, exit SIDEWAYS at ADX > 22
|
||||
global _previous_regime
|
||||
_ADX_ENTER_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 # 18
|
||||
_ADX_EXIT_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 # 22
|
||||
|
||||
if _adx_value is not None:
|
||||
_was_sideways = (_previous_regime == MarketRegime.SIDEWAYS)
|
||||
# hysteresis: 이미 SIDEWAYS면 ADX > 22 돌파해야 탈출, 아니면 ADX < 18 이하여야 진입
|
||||
_adx_triggers_sideways = (
|
||||
(_was_sideways and _adx_value < _ADX_EXIT_SIDEWAYS) or
|
||||
(not _was_sideways and _adx_value < _ADX_ENTER_SIDEWAYS)
|
||||
)
|
||||
if _adx_triggers_sideways:
|
||||
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
|
||||
logger.info("시장 국면: SIDEWAYS (ADX=%.1f, hysteresis=[enter<%.0f,exit>%.0f] AND slope≈0, ratio=%.5f) — CLAUDE.md 6.1",
|
||||
_adx_value, _ADX_ENTER_SIDEWAYS, _ADX_EXIT_SIDEWAYS, slope_ratio)
|
||||
_previous_regime = MarketRegime.SIDEWAYS
|
||||
return MarketRegime.SIDEWAYS
|
||||
else:
|
||||
logger.info("시장 국면: ADX=%.1f이나 slope 명확(ratio=%.5f) — BULL/BEAR로 분류", _adx_value, slope_ratio)
|
||||
|
||||
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
|
||||
logger.info("시장 국면: SIDEWAYS (MA200 기울기 ≈ 0, ratio=%.5f) — CLAUDE.md 6.1", slope_ratio)
|
||||
_previous_regime = MarketRegime.SIDEWAYS
|
||||
return MarketRegime.SIDEWAYS
|
||||
|
||||
is_bull = index_closes[-1] > ma200_current and slope_positive
|
||||
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", "BULL" if is_bull else "BEAR", slope_ratio)
|
||||
return MarketRegime.BULL if is_bull else MarketRegime.BEAR
|
||||
result = MarketRegime.BULL if is_bull else MarketRegime.BEAR
|
||||
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", result.value.upper(), slope_ratio)
|
||||
_previous_regime = result
|
||||
return result
|
||||
|
||||
|
||||
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""RSI(Relative Strength Index) 계산"""
|
||||
"""RSI(Relative Strength Index) 계산 — Wilder 평활법
|
||||
|
||||
feature_engineering.compute_rsi()와 동일한 Wilder smoothing 사용:
|
||||
- 첫 period개: 단순 평균(seed)
|
||||
- 이후: avg = (prev_avg * (period-1) + current) / period
|
||||
"""
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
gains, losses = [], []
|
||||
for i in range(-period, 0):
|
||||
diff = closes[i] - closes[i - 1]
|
||||
gains.append(max(diff, 0))
|
||||
losses.append(max(-diff, 0))
|
||||
# 모든 price diff 계산
|
||||
diffs = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
||||
|
||||
# Seed: 첫 period개의 단순 평균
|
||||
avg_gain = sum(max(d, 0) for d in diffs[:period]) / period
|
||||
avg_loss = sum(max(-d, 0) for d in diffs[:period]) / period
|
||||
|
||||
# Wilder smoothing: 나머지 구간
|
||||
for i in range(period, len(diffs)):
|
||||
avg_gain = (avg_gain * (period - 1) + max(diffs[i], 0)) / period
|
||||
avg_loss = (avg_loss * (period - 1) + max(-diffs[i], 0)) / period
|
||||
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss
|
||||
@@ -301,6 +339,8 @@ def check_buy_signal(
|
||||
if not _adp_bb.fallback_used:
|
||||
# Normal 기준 0.30 대비 비율로 스케일링
|
||||
_adaptive_bb_scale = _adp_bb.bb_pct_b / 0.30 if _adp_bb.bb_pct_b > 0 else 1.0
|
||||
# S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 1.3
|
||||
_adaptive_bb_scale = min(_adaptive_bb_scale, 1.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -462,7 +502,7 @@ def check_exit_signal(
|
||||
min_profit_price = entry_price + 2.0 * atr # CLAUDE.md 6.3: 2×ATR
|
||||
_profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0
|
||||
# 최소 수익률 가드: 수수료(~0.4%)를 커버하지 못하면 익절하지 않음
|
||||
_MIN_PROFIT_FOR_EXIT = 1.0 # 최소 1% 수익이어야 익절 (수수료+세금 0.4% 커버 + 알파)
|
||||
_MIN_PROFIT_FOR_EXIT = 0.5 # 최소 0.5% 수익이어야 익절 (수수료+세금 ~0.3-0.4% 커버)
|
||||
if remaining_pct > threshold and _profit_pct >= _MIN_PROFIT_FOR_EXIT:
|
||||
rsi_exit = rsi >= _adaptive_rsi_partial
|
||||
bb_exit = close > bb_middle
|
||||
|
||||
Reference in New Issue
Block a user