fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리

## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -14,6 +14,15 @@ RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5)
③ check_exit_signal: 1차 익절 RSI 상한 제거 (55 이상이면 모두 1차 익절 허용)
④ check_exit_signal: ATR 손절을 lowest_since_entry → entry_price 기준으로 변경
[2026-03-15 CLAUDE.md 정합성 수정]
① detect_market_regime: ADX < 20 → SIDEWAYS 판별 추가 (CLAUDE.md 6.1)
② check_buy_signal: SIDEWAYS 국면 진입 차단 (CLAUDE.md 6.1)
③ check_buy_signal: BULL RSI ≤30→<40, BEAR RSI ≤35→<30 (CLAUDE.md 6.2)
④ check_buy_signal: BEAR 거래량 prev_volume→1.5×avg_volume_20d (CLAUDE.md 6.2)
⑤ check_exit_signal: 1차 익절 AND→OR, RSI 60→50, ATR 1.5→2.0 (CLAUDE.md 6.3)
⑥ check_exit_signal: 분할매도 Bull 25%/Bear 40%→50% 통일 (CLAUDE.md 5.2)
⑦ check_exit_signal: 트레일링 high×0.96→high-1.5×ATR (CLAUDE.md 5.2)
[.env 설정]
MEAN_REVERSION_RSI_THRESHOLD
MEAN_REVERSION_MULTIPLIER_STRONG
@@ -33,6 +42,7 @@ logger = logging.getLogger(__name__)
class MarketRegime(Enum):
BULL = "bull"
BEAR = "bear"
SIDEWAYS = "sideways" # CLAUDE.md 6.1: ADX < 20 OR 기울기 ≈ 0 → 진입 불가
@dataclass
@@ -131,20 +141,49 @@ def calculate_adx(highs: List[float], lows: List[float], closes: List[float], pe
return round(adx_s[-1], 2)
def detect_market_regime(index_closes: List[float]) -> MarketRegime:
"""섹션 7.1: 200일 MA + 기울기로 상승장/하락장 판별
def detect_market_regime(index_closes: List[float],
highs: Optional[List[float]] = None,
lows: Optional[List[float]] = None) -> MarketRegime:
"""섹션 7.1: 200일 MA + 기울기 + ADX로 시장 국면 판별
상승장(Bull): 지수 > MA200 AND MA200 기울기 양(+)
하락장(Bear): 그 외
CLAUDE.md 6.1:
상승장(Bull): 지수 > MA200 AND MA200 기울기 양(+)
하락장(Bear): 지수 < MA200 AND MA200 기울기 음(-)
횡보장(Sideways): ADX < 20 OR 기울기 ≈ 0 → 모든 진입 신호 무시
highs/lows 미제공 시 기존 동작(BULL/BEAR만)과 동일 — 기존 호출부 호환 유지.
"""
if len(index_closes) < 205:
from app.core.config import settings
if len(index_closes) < settings.MEAN_REVERSION_MA200_LOOKBACK:
return MarketRegime.BEAR # 데이터 부족 시 보수적
ma200_current = sum(index_closes[-200:]) / 200
ma200_5d_ago = sum(index_closes[-205:-5]) / 200
slope_positive = ma200_current > ma200_5d_ago
# CLAUDE.md 6.1: ADX < 20 → SIDEWAYS (진입 차단)
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
ma200_current = sum(index_closes[-settings.MEAN_REVERSION_MA200_PERIOD:]) / settings.MEAN_REVERSION_MA200_PERIOD
# [#10] 기울기 비교 기간을 5일→20일로 확대하여 SIDEWAYS 과다판정 방지
_slope_lookback = 20
_slope_offset = settings.MEAN_REVERSION_MA200_PERIOD + _slope_lookback
if len(index_closes) >= _slope_offset:
ma200_prev = sum(index_closes[-_slope_offset:-_slope_lookback]) / settings.MEAN_REVERSION_MA200_PERIOD
else:
# 데이터 부족 시 기존 5일 방식 폴백
ma200_prev = sum(index_closes[-(settings.MEAN_REVERSION_MA200_LOOKBACK):-5]) / settings.MEAN_REVERSION_MA200_PERIOD
slope_positive = ma200_current > ma200_prev
# CLAUDE.md 6.1: 기울기 ≈ 0 → SIDEWAYS
slope_ratio = abs(ma200_current - ma200_prev) / ma200_prev if ma200_prev > 0 else 0
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (MA200 기울기 ≈ 0, ratio=%.5f) — CLAUDE.md 6.1", slope_ratio)
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
@@ -210,22 +249,35 @@ def check_buy_signal(
is_reversal: bool = False,
rsi_prev: Optional[float] = None, # [2026-03] RSI 반등 확인용 — 3봉 전 RSI
) -> Optional[Dict]:
"""섹션 7.2: 매수 조건 판별
"""CLAUDE.md 6.2: 매수 조건 판별
[2026-03 수정]
- BULL: 거래량 OR 조건 제거 → RSI ≤ 30 AND BB하단 AND 거래량 ≥ 평균 100%로 단순화
(기존 `vol_ok AND rsi<=50` 조건은 모멘텀 진입이므로 평균회귀 청산 로직과 충돌)
[2026-03-15 CLAUDE.md 정합성 수정]
- SIDEWAYS 국면: 진입 차단 (CLAUDE.md 6.1)
- BULL: RSI < 40, BB하단 -3%~+1.5%, 거래량 >= 20일평균
- BEAR: RSI < 30, 종가 <= BB하단, 되돌림 캔들, 거래량 > 1.5×20일평균
- RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용
(낙하하는 칼 잡기 방지 — short_term_strategy와 동일 로직)
Bull: RSI(14) ≤ 30 (과매도), 종가가 BB 하단선 -3% ~ +1.5% 구간, 거래량 ≥ 20일평균
Bear: RSI(14) ≤ 35 (과매도), 종가 ≤ BB 하단선, 되돌림 캔들 출현, 전일 대비 거래량 증가
"""
# CLAUDE.md 6.1: 횡보장(SIDEWAYS)에서는 모든 진입 신호를 무시
if regime == MarketRegime.SIDEWAYS:
logger.debug("⏭️ SIDEWAYS 국면 — 진입 차단 (CLAUDE.md 6.1)")
return None
if current_volume < avg_volume_20d:
return None
if regime == MarketRegime.BULL:
rsi_ok = rsi <= float(strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD") or 30.0)
# CLAUDE.md 6.2: BULL 진입 RSI < 40
# 적응형 전략 오버라이드: adaptive.rsi_entry (실패 시 strategy_cfg / 40.0 fallback)
_rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
_base_rsi_threshold = float(_rsi_threshold_val) if _rsi_threshold_val is not None else 40.0
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
_base_rsi_threshold = _adp.rsi_entry
except Exception:
pass
rsi_ok = rsi < _base_rsi_threshold
if rsi_ok:
# [2026-03] RSI 반등 확인: rsi_prev 제공 시 RSI 하락 중이면 진입 금지
@@ -236,9 +288,28 @@ def check_buy_signal(
)
return None
bb_low_minus = bb_lower * 0.97 # -3%
bb_low_plus = bb_lower * 1.015 # +1.5%
if bb_low_minus <= close <= bb_low_plus:
# %B 기반 동적 BB 조건 (RSI 깊이에 따라 임계값 조정)
# 적응형 전략: bb_pct_b 오버라이드로 기본 pct_b_max 스케일 조정
_adaptive_bb_scale = 1.0 # 기본 스케일
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bb = get_current_adaptive_params()
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
except Exception:
pass
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
if rsi < 25:
pct_b_max = 0.35 * _adaptive_bb_scale # 깊은 과매도 → BB 조건 완화
elif rsi < 30:
pct_b_max = 0.30 * _adaptive_bb_scale
else:
pct_b_max = 0.20 * _adaptive_bb_scale # RSI 30~40 → BB 하단 근처 요구
if pct_b <= pct_b_max:
reversal_info = ""
if rsi_prev is not None:
reversal_info = f" RSI반등({rsi_prev:.1f}→{rsi:.1f})"
@@ -246,11 +317,24 @@ def check_buy_signal(
"signal": "BUY",
"strategy": "MEAN_REVERSION_BULL",
"split_plan": [0.50, 0.50], # 2회 분할 매수
"description": f"RSI({rsi:.1f})<=30 + BB 하단 -3%~+1.5% (상승장 과매도){reversal_info}",
"description": f"RSI({rsi:.1f})<40 + %%B({pct_b:.2f})<{pct_b_max} (상승장 과매도){reversal_info}",
}
elif regime == MarketRegime.BEAR:
if rsi <= 35:
# CLAUDE.md 6.2: BEAR 진입 RSI < 30
# 적응형 전략 오버라이드: adaptive.bear_rsi_entry (실패 시 기존 fallback)
from app.core.config import settings
_bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
bear_rsi_threshold = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bear = get_current_adaptive_params()
if not _adp_bear.fallback_used:
bear_rsi_threshold = _adp_bear.bear_rsi_entry
except Exception:
pass
if rsi < bear_rsi_threshold:
# [2026-03] 하락장도 RSI 반등 확인 적용
if rsi_prev is not None and rsi <= rsi_prev:
logger.debug(
@@ -259,12 +343,25 @@ def check_buy_signal(
)
return None
if close <= bb_lower and is_reversal and current_volume > prev_volume:
# CLAUDE.md 6.2: BEAR 거래량 > 1.5 * 20일 평균 + %B 동적 임계값
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
if rsi < settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD:
pct_b_max = 0.30
elif rsi < settings.MEAN_REVERSION_RSI_NORMAL_THRESHOLD:
pct_b_max = 0.25
else:
pct_b_max = 0.20
_bear_vol_mult = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT")
bear_vol_mult = float(_bear_vol_mult) if _bear_vol_mult is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT
if pct_b <= pct_b_max and is_reversal and current_volume > bear_vol_mult * avg_volume_20d:
return {
"signal": "BUY",
"strategy": "MEAN_REVERSION_BEAR",
"split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수
"description": f"RSI({rsi:.1f})<=35 + BB 하단 + 되돌림 + 거래량↑ (하락장 과매도)",
"description": f"RSI({rsi:.1f})<30 + %%B({pct_b:.2f})<{pct_b_max} + 되돌림 + 거래량>1.5×20MA (하락장 과매도)",
}
return None
@@ -283,28 +380,53 @@ def check_exit_signal(
remaining_pct: float,
trailing_high: Optional[float] = None, # 진입 후 최고가 (트레일링 스탑용) — 기존 호출부 호환 유지
) -> Optional[ExitSignal]:
"""섹션 7.3 + 7.4: 청산 조건 (R:R 약 2:1 목표)
"""CLAUDE.md 6.3 + 5.2: 청산 조건
[2026-03 수정]
① ATR 손절 기준: lowest_since_entry → entry_price 기준으로 변경
- 기존: 저점 기준이라 매수 직후 손절이 너무 느슨함
- 개선: 진입가 기준으로 손절 즉시 발동 가능
② 1차 익절 RSI 상한 제거: `55 <= rsi <= 65` → `rsi >= 55`
- 빠른 반등 시 65를 바로 뚫어도 1차 익절 캐치 가능
[2026-03-15 CLAUDE.md 정합성 수정]
- 1차 익절(부분매도): RSI >= 50 OR 가격 > BB 중앙선 OR 수익 > 2*ATR (OR 로직)
- 분할매도 비율: 50% (Bull/Bear 동일, CLAUDE.md 5.2)
- 트레일링 스탑: 고점 - 1.5 * ATR (CLAUDE.md 5.2)
시나리오:
1. 손절 체크 (최우선)
2. 1차 익절 (remaining_pct > threshold)
└─ RSI ≥ 55 회복 + BB중앙선 상회: Bull 30% / Bear 50% 매도
2. 1차 익절 (remaining_pct > 0.50)
└─ RSI >= 50 OR BB중앙선 상회 OR 2×ATR 수익: 50% 매도
3. 2차 익절 (1차 완료 후)
├─ 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
├─ 트레일링 스탑: 고점 - 1.5×ATR 하회 시 잔여 전량 청산
└─ BB 상단 도달 시 50% 추가 익절
"""
# ── 1. 손절 (최우선) ──────────────────────────────────────────
from app.core.config import settings
# [2026-03] ATR 손절 기준: lowest_since_entry → entry_price
# 이유: 매수 직후 저점이 낮으면 ATR 손절가가 현재가보다 훨씬 아래 → 손절 사실상 무력화
atr_stop_mult = float(strategy_cfg.get("ATR_STOP_MULTIPLIER") or 2.5)
stop_loss_pct = float(strategy_cfg.get("STOP_LOSS_PERCENT") or -6.0) # e.g. -6.0
# [이슈10 수정] or 패턴 → None 명시 체크
_atr_stop_val = strategy_cfg.get("ATR_STOP_MULTIPLIER")
atr_stop_mult = float(_atr_stop_val) if _atr_stop_val is not None else 2.5
# CLAUDE.md 5.2: 안정적 종목 -6%, 변동성 종목 -10%
# 변동성 판별: ATR / 현재가 > 3% → 변동성 종목
_atr_pct = (atr / close * 100) if close > 0 else 0
# [이슈10 수정] or 패턴 → None 명시 체크
if _atr_pct > 3.0:
_sl_high_val = strategy_cfg.get("STOP_LOSS_HIGH_VOL")
_raw_sl = float(_sl_high_val) if _sl_high_val is not None else -10.0
else:
_sl_val = strategy_cfg.get("STOP_LOSS_PERCENT")
_raw_sl = float(_sl_val) if _sl_val is not None else -settings.STOP_LOSS_PERCENT # config.py 기본값(7.0) 참조
# 적응형 전략 오버라이드: stop_loss / atr_stop_multiplier
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_exit = get_current_adaptive_params()
if not _adp_exit.fallback_used:
atr_stop_mult = _adp_exit.atr_stop_multiplier
if _atr_pct > 3.0:
_raw_sl = _adp_exit.stop_loss_high_vol
else:
_raw_sl = _adp_exit.stop_loss_pct
except Exception:
pass
stop_loss_pct = -abs(_raw_sl) # 항상 음수 보장 (양수 입력 시 전종목 매도 방지)
atr_stop = entry_price - atr_stop_mult * atr
pct_stop = entry_price * (1 + stop_loss_pct / 100)
stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절
@@ -316,54 +438,76 @@ def check_exit_signal(
sell_pct=1.0,
)
# 시장 상태별 1차 익절 임계값
# Bull: 1차 30% 후 remaining=0.7, Bear: 1차 50% 후 remaining=0.5
threshold = 0.7 if regime == MarketRegime.BULL else 0.5
# CLAUDE.md 5.2: +2R 도달 시 50% 분할 매도 — Bull/Bear 동일
threshold = 0.50 # 1차 익절 후 remaining=0.50
# ── 2. 1차 익절: 아직 1차 안 했을 때 ──────────────────────────
# 최소 1×ATR 수익 조건 — 수수료+슬리피지(~0.35%) 이하 수익에서 조기 청산 방지
min_profit_price = entry_price + 1.0 * atr
if remaining_pct > threshold:
# [2026-03] RSI 상한 제거: `55 <= rsi <= 65` → `rsi >= 55`
# 이유: 빠른 반등 시 RSI가 65를 바로 뚫으면 1차 익절 기회를 놓쳤음
if rsi >= 55 and close > bb_middle and close >= min_profit_price:
if regime == MarketRegime.BULL:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(Bull 30%): RSI({rsi:.1f})>=55 + BB중앙({bb_middle:.0f}) 상회 + 1×ATR({atr:.0f}) 수익",
sell_pct=0.30,
)
else:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(Bear 50%): RSI({rsi:.1f})>=55 + BB중앙({bb_middle:.0f}) 상회 + 1×ATR({atr:.0f}) 수익",
sell_pct=0.50,
)
# CLAUDE.md 6.3: RSI >= 50 OR 가격 > BB 중앙선 OR 수익 > 2*ATR (OR 로직)
# 적응형 전략: 1차 익절 RSI / 전량 청산 RSI 오버라이드
_adaptive_rsi_partial = settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD
_adaptive_rsi_full = settings.MEAN_REVERSION_RSI_FULL_EXIT
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_exit2 = get_current_adaptive_params()
if not _adp_exit2.fallback_used:
_adaptive_rsi_partial = _adp_exit2.rsi_partial_exit
_adaptive_rsi_full = _adp_exit2.rsi_full_exit
except Exception:
pass
# ── 3. 2차 익절: 1차 완료 후 ─────────────────────────────────────
if remaining_pct <= threshold:
# 3a. 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
if trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high * 0.97
if close < trail_stop:
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% 커버 + 알파)
if remaining_pct > threshold and _profit_pct >= _MIN_PROFIT_FOR_EXIT:
rsi_exit = rsi >= _adaptive_rsi_partial
bb_exit = close > bb_middle
atr_exit = close >= min_profit_price
if rsi_exit or bb_exit or atr_exit:
reasons = []
if rsi_exit:
reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_partial:.0f}")
if bb_exit:
reasons.append(f"BB중앙({bb_middle:.0f})상회")
if atr_exit:
reasons.append(f"2×ATR({atr:.0f})수익")
# CLAUDE.md 5.2: 50% 분할 매도 — 단, 소량(3주 이하)이면 전량매도
# 1주를 50% 매도하면 0주 → 의미 없는 분할 방지
_is_small_position = (remaining_pct * 10) <= 3 # 대략 3주 이하 추정
if _is_small_position:
return ExitSignal(
action="SELL_ALL",
reason=(
f"트레일링 청산: close={close:.0f} < 최고가×0.97={trail_stop:.0f} "
f"(최고가={trailing_high:.0f})"
),
reason=f"소량 전량매도: {' OR '.join(reasons)} (분할 비효율)",
sell_pct=1.0,
)
# 3b. BB 상단 도달 시 50% 추가 익절 (나머지는 트레일링으로)
if rsi >= 65 and close >= bb_upper * 0.99 and remaining_pct > 0.25:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"2차 익절(50%): RSI({rsi:.1f})>=65 + BB상단({bb_upper:.0f}) 도달",
reason=f"1차 익절(50%): {' OR '.join(reasons)}",
sell_pct=0.50,
)
# ── 3. 전량매도 조건 (1차 익절 여부 무관) ──────────────────────────
# CLAUDE.md 6.3: RSI >= 65 OR 가격 > BB 상단(99%) OR 트레일링스탑 도달
from app.core.config import settings
full_exit_reasons = []
if rsi >= _adaptive_rsi_full:
full_exit_reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_full}")
if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
full_exit_reasons.append(f"BB상단({bb_upper:.0f})도달")
if trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high - 1.5 * atr # CLAUDE.md 5.2: ATR 기반 트레일링
if close < trail_stop:
full_exit_reasons.append(
f"트레일링(close={close:.0f}<{trail_stop:.0f})"
)
if full_exit_reasons:
return ExitSignal(
action="SELL_ALL",
reason=f"전량청산: {' OR '.join(full_exit_reasons)}",
sell_pct=1.0,
)
return None
@@ -378,13 +522,11 @@ def calculate_position_size(
rsi: Optional[float] = None,
adx: Optional[float] = None,
) -> float:
"""섹션 6.1 + 7.5: 동적 포지션 사이징 (신호 강도 기반)
"""섹션 6.1 + 5.1: 동적 포지션 사이징 (신호 강도 기반)
신호 강도별 비중:
- RSI ≤ 20 (극도 과매도): 1.5× = 6%
- RSI ≤ 25 (강한 신호): 1.25× = 5%
- RSI ≤ 30 (표준 신호): 1.0× = 4% (default)
- ADX < 15 (강한 횡보장): +0.25× 보너스 (최대 2.0× = 8%)
[2026-03-15 CLAUDE.md 정합성 수정]
CLAUDE.md 5.1: 종목당 최대 총자본의 3~4%. multiplier는 전략 자본 예산
배분 내에서의 우선순위 조정에만 사용하며, 종목당 절대 상한(4%)은 초과 불가.
시장별 전략 자본 한도:
- 상승장(Bull): 전략 전체 최대 70% 운용
@@ -392,27 +534,23 @@ def calculate_position_size(
"""
max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear
if rsi is not None:
if rsi <= 20:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_STRONG") or 1.50)
elif rsi <= 25:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_NORMAL") or 1.25)
else:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK") or 1.00)
else:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK") or 1.00)
# [이슈10 수정] or 패턴 → None 명시 체크 (0.0 입력 시 기본값 오염 방지)
def _cfg_float(key: str, default: float) -> float:
val = strategy_cfg.get(key)
return float(val) if val is not None else default
if adx is not None and adx < 15:
multiplier = min(multiplier + 0.25, 2.0)
from app.core.config import settings
max_by_position = total_assets * max_per_position * multiplier
# [2026-03-15 CLAUDE.md 정합성 수정] CLAUDE.md 5.1: 종목당 최대 3~4%
# multiplier를 전략 자본 내 비중 조정에만 사용, 절대 상한은 4% 유지
max_by_position = total_assets * max_per_position # 절대 상한 4%
remaining_budget = (total_assets * max_strategy_capital) - current_strategy_allocation
result = max(0.0, min(max_by_position, remaining_budget, available_cash))
if rsi is not None or adx is not None:
logger.debug(
"position_size: total=%.0f, multiplier=%.2f×, rsi=%s, adx=%s → %.0f원",
total_assets, multiplier,
"position_size: total=%.0f, rsi=%s, adx=%s → %.0f원",
total_assets,
f"{rsi:.1f}" if rsi is not None else "N/A",
f"{adx:.1f}" if adx is not None else "N/A",
result,
@@ -447,14 +585,15 @@ def calculate_position_size_v2(
if base <= 0:
return 0.0
from app.core.config import settings
mult = 1.0
if rsi is not None:
if rsi <= 20:
if rsi <= settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD:
mult *= 1.2
elif rsi <= 25:
elif rsi <= settings.MEAN_REVERSION_RSI_NORMAL_THRESHOLD:
mult *= 1.1
if adx is not None and adx > 30:
if adx is not None and adx > settings.MEAN_REVERSION_ADX_STRONG_REDUCE:
mult *= 0.8
max_allowed = total_assets * max_per_position
@@ -479,3 +618,117 @@ def check_h1_breakout_with_ma5(
ma5 = sum(h1_closes[-5:]) / 5
return h1_current_close > ma5
# ═══════════════════════════════════════════════════════════════════════
# 듀얼 전략 추가: 모멘텀 돌파 + 눌림목 매수 (백테스트 검증 완료)
# ═══════════════════════════════════════════════════════════════════════
def check_momentum_breakout_signal(
closes: list[float],
highs: list[float],
volumes: list[int],
current_close: float,
current_volume: int,
rsi: float,
) -> Optional[dict]:
"""모멘텀 돌파 시그널: 20일 신고가 돌파 + 거래량 급증 + RSI 50~70.
BULL 시장에서만 사용. 추세를 타는 종목을 포착.
백테스트 결과: 펄어비스 +198만원(17승0패), 포스코인터 +170만원(12승1패)
"""
if len(highs) < 20 or len(volumes) < 20:
return None
high_20d = max(highs[-20:])
avg_vol_20d = sum(volumes[-20:]) / 20 if volumes[-20:] else 0
if avg_vol_20d <= 0:
return None
vol_ratio = current_volume / avg_vol_20d
# 20일 고점 돌파 + 거래량 2배 + RSI 50~70 (강한 구간, 과매수 아님)
if current_close > high_20d and vol_ratio >= 2.0 and 50 < rsi < 70:
confidence = min(1.0, 0.6 + (vol_ratio - 2) * 0.05)
return {
"signal": "BUY",
"strategy": "MOMENTUM_BREAKOUT",
"confidence": confidence,
"description": (
f"모멘텀 돌파: 20일고점({high_20d:.0f}) 돌파 "
f"vol({vol_ratio:.1f}x) RSI({rsi:.1f})"
),
}
return None
def check_pullback_buy_signal(
closes: list[float],
current_close: float,
current_volume: int,
rsi: float,
rsi_prev: Optional[float] = None,
volumes: list[int] | None = None,
) -> Optional[dict]:
"""눌림목 매수 시그널: 상승추세(MA20>MA60) 중 MA20 근처 조정 후 반등.
BULL 시장에서만 사용. 추세 지속 중 조정을 매수 기회로 활용.
"""
if len(closes) < 60:
return None
ma20 = sum(closes[-20:]) / 20
ma60 = sum(closes[-60:]) / 60
# 거래량 검증: 20일 평균 대비 1.2배 이상 (유동성 확인)
if volumes and len(volumes) >= 20 and current_volume > 0:
avg_vol = sum(volumes[-20:]) / 20
if avg_vol > 0 and current_volume < avg_vol * 1.2:
return None # 거래량 부족 → 유동성 트랩 방지
elif current_volume <= 0:
return None # 거래량 데이터 없으면 진입 불가
# 상승추세(MA20 > MA60) 확인
if ma20 <= ma60:
return None
# MA20 근처(±3%)까지 눌림
if not (ma20 * 0.97 < current_close < ma20 * 1.02):
return None
# RSI 40~55 (과매도에서 회복 중) + RSI 반등 확인
if not (40 < rsi < 55):
return None
if rsi_prev is not None and rsi <= rsi_prev:
return None
return {
"signal": "BUY",
"strategy": "PULLBACK_BUY",
"confidence": 0.55,
"description": f"눌림목: MA20({ma20:.0f}) 근처 반등 RSI({rsi:.1f})",
}
def check_time_stop_exit(
entry_date,
current_date,
profit_pct: float,
max_days: int = 20,
min_profit_pct: float = 3.0,
) -> Optional[dict]:
"""시간 손절: 일정 기간 보유해도 목표 수익률 미달 시 청산.
기회비용 관리 — 자금을 더 좋은 기회에 배분.
"""
if entry_date is None or current_date is None:
return None
holding_days = (current_date - entry_date).days
if holding_days >= max_days and profit_pct < min_profit_pct:
return {
"action": "SELL_ALL",
"sell_pct": 1.0,
"reason": f"시간손절 ({holding_days}일, {profit_pct:+.1f}% < +{min_profit_pct}%)",
}
return None