Files
auto-stock/backend/tests/test_mean_reversion.py
shuim de7921d270 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>
2026-03-26 16:05:41 +09:00

1080 lines
42 KiB
Python
Raw 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.
"""
평균회귀 전략 (mean_reversion_strategy.py) 단위 테스트
테스트 시나리오:
1. ATR (Average True Range) 계산
2. ADX (Average Directional Index) 계산
3. 시장 국면 감지 (detect_market_regime)
4. RSI 계산 (calculate_rsi)
5. 볼린저 밴드 계산 (calculate_bollinger)
6. 되돌림 캔들 감지 (is_reversal_candle)
7. 매수 신호 (check_buy_signal) — Bull/Bear 시장별
8. 청산 신호 (check_exit_signal) — 손절/1차 익절/2차 익절/트레일링
9. 포지션 사이징 (calculate_position_size)
10. 포지션 사이징 v2 (calculate_position_size_v2)
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from unittest.mock import patch, MagicMock
# ---------------------------------------------------------------------------
# strategy_cfg 모킹 — 모듈 임포트 전에 패치
# ---------------------------------------------------------------------------
_mock_strategy_cfg = MagicMock()
_mock_strategy_cfg.get.side_effect = lambda key, default=None: {
"MEAN_REVERSION_RSI_THRESHOLD": 30.0,
"ATR_STOP_MULTIPLIER": 2.5,
"STOP_LOSS_PERCENT": -6.0,
"MEAN_REVERSION_MULTIPLIER_STRONG": 1.50,
"MEAN_REVERSION_MULTIPLIER_NORMAL": 1.25,
"MEAN_REVERSION_MULTIPLIER_WEAK": 1.00,
}.get(key, default)
with patch.dict("sys.modules", {
"app.core.config": MagicMock(),
"app.services.strategy_config_service": MagicMock(strategy_cfg=_mock_strategy_cfg),
}):
from app.services.mean_reversion_strategy import (
calculate_atr,
calculate_adx,
detect_market_regime,
calculate_rsi,
calculate_bollinger,
is_reversal_candle,
check_buy_signal,
check_exit_signal,
calculate_position_size,
calculate_position_size_v2,
MarketRegime,
ExitSignal,
)
# ---------------------------------------------------------------------------
# 헬퍼 함수
# ---------------------------------------------------------------------------
def _rising(start, count, step=100):
"""상승 가격 리스트"""
return [start + i * step for i in range(count)]
def _falling(start, count, step=100):
"""하락 가격 리스트"""
return [start - i * step for i in range(count)]
def _flat(price, count):
"""일정 가격 리스트"""
return [price] * count
# ═══════════════════════════════════════════════════════════════════════════
# 1. calculate_atr
# ═══════════════════════════════════════════════════════════════════════════
class TestCalculateATR:
"""ATR(Average True Range) 계산 테스트"""
def test_basic_calculation(self):
"""기본 ATR 계산 검증"""
n = 20
highs = [100 + i for i in range(n)]
lows = [90 + i for i in range(n)]
closes = [95 + i for i in range(n)]
result = calculate_atr(highs, lows, closes, period=14)
assert result is not None
assert result > 0
def test_insufficient_data(self):
"""데이터 부족 시 None 반환"""
result = calculate_atr([100, 110], [90, 95], [95, 105], period=14)
assert result is None
def test_constant_prices_atr_near_zero(self):
"""일정한 가격 → ATR ≈ H-L (매 봉 동일)"""
n = 20
highs = [101] * n
lows = [99] * n
closes = [100] * n
result = calculate_atr(highs, lows, closes, period=14)
assert result is not None
assert result == pytest.approx(2.0, abs=0.01) # H-L = 2
def test_period_parameter(self):
"""period 변경 시 정상 동작"""
n = 30
highs = [100 + i for i in range(n)]
lows = [90 + i for i in range(n)]
closes = [95 + i for i in range(n)]
result_5 = calculate_atr(highs, lows, closes, period=5)
result_14 = calculate_atr(highs, lows, closes, period=14)
assert result_5 is not None
assert result_14 is not None
def test_gap_up_increases_atr(self):
"""갭 상승 시 TR이 커져 ATR 상승"""
n = 20
highs = _flat(110, n)
lows = _flat(100, n)
closes = _flat(105, n)
# 갭 상승 삽입
highs[-1] = 130
lows[-1] = 120
closes[-1] = 125
result = calculate_atr(highs, lows, closes, period=14)
assert result is not None
assert result > 10 # 기본 H-L=10 보다 큰 ATR
def test_minimum_data_for_period(self):
"""period+1개 데이터 → 계산 가능"""
period = 5
n = period + 1
highs = [100 + i * 2 for i in range(n)]
lows = [90 + i * 2 for i in range(n)]
closes = [95 + i * 2 for i in range(n)]
result = calculate_atr(highs, lows, closes, period=period)
assert result is not None
# ═══════════════════════════════════════════════════════════════════════════
# 2. calculate_adx
# ═══════════════════════════════════════════════════════════════════════════
class TestCalculateADX:
"""ADX(Average Directional Index) 계산 테스트"""
def test_basic_calculation(self):
"""기본 ADX 계산 — 충분한 데이터, 결과는 숫자"""
# 실제 주식 시장처럼 변동하는 가격 데이터 사용
import math
n = 40
highs = [1000 + 50 * math.sin(i * 0.5) + i * 2 + 30 for i in range(n)]
lows = [1000 + 50 * math.sin(i * 0.5) + i * 2 - 30 for i in range(n)]
closes = [1000 + 50 * math.sin(i * 0.5) + i * 2 for i in range(n)]
result = calculate_adx(highs, lows, closes, period=14)
assert result is not None
assert isinstance(result, float)
def test_insufficient_data(self):
"""데이터 부족 시 None 반환 — 최소 2*period+1 필요"""
result = calculate_adx([100] * 10, [90] * 10, [95] * 10, period=14)
assert result is None
def test_strong_trend_high_adx(self):
"""강한 상승 추세 → ADX 높음"""
n = 50
highs = [100 + i * 10 for i in range(n)]
lows = [90 + i * 10 for i in range(n)]
closes = [95 + i * 10 for i in range(n)]
result = calculate_adx(highs, lows, closes, period=14)
assert result is not None
assert result > 20 # 강한 추세
def test_sideways_market_low_adx(self):
"""횡보 시장 → ADX 낮음"""
n = 40
# 좁은 범위에서 오르내림
highs = [101 + (i % 3) for i in range(n)]
lows = [99 - (i % 3) for i in range(n)]
closes = [100 + (i % 2) - 0.5 for i in range(n)]
result = calculate_adx(highs, lows, closes, period=14)
assert result is not None
# 횡보 시 ADX는 보통 낮지만 정확한 수치는 데이터에 의존
def test_minimum_required_data(self):
"""최소 데이터 (2*period+1)에서 정상 작동"""
period = 5
n = period * 2 + 1
highs = [100 + i for i in range(n)]
lows = [90 + i for i in range(n)]
closes = [95 + i for i in range(n)]
result = calculate_adx(highs, lows, closes, period=period)
assert result is not None
def test_result_is_rounded(self):
"""결과값이 소수점 2자리로 반올림"""
n = 40
highs = [100 + i * 3 for i in range(n)]
lows = [90 + i * 3 for i in range(n)]
closes = [95 + i * 3 for i in range(n)]
result = calculate_adx(highs, lows, closes, period=14)
assert result is not None
# 소수점 2자리 이내 확인
assert result == round(result, 2)
# ═══════════════════════════════════════════════════════════════════════════
# 3. detect_market_regime
# ═══════════════════════════════════════════════════════════════════════════
class TestDetectMarketRegime:
"""시장 국면 감지 테스트"""
def test_insufficient_data_returns_bear(self):
"""데이터 부족 → 보수적으로 BEAR 반환"""
result = detect_market_regime([2500] * 100)
assert result == MarketRegime.BEAR
def test_bull_market(self):
"""상승장: 지수 > MA200 AND 기울기 양(+)"""
prices = _rising(2000, 210, 5)
result = detect_market_regime(prices)
assert result == MarketRegime.BULL
def test_bear_market(self):
"""하락장: 지수 < MA200"""
prices = _falling(3000, 210, 5)
result = detect_market_regime(prices)
assert result == MarketRegime.BEAR
def test_flat_market_bear(self):
"""횡보 → 기울기 ≈ 0 → SIDEWAYS (CLAUDE.md 6.1: 기울기 ≈ 0 → 횡보장)"""
prices = _flat(2500, 210)
result = detect_market_regime(prices)
# [2026-03-15 수정] CLAUDE.md 6.1: MA200 기울기 0 → SIDEWAYS (slope_ratio < 0.001)
assert result == MarketRegime.SIDEWAYS
def test_price_above_ma_but_declining(self):
"""가격 > MA200이지만 기울기 하락 → BEAR"""
# 초반 상승 → 후반 하락 (MA200 기울기가 음수가 되도록)
prices = _rising(2000, 150, 10) + _falling(3500, 60, 15)
result = detect_market_regime(prices)
# 최근 가격은 하락 중이므로 MA200 기울기 음수일 가능성
assert result in (MarketRegime.BULL, MarketRegime.BEAR)
def test_exactly_205_data_points(self):
"""최소 필요 데이터 (205개)에서 정상 작동"""
prices = _rising(2000, 205, 3)
result = detect_market_regime(prices)
assert result == MarketRegime.BULL
# ═══════════════════════════════════════════════════════════════════════════
# 4. calculate_rsi
# ═══════════════════════════════════════════════════════════════════════════
class TestCalculateRSI:
"""RSI(Relative Strength Index) 계산 테스트"""
def test_insufficient_data(self):
"""데이터 부족 → None"""
result = calculate_rsi([100, 101, 102], period=14)
assert result is None
def test_all_gains_rsi_100(self):
"""모든 봉 상승 → RSI = 100"""
prices = _rising(10000, 20, 100)
result = calculate_rsi(prices, period=14)
assert result == 100.0
def test_all_losses_rsi_0(self):
"""모든 봉 하락 → RSI = 0"""
prices = _falling(20000, 20, 100)
result = calculate_rsi(prices, period=14)
assert result == pytest.approx(0.0, abs=0.01)
def test_balanced_rsi_near_50(self):
"""상승/하락 균등 → RSI ≈ 50"""
prices = []
base = 10000
for i in range(20):
if i % 2 == 0:
base += 100
else:
base -= 100
prices.append(base)
result = calculate_rsi(prices, period=14)
assert result is not None
assert 30 < result < 70 # 대략 50 근처
def test_period_parameter(self):
"""period 변경 시 다른 결과"""
prices = _rising(10000, 30, 50) + _falling(11500, 10, 100)
rsi_7 = calculate_rsi(prices, period=7)
rsi_14 = calculate_rsi(prices, period=14)
assert rsi_7 is not None
assert rsi_14 is not None
# 다른 period → 다른 값 (동일할 수도 있지만 일반적으로 다름)
def test_minimum_data(self):
"""period+1개 데이터에서 정상 작동"""
prices = _rising(10000, 15, 50) # 15 = 14 + 1
result = calculate_rsi(prices, period=14)
assert result is not None
# ═══════════════════════════════════════════════════════════════════════════
# 5. calculate_bollinger
# ═══════════════════════════════════════════════════════════════════════════
class TestCalculateBollinger:
"""볼린저 밴드 계산 테스트"""
def test_insufficient_data(self):
"""데이터 부족 → None"""
result = calculate_bollinger([100, 101], period=20)
assert result is None
def test_basic_calculation(self):
"""기본 볼린저 밴드 계산 — (upper, middle, lower) 반환"""
prices = _flat(10000, 20)
result = calculate_bollinger(prices, period=20)
assert result is not None
upper, middle, lower = result
assert middle == pytest.approx(10000.0)
# 표준편차 0 → upper == middle == lower
assert upper == pytest.approx(10000.0)
assert lower == pytest.approx(10000.0)
def test_band_width_with_volatility(self):
"""변동성 있으면 밴드 폭이 넓어짐"""
prices = [10000 + (i % 2) * 500 - 250 for i in range(20)]
result = calculate_bollinger(prices, period=20)
assert result is not None
upper, middle, lower = result
assert upper > middle > lower
band_width = upper - lower
assert band_width > 0
def test_upper_above_lower(self):
"""항상 upper >= lower"""
prices = _rising(9000, 25, 100)
result = calculate_bollinger(prices, period=20)
assert result is not None
upper, middle, lower = result
assert upper >= lower
def test_symmetric_bands(self):
"""밴드는 중앙선 기준 대칭"""
prices = [10000, 10100, 9900, 10050, 9950] * 4
result = calculate_bollinger(prices, period=20)
assert result is not None
upper, middle, lower = result
assert upper - middle == pytest.approx(middle - lower, abs=0.01)
def test_period_parameter(self):
"""다른 period에서 정상 작동"""
prices = _rising(10000, 30, 50)
result = calculate_bollinger(prices, period=10)
assert result is not None
# ═══════════════════════════════════════════════════════════════════════════
# 6. is_reversal_candle
# ═══════════════════════════════════════════════════════════════════════════
class TestIsReversalCandle:
"""되돌림 캔들 감지 테스트"""
def test_bullish_candle(self):
"""양봉 (종가 > 시가) → True"""
result = is_reversal_candle(
opens=[10000], highs=[10500], lows=[9800], closes=[10300]
)
assert result is True
def test_long_lower_shadow(self):
"""긴 하단 꼬리 (하단 그림자 > 2× 몸통) → True"""
# 음봉이지만 긴 꼬리: 시가=10300, 종가=10200 (body=100)
# 하단 그림자 = min(10300,10200) - 9800 = 400 > 2*100
result = is_reversal_candle(
opens=[10300], highs=[10500], lows=[9800], closes=[10200]
)
assert result is True
def test_bearish_no_shadow(self):
"""음봉 + 짧은 꼬리 → False"""
# 시가=10500, 종가=10000 (body=500), 하단 그림자 = 10000-9900=100 < 2*500
result = is_reversal_candle(
opens=[10500], highs=[10600], lows=[9900], closes=[10000]
)
assert result is False
def test_empty_closes_returns_false(self):
"""빈 closes → False"""
result = is_reversal_candle(opens=[], highs=[], lows=[], closes=[])
assert result is False
def test_doji_with_long_lower_shadow(self):
"""도지 (몸통 ≈ 0) + 긴 하단 꼬리 → True"""
result = is_reversal_candle(
opens=[10000], highs=[10010], lows=[9500], closes=[10000]
)
assert result is True
def test_uses_last_candle_only(self):
"""여러 캔들 데이터 중 마지막 캔들만 사용"""
result = is_reversal_candle(
opens=[9000, 10000], # 마지막: 10000
highs=[9500, 10500],
lows=[8500, 9800],
closes=[8800, 10300], # 마지막: 양봉
)
assert result is True
# ═══════════════════════════════════════════════════════════════════════════
# 7. check_buy_signal
# ═══════════════════════════════════════════════════════════════════════════
class TestCheckBuySignal:
"""매수 신호 판별 테스트 — Bull/Bear 시장별"""
def test_bull_buy_signal(self):
"""상승장 매수 조건 충족: RSI<=30 + BB 하단 구간 + 거래량 충분 + RSI 반등"""
result = check_buy_signal(
regime=MarketRegime.BULL,
rsi=25.0,
close=9750, # bb_lower*0.97=9700 ~ bb_lower*1.015=10150
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=900000,
is_reversal=False,
rsi_prev=22.0, # RSI 반등 중 (22 → 25)
)
assert result is not None
assert result["signal"] == "BUY"
assert result["strategy"] == "MEAN_REVERSION_BULL"
def test_bull_rsi_too_high(self):
"""상승장 RSI > 30 → 신호 없음"""
result = check_buy_signal(
regime=MarketRegime.BULL,
rsi=45.0,
close=9800,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=900000,
)
assert result is None
def test_bull_insufficient_volume(self):
"""상승장 거래량 < 20일 평균 → 신호 없음"""
result = check_buy_signal(
regime=MarketRegime.BULL,
rsi=25.0,
close=9800,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=500000,
prev_volume=900000,
rsi_prev=22.0,
)
assert result is None
def test_bull_price_outside_bb_lower_range(self):
"""상승장 가격이 BB 하단 -3%~+1.5% 범위 밖 → 신호 없음"""
result = check_buy_signal(
regime=MarketRegime.BULL,
rsi=25.0,
close=10500, # BB 하단(10000) 대비 너무 높음
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=900000,
rsi_prev=22.0,
)
assert result is None
def test_bull_rsi_still_falling_blocked(self):
"""상승장 RSI가 하락 중 (낙하하는 칼) → 진입 차단"""
result = check_buy_signal(
regime=MarketRegime.BULL,
rsi=25.0,
close=9800,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=900000,
rsi_prev=28.0, # RSI 하락 중 (28 → 25)
)
assert result is None
def test_bear_buy_signal(self):
"""하락장 매수 조건: RSI<30 + BB 하단 이하 + 되돌림 + 거래량 > 1.5×20MA
[2026-03-15 수정] CLAUDE.md 6.2: BEAR RSI < 30 (이하 아닌 미만), 거래량 > 1.5× 20일 평균
- rsi=29.0 (< 30 충족)
- current_volume=1600000 (> 1.5×1000000=1500000 충족)
"""
result = check_buy_signal(
regime=MarketRegime.BEAR,
rsi=29.0,
close=9900,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1600000,
prev_volume=1100000,
is_reversal=True,
rsi_prev=27.0, # RSI 반등 중 (27→29)
)
assert result is not None
assert result["signal"] == "BUY"
assert result["strategy"] == "MEAN_REVERSION_BEAR"
assert len(result["split_plan"]) == 3 # 3회 분할 매수
def test_bear_no_reversal_candle(self):
"""하락장 되돌림 캔들 없음 → 신호 없음"""
result = check_buy_signal(
regime=MarketRegime.BEAR,
rsi=30.0,
close=9900,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=1100000,
is_reversal=False,
rsi_prev=27.0,
)
assert result is None
def test_bear_volume_not_increasing(self):
"""하락장 전일 대비 거래량 감소 → 신호 없음"""
result = check_buy_signal(
regime=MarketRegime.BEAR,
rsi=30.0,
close=9900,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=1300000, # 전일보다 감소
is_reversal=True,
rsi_prev=27.0,
)
assert result is None
def test_bear_rsi_still_falling_blocked(self):
"""하락장 RSI 하락 중 → 진입 차단"""
result = check_buy_signal(
regime=MarketRegime.BEAR,
rsi=30.0,
close=9900,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=1100000,
is_reversal=True,
rsi_prev=33.0, # RSI 하락 중 (33 → 30)
)
assert result is None
def test_bull_split_plan(self):
"""상승장 매수 → 2회 분할 매수 [0.50, 0.50]"""
result = check_buy_signal(
regime=MarketRegime.BULL,
rsi=25.0,
close=9800,
bb_upper=11000,
bb_middle=10500,
bb_lower=10000,
avg_volume_20d=1000000,
current_volume=1200000,
prev_volume=900000,
rsi_prev=22.0,
)
assert result is not None
assert result["split_plan"] == [0.50, 0.50]
# ═══════════════════════════════════════════════════════════════════════════
# 8. check_exit_signal
# ═══════════════════════════════════════════════════════════════════════════
class TestCheckExitSignal:
"""청산 신호 판별 테스트"""
def test_stop_loss_pct_triggered(self):
"""손절 도달 → SELL_ALL
[2026-03-15 수정] 변동성 판별 로직: ATR/종가 > 3% → -10% 손절 적용
- atr=100, close=9200 → _atr_pct=100/9200*100=1.09% <= 3% → 안정적 종목 (-6%)
- pct_stop = 10000×(1-6/100) = 9400
- atr_stop = 10000 - 2.5×100 = 9750
- stop_price = max(9750, 9400) = 9750
- close=9200 <= 9750 → 손절 발동
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=35.0,
close=9200, # stop_price=9750보다 낮아 손절 발동
bb_upper=11000,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=100, # ATR 작게 → 변동성 낮음 → -6% 손절 적용
lowest_since_entry=9000,
remaining_pct=1.0,
)
assert result is not None
assert result.action == "SELL_ALL"
assert result.sell_pct == 1.0
def test_atr_stop_triggered(self):
"""ATR 손절 도달 → SELL_ALL"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=30.0,
close=9200,
bb_upper=11000,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=300, # ATR 손절: 10000 - 2.5*300 = 9250
lowest_since_entry=9100,
remaining_pct=1.0,
)
assert result is not None
assert result.action == "SELL_ALL"
def test_first_profit_taking_bull(self):
"""Bull 1차 익절: RSI>=50 OR BB중앙 상회 OR 2×ATR 수익 → 50% 매도
[2026-03-15 수정] CLAUDE.md 6.3: 1차 익절은 OR 로직, 분할매도 50% (Bull/Bear 동일)
- RSI(58) >= 50: True
- close(10800) > bb_middle(10500): True
- close(10800) >= entry(10000)+2×ATR(300)=10600: True
→ sell_pct=0.50
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=58.0,
close=10800, # > bb_middle(10500), > entry+2*ATR(10600)
bb_upper=11500,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=300,
lowest_since_entry=9800,
remaining_pct=1.0, # 아직 1차 익절 안 함
)
assert result is not None
assert result.action == "SELL_PARTIAL"
assert result.sell_pct == 0.50 # CLAUDE.md 5.2: 50% 통일
def test_first_profit_taking_bear(self):
"""Bear 1차 익절: RSI>=55 + BB중앙 상회 → 50% 매도"""
result = check_exit_signal(
regime=MarketRegime.BEAR,
rsi=60.0,
close=10800,
bb_upper=11500,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=300,
lowest_since_entry=9800,
remaining_pct=1.0,
)
assert result is not None
assert result.action == "SELL_PARTIAL"
assert result.sell_pct == 0.50 # Bear 50%
def test_no_profit_if_rsi_below_55(self):
"""RSI < 50 AND BB중앙 미하회 AND 2×ATR 수익 미달 → 1차 익절 미발동
[2026-03-15 수정] CLAUDE.md 6.3: 1차 익절 OR 조건:
- RSI(49) < 50: False
- close(10200) <= bb_middle(10500): False (10200 < 10500 → BB중앙 미초과)
- close(10200) < entry(10000)+2×ATR(200)=10400: False
→ 세 조건 모두 불충족 → 익절 미발동
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=49.0,
close=10200, # bb_middle(10500) 미달, 2×ATR(10400) 미달
bb_upper=11500,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=200,
lowest_since_entry=9800,
remaining_pct=1.0,
)
assert result is None
def test_no_profit_below_min_profit(self):
"""OR 조건 모두 미충족 → 1차 익절 미발동
[2026-03-15 수정] CLAUDE.md 6.3: OR 조건이므로 세 조건 모두 False여야 미발동.
- RSI(47) < 50: False
- close(9900) <= bb_middle(10200): False (9900 < 10200 → 미초과)
- close(9900) < entry(10000)+2×ATR(50)=10100: False
→ 전부 False → 익절 미발동
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=47.0,
close=9900, # bb_middle(10200) 미달, RSI(47) < 50, 2×ATR(10100) 미달
bb_upper=11500,
bb_middle=10200,
bb_lower=9500,
entry_price=10000,
atr=50,
lowest_since_entry=9800,
remaining_pct=1.0,
)
assert result is None
def test_trailing_stop(self):
"""트레일링 스탑: 고점 - 1.5×ATR 하회 → SELL_ALL
[2026-03-15 수정] CLAUDE.md 5.2: 트레일링 = 고점 - 1.5×ATR (비율이 아닌 ATR 기반)
- remaining_pct=0.4 (1차 익절 완료, threshold=0.50 이하)
- 1차 익절 섹션: remaining_pct(0.4) > 0.50 → False → 통과
- 전량매도 섹션:
- rsi=45 < 65 → False
- close=10100 < bb_upper×0.99=11385 → False
- trailing_high(11000) > entry(10000), trail_stop=11000-1.5×300=10550
- close=10100 < 10550 → True → 트레일링 발동
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=45.0,
close=10100, # trail_stop=11000-1.5×300=10550 하회
bb_upper=11500,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=300,
lowest_since_entry=9800,
remaining_pct=0.4, # 1차 익절 완료 (threshold=0.50 이하)
trailing_high=11000,
)
assert result is not None
assert result.action == "SELL_ALL"
assert "트레일링" in result.reason
def test_second_profit_bb_upper(self):
"""BB 상단 도달 + RSI>=65 → 전량청산 (SELL_ALL)
[2026-03-15 수정] CLAUDE.md 6.3: BB 상단 도달 시 전량매도(SELL_ALL) 조건.
- remaining_pct=0.4 (1차 익절 완료, threshold=0.50 이하)
- 1차 익절 섹션: remaining_pct(0.4) > 0.50 → False → 스킵
- 전량매도 섹션:
- rsi=68 >= 65 → True → SELL_ALL 반환
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=68.0,
close=11400, # bb_upper*0.99=11385 상회
bb_upper=11500,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=300,
lowest_since_entry=9800,
remaining_pct=0.4, # 1차 완료 (threshold=0.50 이하)
)
assert result is not None
assert result.action == "SELL_ALL"
assert result.sell_pct == 1.0
def test_no_exit_normal_hold(self):
"""청산 조건 모두 미충족 → 신호 없음
[2026-03-15 수정] 모든 OR 조건 False여야 None 반환:
- 손절: stop_price < close(10150) → 미발동
- _atr_pct=50/10150*100=0.49% → 안정적 → -6%
- pct_stop=10000×0.94=9400, atr_stop=10000-2.5×50=9875
- stop_price=9875 < 10150 → 미발동
- 1차 익절 (remaining=1.0 > 0.50):
- RSI(47) < 50: False
- close(10150) <= bb_middle(10500): False
- close(10150) < entry+2×ATR(10100): False (10150 >= 10100 → True!)
→ atr_exit=True → 발동됨... atr=50: min_profit=10000+2×50=10100 → close=10150 >= 10100
- 수정: close를 ATR 이익 미달로 조정: close=10050, atr=50 → min_profit=10100 → 10050<10100 OK
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=47.0,
close=10050, # RSI<50, BB중앙(10500) 미달, 2×ATR(10100) 미달
bb_upper=11500,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=50,
lowest_since_entry=9800,
remaining_pct=1.0,
)
assert result is None
def test_stop_loss_has_priority(self):
"""손절은 익절보다 우선
[2026-03-15 수정] 변동성 판별: ATR/종가 <= 3% → -6% 손절 적용
- atr=80, close=9200 → _atr_pct=80/9200*100=0.87% → 안정적 (-6%)
- pct_stop=10000×0.94=9400, atr_stop=10000-2.5×80=9800
- stop_price=max(9800,9400)=9800
- close=9200 <= 9800 → 손절 발동 (RSI>=50 익절 조건보다 우선)
"""
result = check_exit_signal(
regime=MarketRegime.BULL,
rsi=60.0, # 1차 익절 조건(RSI>=50) 충족이지만 손절 우선
close=9200, # stop_price=9800 하회 → 손절
bb_upper=11500,
bb_middle=10500,
bb_lower=9500,
entry_price=10000,
atr=80, # 변동성 낮음 → -6% 손절 적용
lowest_since_entry=9000,
remaining_pct=1.0,
)
assert result is not None
assert result.action == "SELL_ALL"
assert "손절" in result.reason
# ═══════════════════════════════════════════════════════════════════════════
# 9. calculate_position_size
# ═══════════════════════════════════════════════════════════════════════════
class TestCalculatePositionSize:
"""포지션 사이징 테스트"""
def test_basic_bull_market(self):
"""상승장 기본 포지션 사이즈"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
)
# 기본: 100M * 4% * 1.0 = 4M
# 예산: 100M * 70% - 0 = 70M
# min(4M, 70M, 50M) = 4M
assert result == pytest.approx(4_000_000)
def test_bear_market_lower_budget(self):
"""하락장 전략 자본 한도 30%"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BEAR,
)
# 기본: 100M * 4% * 1.0 = 4M
# 예산: 100M * 30% - 0 = 30M
# min(4M, 30M, 50M) = 4M
assert result == pytest.approx(4_000_000)
def test_rsi_extreme_oversold_multiplier(self):
"""RSI <= 20 → multiplier 1.5×이지만 CLAUDE.md 5.1: 절대 상한 4%"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=18.0,
)
# [2026-03-15] CLAUDE.md 5.1 준수: 100M * 4% = 4M (multiplier로 상한 초과 불가)
assert result == pytest.approx(4_000_000)
def test_rsi_strong_oversold_multiplier(self):
"""RSI <= 25 → multiplier 1.25×이지만 절대 상한 4%"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=23.0,
)
assert result == pytest.approx(4_000_000) # CLAUDE.md 5.1: 4% 상한
def test_adx_low_bonus(self):
"""ADX < 15 → +0.25×이지만 절대 상한 4%"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=28.0, # 1.0×
adx=10.0, # +0.25 → 1.25× (but capped at 4%)
)
assert result == pytest.approx(4_000_000)
def test_adx_low_with_strong_rsi(self):
"""RSI<=20 + ADX<15 → multiplier 1.75×이지만 절대 상한 4%"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=18.0,
adx=10.0,
)
# CLAUDE.md 5.1: 절대 상한 4% = 4M
assert result == pytest.approx(4_000_000)
def test_remaining_budget_constraint(self):
"""전략 배분 한도 소진 시 0 반환"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=70_000_000, # 70% 이미 배분
regime=MarketRegime.BULL,
)
assert result == 0.0
def test_cash_constraint(self):
"""가용 현금 < 포지션 사이즈 → 현금만큼만"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=2_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
)
assert result == pytest.approx(2_000_000)
def test_zero_assets(self):
"""자산 0 → 0"""
result = calculate_position_size(
total_assets=0,
available_cash=0,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
)
assert result == 0.0
def test_multiplier_cap_at_4pct(self):
"""[CLAUDE.md 5.1] multiplier와 무관하게 절대 상한 4%"""
result = calculate_position_size(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=18.0, # 1.5×
adx=10.0, # +0.25 → 1.75
)
# CLAUDE.md 5.1: 4% 절대 상한 = 4M
assert result == pytest.approx(4_000_000)
# ═══════════════════════════════════════════════════════════════════════════
# 10. calculate_position_size_v2
# ═══════════════════════════════════════════════════════════════════════════
class TestCalculatePositionSizeV2:
"""포지션 사이징 v2 테스트 — RSI/ADX 보정 추가"""
def test_basic_no_adjustment(self):
"""RSI/ADX 없으면 base와 동일"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
)
# base = 100M * 4% * 1.0 = 4M, mult=1.0 → 4M
assert result == pytest.approx(4_000_000)
def test_rsi_deep_oversold_boost(self):
"""RSI <= 20 → ×1.2 보정"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=18.0,
)
# base = 4M (v1에서 rsi=None), mult = 1.2
# 4M * 1.2 = 4.8M, max_allowed = 100M * 4% = 4M → min(4.8M, 4M) = 4M
assert result == pytest.approx(4_000_000)
def test_rsi_moderate_oversold_boost(self):
"""RSI <= 25 → ×1.1 보정"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=23.0,
)
# base = 4M, mult = 1.1 → 4.4M, max_allowed = 4M → 4M
assert result == pytest.approx(4_000_000)
def test_adx_strong_trend_reduction(self):
"""ADX > 30 → ×0.8 감소"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
adx=35.0,
)
# base = 4M, mult = 0.8 → 3.2M, max_allowed = 4M → 3.2M
assert result == pytest.approx(3_200_000)
def test_combined_rsi_adx(self):
"""RSI <= 20 (×1.2) + ADX > 30 (×0.8) → ×0.96"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=18.0,
adx=35.0,
)
# base = 4M, mult = 1.2 * 0.8 = 0.96 → 3.84M
assert result == pytest.approx(3_840_000)
def test_max_allowed_cap(self):
"""보정 후에도 종목당 4% 상한 재적용"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
rsi=15.0, # ×1.2
)
max_allowed = 100_000_000 * 0.04 # 4M
assert result <= max_allowed
def test_zero_base_returns_zero(self):
"""base = 0 (예산 소진) → 0"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=50_000_000,
current_strategy_allocation=70_000_000,
regime=MarketRegime.BULL,
rsi=18.0,
)
assert result == 0.0
def test_cash_limited(self):
"""가용 현금 < 보정 사이즈 → 현금 제한"""
result = calculate_position_size_v2(
total_assets=100_000_000,
available_cash=1_000_000,
current_strategy_allocation=0,
regime=MarketRegime.BULL,
)
assert result == pytest.approx(1_000_000)