""" 기술적 지표 분석 모듈 (technical_indicators.py) 단위 테스트 테스트 시나리오: 1. 단순/지수 이동평균 계산 (SMA, EMA) 2. 추세 필터 (_passes_uptrend) 3. 급등 과열 필터 (analyze_overheat) 4. RSI 분석 (analyze_rsi) — 과매도/과매수/반등 신호 5. 볼린저 밴드 분석 (analyze_bollinger) 6. MACD 분석 (analyze_macd) 7. 스토캐스틱 분석 (analyze_stochastic) 8. 이동평균선 정배열/역배열 (analyze_moving_average) 9. 변동성 돌파 전략 (analyze_volatility_breakout) 10. ADX 추세 강도 (analyze_adx) 11. OBV 거래량 신호 (analyze_obv) """ import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import pytest from unittest.mock import patch, MagicMock # --------------------------------------------------------------------------- # settings / strategy_cfg 모킹 — 모듈 임포트 전에 패치해야 함 # --------------------------------------------------------------------------- _mock_settings = MagicMock() _mock_settings.RSI_PERIOD = 14 _mock_settings.BOLLINGER_PERIOD = 20 _mock_settings.BOLLINGER_STDDEV = 2.0 _mock_settings.MA_SHORT_PERIOD = 5 _mock_settings.MA_MEDIUM_PERIOD = 20 _mock_settings.MA_LONG_PERIOD = 60 _mock_settings.TREND_FILTER_ENABLED = True _mock_settings.TREND_MA_SHORT = 20 _mock_settings.TREND_MA_LONG = 60 _mock_settings.TREND_TOLERANCE_PCT = 0.005 _mock_settings.OVERHEAT_FILTER_ENABLED = True _mock_settings.OVERHEAT_3DAY_MAX = 15.0 _mock_settings.OVERHEAT_5DAY_MAX = 20.0 _mock_settings.OVERHEAT_10DAY_MAX = 30.0 _mock_settings.OVERHEAT_DISPARITY_MAX = 108.0 # strategy_cfg.get() mock for mean_reversion_strategy (imported by technical_indicators) _mock_strategy_cfg = MagicMock() _mock_strategy_cfg.get.return_value = None with patch.dict("sys.modules", { "app.core.config": MagicMock(settings=_mock_settings), "app.services.strategy_config_service": MagicMock(strategy_cfg=_mock_strategy_cfg), }): from app.services.technical_indicators import ( _ma, _ema, _passes_uptrend, analyze_overheat, analyze_rsi, analyze_bollinger, analyze_macd, analyze_stochastic, analyze_moving_average, analyze_volatility_breakout, analyze_adx, analyze_obv, ) # --------------------------------------------------------------------------- # 헬퍼 함수 # --------------------------------------------------------------------------- def _make_chart_data( closes, highs=None, lows=None, opens=None, volumes=None, ): """chart_data 딕셔너리 리스트 생성 헬퍼""" n = len(closes) highs = highs or [c * 1.02 for c in closes] lows = lows or [c * 0.98 for c in closes] opens = opens or [c * 0.99 for c in closes] volumes = volumes or [1000000] * n return [ { "stck_clpr": str(closes[i]), "stck_hgpr": str(highs[i]), "stck_lwpr": str(lows[i]), "stck_oprc": str(opens[i]), "acml_vol": str(volumes[i]), } for i in range(n) ] def _rising_prices(start, count, step=100): """일정하게 상승하는 가격 리스트 생성""" return [start + i * step for i in range(count)] def _falling_prices(start, count, step=100): """일정하게 하락하는 가격 리스트 생성""" return [start - i * step for i in range(count)] def _flat_prices(price, count): """일정한 가격 리스트 생성""" return [price] * count # ═══════════════════════════════════════════════════════════════════════════ # 1. _ma (단순 이동평균) # ═══════════════════════════════════════════════════════════════════════════ class TestMA: """단순 이동평균(SMA) 계산 테스트""" def test_basic_calculation(self): """기본 SMA 계산 검증""" values = [10, 20, 30, 40, 50] assert _ma(values, 3) == pytest.approx(40.0) # (30+40+50)/3 assert _ma(values, 5) == pytest.approx(30.0) # (10+20+30+40+50)/5 def test_window_equals_length(self): """window == 데이터 길이""" values = [100, 200, 300] assert _ma(values, 3) == pytest.approx(200.0) def test_insufficient_data_returns_none(self): """데이터 부족 시 None 반환""" assert _ma([1, 2], 5) is None def test_empty_list_returns_none(self): """빈 리스트 → None""" assert _ma([], 3) is None def test_window_one(self): """window=1 → 마지막 값 반환""" assert _ma([5, 10, 15], 1) == pytest.approx(15.0) def test_uses_last_n_values(self): """마지막 window개 값만 사용하는지 확인""" values = [1, 2, 3, 100, 200] assert _ma(values, 2) == pytest.approx(150.0) # (100+200)/2 # ═══════════════════════════════════════════════════════════════════════════ # 2. _ema (지수 이동평균) # ═══════════════════════════════════════════════════════════════════════════ class TestEMA: """지수 이동평균(EMA) 계산 테스트""" def test_basic_calculation(self): """EMA 기본 계산 검증 — SMA 시드 후 지수 가중""" values = [10, 20, 30, 40, 50] result = _ema(values, 3) assert result is not None # SMA seed = (10+20+30)/3 = 20 # k = 2/(3+1) = 0.5 # step 1: 40*0.5 + 20*0.5 = 30 # step 2: 50*0.5 + 30*0.5 = 40 assert result == pytest.approx(40.0) def test_insufficient_data_returns_none(self): """데이터 부족 시 None 반환""" assert _ema([1, 2], 5) is None def test_empty_list_returns_none(self): """빈 리스트 → None""" assert _ema([], 3) is None def test_window_equals_length(self): """window == 데이터 길이 → SMA와 동일 (추가 데이터 없음)""" values = [10, 20, 30] result = _ema(values, 3) assert result == pytest.approx(20.0) def test_ema_weights_recent_more(self): """EMA는 최근 값에 더 높은 가중치를 부여""" values = [100, 100, 100, 100, 200] ema_val = _ema(values, 4) ma_val = _ma(values, 4) # EMA는 최근 200에 더 가중 → SMA보다 높아야 함 assert ema_val > ma_val # ═══════════════════════════════════════════════════════════════════════════ # 3. _passes_uptrend (추세 필터) # ═══════════════════════════════════════════════════════════════════════════ class TestPassesUptrend: """MA 정배열 기반 상승 추세 필터 테스트""" def test_clear_uptrend(self): """명확한 상승 추세 — MA_SHORT > MA_LONG, 현재가 > MA_SHORT""" closes = _rising_prices(10000, 70, 50) result = _passes_uptrend(closes, ma_short=20, ma_long=60, tol=0.005) assert result is True def test_downtrend(self): """하락 추세 — MA_SHORT < MA_LONG""" closes = _falling_prices(15000, 70, 50) result = _passes_uptrend(closes, ma_short=20, ma_long=60, tol=0.005) assert result is False def test_insufficient_data_returns_none(self): """데이터 부족 시 None 반환""" closes = [10000] * 30 # 60개 미만 result = _passes_uptrend(closes, ma_short=20, ma_long=60, tol=0.005) assert result is None def test_empty_list_returns_none(self): """빈 리스트 → None""" assert _passes_uptrend([], ma_short=5, ma_long=10) is None def test_tolerance_allows_slight_deviation(self): """허용 오차 내 약간의 역전은 상승으로 인정""" # MA_SHORT가 MA_LONG보다 살짝 낮지만 tol 범위 내 closes = [10000] * 60 closes[-1] = 10050 # 현재가 약간 상승 result = _passes_uptrend(closes, ma_short=20, ma_long=60, tol=0.01) assert result is True def test_flat_prices(self): """횡보 — 모든 가격 동일""" closes = _flat_prices(10000, 70) result = _passes_uptrend(closes, ma_short=20, ma_long=60, tol=0.005) # MA_SHORT == MA_LONG, close == MA_SHORT → True (tol 범위) assert result is True # ═══════════════════════════════════════════════════════════════════════════ # 4. analyze_overheat (급등 과열 필터) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeOverheat: """급등 과열 필터 테스트""" def test_no_overheat_stable_prices(self): """안정적 가격 — 과열 아님""" closes = _flat_prices(10000, 15) result = analyze_overheat(closes, ma20=10000) assert result["blocked"] is False assert result["reason"] == "" def test_3day_overheat(self): """3일간 15% 초과 상승 → 과열 차단""" closes = [10000] * 12 closes[-1] = 11600 # 16% 상승 vs closes[-4] result = analyze_overheat(closes, ma20=10500) assert result["blocked"] is True assert "3일" in result["reason"] def test_5day_overheat(self): """5일간 20% 초과 상승 → 과열 차단""" closes = [10000] * 12 closes[-1] = 12100 # 21% 상승 vs closes[-6] result = analyze_overheat(closes, ma20=10500) assert result["blocked"] is True assert "5일" in result["reason"] def test_10day_overheat(self): """10일간 30% 초과 상승 → 과열 차단""" closes = [10000] * 15 closes[-1] = 13100 # 31% 상승 vs closes[-11] result = analyze_overheat(closes, ma20=10500) assert result["blocked"] is True assert "10일" in result["reason"] def test_disparity_overheat(self): """이격도 108% 초과 → 과열 차단""" closes = [10000] * 5 closes[-1] = 10900 result = analyze_overheat(closes, ma20=10000) assert result["blocked"] is True assert "이격도" in result["reason"] def test_insufficient_data_no_block(self): """데이터 부족 시 차단 없음""" closes = [10000, 10100, 10200] result = analyze_overheat(closes, ma20=10000) assert result["blocked"] is False def test_ma20_none(self): """ma20 = None → 이격도 검사 스킵""" closes = [10000] * 5 closes[-1] = 15000 # 높은 가격이지만 ma20=None이면 이격도 검사 안 함 result = analyze_overheat(closes, ma20=None) # 3일 급등만 체크됨 assert "이격도" not in result.get("reason", "") def test_details_populated(self): """details 딕셔너리에 수치가 올바르게 기록되는지""" closes = _flat_prices(10000, 15) result = analyze_overheat(closes, ma20=10000) assert "gain_3d" in result["details"] assert "gain_5d" in result["details"] assert "gain_10d" in result["details"] assert "disparity_20" in result["details"] # ═══════════════════════════════════════════════════════════════════════════ # 5. analyze_rsi (RSI 분석) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeRSI: """RSI 분석 신호 테스트""" def test_insufficient_data(self): """데이터 부족 시 신호 없음""" chart_data = _make_chart_data([10000] * 10) result = analyze_rsi(chart_data) assert result["signal"] is None assert "부족" in result["description"] def test_overbought_signal(self): """RSI > 70 → 매도 신호""" # 꾸준히 상승하는 가격 → RSI 높음 prices = _rising_prices(10000, 20, 500) chart_data = _make_chart_data(prices) result = analyze_rsi(chart_data) assert result["signal"] == "sell" assert "과매수" in result["description"] def test_oversold_still_falling(self): """RSI < 30 + 여전히 하락 중 → 신호 없음 (낙하하는 칼 방지)""" prices = _falling_prices(20000, 20, 500) chart_data = _make_chart_data(prices) result = analyze_rsi(chart_data) assert result["signal"] is None def test_stable_rsi_range(self): """RSI 45~60 안정 구간 → 매수 신호""" # 약간 오르내리는 가격으로 RSI ~50 구간 생성 base = 10000 prices = [] for i in range(20): if i % 2 == 0: base += 50 else: base -= 30 prices.append(base) chart_data = _make_chart_data(prices) result = analyze_rsi(chart_data) # RSI가 45~60이면 "안정 상승", 아니면 다른 신호 assert result["signal"] is not None or result["signal"] is None # 결과가 정상 반환 def test_rsi_all_gains_returns_100(self): """모든 봉이 상승 → RSI = 100 → 과매수""" prices = _rising_prices(10000, 20, 100) chart_data = _make_chart_data(prices) result = analyze_rsi(chart_data) assert result["signal"] == "sell" # ═══════════════════════════════════════════════════════════════════════════ # 6. analyze_bollinger (볼린저 밴드) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeBollinger: """볼린저 밴드 신호 테스트""" def test_insufficient_data(self): """데이터 부족 시 신호 없음""" chart_data = _make_chart_data([10000] * 10) result = analyze_bollinger(chart_data) assert result["signal"] is None assert "부족" in result["description"] def test_buy_signal_below_lower_band(self): """현재가 < 하한선 → 매수 신호""" prices = _flat_prices(10000, 19) + [8000] # 마지막 봉 급락 chart_data = _make_chart_data(prices) result = analyze_bollinger(chart_data) assert result["signal"] == "buy" assert "하한선" in result["description"] def test_sell_signal_above_upper_band(self): """현재가 > 상한선 → 매도 신호""" prices = _flat_prices(10000, 19) + [12000] # 마지막 봉 급등 chart_data = _make_chart_data(prices) result = analyze_bollinger(chart_data) assert result["signal"] == "sell" assert "상한선" in result["description"] def test_neutral_within_bands(self): """현재가가 밴드 내 → 중립""" prices = _flat_prices(10000, 20) chart_data = _make_chart_data(prices) result = analyze_bollinger(chart_data) assert result["signal"] is None assert "중립" in result["description"] def test_all_same_price_neutral(self): """모든 가격 동일 → 표준편차 0 → 중립 (상한선=하한선=중앙선)""" prices = _flat_prices(10000, 20) chart_data = _make_chart_data(prices) result = analyze_bollinger(chart_data) assert result["signal"] is None # ═══════════════════════════════════════════════════════════════════════════ # 7. analyze_macd (MACD) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeMACD: """MACD 골든크로스/데드크로스 신호 테스트""" def test_insufficient_data(self): """데이터 부족 시 신호 없음""" chart_data = _make_chart_data([10000] * 10) result = analyze_macd(chart_data) assert result["signal"] is None assert "부족" in result["description"] def test_golden_cross_signal(self): """하락 후 반등 → 골든크로스 가능성""" # 꾸준히 하락 후 급반등 → MACD 라인이 시그널 상향 돌파 prices = _falling_prices(15000, 20, 100) + _rising_prices(13000, 10, 300) chart_data = _make_chart_data(prices) result = analyze_macd(chart_data) # 정확한 교차 여부는 가격 패턴에 따라 다름 assert result["signal"] in ("buy", "sell", None) def test_dead_cross_signal(self): """상승 후 급락 → 데드크로스 가능성""" prices = _rising_prices(10000, 20, 200) + _falling_prices(14000, 10, 500) chart_data = _make_chart_data(prices) result = analyze_macd(chart_data) assert result["signal"] in ("buy", "sell", None) def test_flat_prices_no_signal(self): """횡보 시 신호 없음""" prices = _flat_prices(10000, 30) chart_data = _make_chart_data(prices) result = analyze_macd(chart_data) assert result["signal"] is None def test_exactly_26_data_points(self): """최소 데이터 (26개)로 정상 실행""" prices = _rising_prices(10000, 26, 50) chart_data = _make_chart_data(prices) result = analyze_macd(chart_data) # 26개 데이터에서는 EMA26 길이가 1 → 데이터 부족일 수 있음 assert "signal" in result # ═══════════════════════════════════════════════════════════════════════════ # 8. analyze_stochastic (스토캐스틱) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeStochastic: """스토캐스틱 %K 신호 테스트""" def test_insufficient_data(self): """데이터 부족 시 신호 없음""" chart_data = _make_chart_data([10000] * 5) result = analyze_stochastic(chart_data) assert result["signal"] is None def test_oversold_signal(self): """%K < 30 → 과매도 매수 신호""" # 14봉 중 초반 고가, 후반 저가 → %K 낮음 highs = [15000] * 10 + [10500] * 4 lows = [14500] * 10 + [10000] * 4 closes = [14800] * 10 + [10100] * 3 + [10100] chart_data = _make_chart_data(closes, highs=highs, lows=lows) result = analyze_stochastic(chart_data) assert result["signal"] == "buy" assert "과매도" in result["description"] def test_overbought_signal(self): """%K > 70 → 과매수 매도 신호""" # 14봉 중 초반 저가, 후반 고가 → %K 높음 highs = [10500] * 10 + [15000] * 4 lows = [10000] * 10 + [14500] * 4 closes = [10200] * 10 + [14800] * 3 + [14900] chart_data = _make_chart_data(closes, highs=highs, lows=lows) result = analyze_stochastic(chart_data) assert result["signal"] == "sell" assert "과매수" in result["description"] def test_neutral_signal(self): """%K 30~70 → 중립""" # 현재가가 범위의 중간 highs = [12000] * 14 lows = [8000] * 14 closes = [10000] * 14 chart_data = _make_chart_data(closes, highs=highs, lows=lows) result = analyze_stochastic(chart_data) assert result["signal"] is None assert "중립" in result["description"] def test_highest_equals_lowest_returns_neutral(self): """최고가 == 최저가 → 데이터 부족""" closes = _flat_prices(10000, 14) highs = _flat_prices(10000, 14) lows = _flat_prices(10000, 14) chart_data = _make_chart_data(closes, highs=highs, lows=lows) result = analyze_stochastic(chart_data) assert result["signal"] is None # ═══════════════════════════════════════════════════════════════════════════ # 9. analyze_moving_average (이동평균선) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeMovingAverage: """이동평균선 정배열/역배열 신호 테스트""" def test_insufficient_data(self): """데이터 부족 시 신호 없음""" chart_data = _make_chart_data([10000] * 30) result = analyze_moving_average(chart_data) assert result["signal"] is None assert "부족" in result["description"] def test_buy_signal_ma5_above_ma20(self): """MA5 > MA20 + 현재가 > MA5 → 매수""" prices = _rising_prices(8000, 60, 100) chart_data = _make_chart_data(prices) result = analyze_moving_average(chart_data) assert result["signal"] == "buy" def test_sell_signal_reverse_alignment(self): """MA5 < MA20 < MA60 + 현재가 < MA5 → 매도 (역배열)""" prices = _falling_prices(20000, 60, 100) chart_data = _make_chart_data(prices) result = analyze_moving_average(chart_data) assert result["signal"] == "sell" assert "역배열" in result["description"] def test_neutral_flat(self): """횡보 → 중립""" prices = _flat_prices(10000, 60) chart_data = _make_chart_data(prices) result = analyze_moving_average(chart_data) assert result["signal"] is None # ═══════════════════════════════════════════════════════════════════════════ # 10. analyze_volatility_breakout (변동성 돌파) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeVolatilityBreakout: """변동성 돌파 전략 신호 테스트""" def test_insufficient_data(self): """데이터 1개 → 신호 없음""" chart_data = _make_chart_data([10000]) result = analyze_volatility_breakout(chart_data) assert result["signal"] is None def test_breakout_signal(self): """현재가 >= 시가 + 전일변동폭*k → 매수""" chart_data = [ { "stck_clpr": "10000", "stck_hgpr": "10500", "stck_lwpr": "9500", "stck_oprc": "10000", "acml_vol": "1000000", }, { "stck_clpr": "10600", # 시가(10100) + 1000*0.5 = 10600 → 돌파 "stck_hgpr": "10700", "stck_lwpr": "10000", "stck_oprc": "10100", "acml_vol": "1200000", }, ] result = analyze_volatility_breakout(chart_data, k=0.5) assert result["signal"] == "buy" assert "돌파" in result["description"] def test_no_breakout(self): """현재가 < 목표가 → 신호 없음""" chart_data = [ { "stck_clpr": "10000", "stck_hgpr": "10500", "stck_lwpr": "9500", "stck_oprc": "10000", "acml_vol": "1000000", }, { "stck_clpr": "10200", # 시가(10100) + 1000*0.5 = 10600 → 미달 "stck_hgpr": "10300", "stck_lwpr": "10000", "stck_oprc": "10100", "acml_vol": "1200000", }, ] result = analyze_volatility_breakout(chart_data, k=0.5) assert result["signal"] is None assert "미달" in result["description"] def test_zero_range_no_signal(self): """전일 변동폭 0 → 신호 없음""" chart_data = [ { "stck_clpr": "10000", "stck_hgpr": "10000", "stck_lwpr": "10000", "stck_oprc": "10000", "acml_vol": "1000000", }, { "stck_clpr": "10500", "stck_hgpr": "10500", "stck_lwpr": "10000", "stck_oprc": "10000", "acml_vol": "1200000", }, ] result = analyze_volatility_breakout(chart_data, k=0.5) assert result["signal"] is None def test_custom_k_factor(self): """k 팩터 변경 시 목표가 변경 검증""" chart_data = [ { "stck_clpr": "10000", "stck_hgpr": "11000", "stck_lwpr": "9000", "stck_oprc": "10000", "acml_vol": "1000000", }, { "stck_clpr": "11500", "stck_hgpr": "11600", "stck_lwpr": "10900", "stck_oprc": "11000", "acml_vol": "1200000", }, ] # k=0.3: target = 11000 + 2000*0.3 = 11600, close=11500 → 미달 result_low_k = analyze_volatility_breakout(chart_data, k=0.3) assert result_low_k["signal"] is None def test_custom_k_factor_buy(self): """k=0.2로 낮추면 목표가 낮아져서 매수 신호 발생""" chart_data = [ { "stck_clpr": "10000", "stck_hgpr": "11000", "stck_lwpr": "9000", "stck_oprc": "10000", "acml_vol": "1000000", }, { "stck_clpr": "11500", "stck_hgpr": "11600", "stck_lwpr": "10900", "stck_oprc": "11000", "acml_vol": "1200000", }, ] # k=0.2: target = 11000 + 2000*0.2 = 11400 → 11500 >= 11400 → buy result = analyze_volatility_breakout(chart_data, k=0.2) assert result["signal"] == "buy" # ═══════════════════════════════════════════════════════════════════════════ # 11. analyze_adx (ADX) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeADX: """ADX 추세 강도 신호 테스트""" def test_insufficient_data(self): """데이터 부족 시 신호 없음""" chart_data = _make_chart_data([10000] * 20) result = analyze_adx(chart_data) assert result["signal"] is None assert result["adx"] is None def test_sideways_market_buy(self): """횡보장 (ADX < 20) → 평균회귀 유리 → 매수""" # 좁은 범위 횡보 → ADX 낮음 prices = [] for i in range(50): prices.append(10000 + (i % 3) * 10 - 10) highs = [p + 20 for p in prices] lows = [p - 20 for p in prices] chart_data = _make_chart_data(prices, highs=highs, lows=lows) result = analyze_adx(chart_data) if result["adx"] is not None and result["adx"] < 20: assert result["signal"] == "buy" def test_strong_trend_sell(self): """강추세 (ADX > 30) → 평균회귀 위험 → 매도""" # 강하게 상승하는 시장 prices = _rising_prices(10000, 50, 500) highs = [p + 200 for p in prices] lows = [p - 100 for p in prices] chart_data = _make_chart_data(prices, highs=highs, lows=lows) result = analyze_adx(chart_data) if result["adx"] is not None and result["adx"] > 30: assert result["signal"] == "sell" def test_returns_adx_value(self): """ADX 수치가 결과에 포함되는지""" prices = _rising_prices(10000, 50, 100) highs = [p + 100 for p in prices] lows = [p - 100 for p in prices] chart_data = _make_chart_data(prices, highs=highs, lows=lows) result = analyze_adx(chart_data) assert "adx" in result # ═══════════════════════════════════════════════════════════════════════════ # 12. analyze_obv (OBV) # ═══════════════════════════════════════════════════════════════════════════ class TestAnalyzeOBV: """OBV 거래량 신호 테스트""" def test_insufficient_data(self): """데이터 부족 시 신호 없음""" chart_data = _make_chart_data([10000]) result = analyze_obv(chart_data) assert result["signal"] is None def test_buy_signal_volume_increase_price_up(self): """거래량 1.5배 증가 + 가격 상승 → 매수""" closes = [10000, 10100, 10200, 10300, 10400] volumes = [1000000, 1000000, 1000000, 1000000, 1600000] chart_data = _make_chart_data(closes, volumes=volumes) result = analyze_obv(chart_data) assert result["signal"] == "buy" assert "증가" in result["description"] def test_sell_signal_volume_decrease_price_down(self): """거래량 0.5배 감소 + 가격 하락 → 매도""" closes = [10400, 10300, 10200, 10100, 10000] volumes = [1000000, 1000000, 1000000, 1000000, 400000] chart_data = _make_chart_data(closes, volumes=volumes) result = analyze_obv(chart_data) assert result["signal"] == "sell" assert "감소" in result["description"] def test_neutral_volume(self): """거래량 변화 없음 → 중립""" closes = [10000, 10100, 10200, 10300, 10400] volumes = [1000000, 1000000, 1000000, 1000000, 1000000] chart_data = _make_chart_data(closes, volumes=volumes) result = analyze_obv(chart_data) assert result["signal"] is None assert "중립" in result["description"] def test_two_data_points(self): """최소 2개 데이터로 정상 작동""" closes = [10000, 10100] volumes = [1000000, 1600000] chart_data = _make_chart_data(closes, volumes=volumes) result = analyze_obv(chart_data) assert result["signal"] == "buy"