## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 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>
1101 lines
43 KiB
Python
1101 lines
43 KiB
Python
"""
|
||
평균회귀 전략 (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)
|