Files
auto-stock/backend/tests/test_mean_reversion.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

1101 lines
43 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)
# ---------------------------------------------------------------------------
# [2026-09-27] import 순서 의존 제거
#
# 아래 patch.dict("sys.modules", ...) 는 **이 파일이 먼저 import될 때만** 먹는다.
# 전체 스위트에서는 알파벳 앞선 테스트들이 이미 app.services.mean_reversion_strategy 를
# 로드해 두므로, 그 모듈은 **실제** strategy_cfg 를 전역에 바인딩한 상태가 된다.
# 그러면 목이 새어 strategy_cfg.get() → None → settings.MEAN_REVERSION_RSI_THRESHOLD(46)
# 이 쓰이고, RSI 임계 30 을 전제한 케이스(test_bull_rsi_too_high: rsi=45)가 깨졌다.
# (단독 실행 74 passed / 전체 스위트 1 failed 로 나타난 순서 의존 버그)
#
# → 모듈 전역 참조를 **테스트 시점에** 덮는 autouse 픽스처로 바꿔, import 순서와 무관하게
# 항상 같은 목이 쓰이도록 한다. 기대값은 하나도 바꾸지 않았다.
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _bind_mock_strategy_cfg():
import app.services.mean_reversion_strategy as _m
with patch.object(_m, "strategy_cfg", _mock_strategy_cfg):
yield
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)