"""미국 주식 매매 전략 (us_stock_strategy.py) 테스트 테스트 시나리오: 1. check_us_buy_signal() — RSI/BB/거래량 조합별 매수 신호 2. check_us_exit_signal() — 손절/익절/트레일링 스탑 3. calculate_us_position_size() — 포지션 한도, 현금 제약 4. get_us_watchlist() — 감시 목록 유효성 5. 엣지 케이스 — 페니스탁 필터, 제로 거래량 """ import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import pytest from unittest.mock import patch, MagicMock from app.services.us_stock_strategy import ( check_us_buy_signal, check_us_exit_signal, calculate_us_position_size, get_us_watchlist, US_RSI_BUY_THRESHOLD, US_MIN_PRICE_USD, US_STOP_LOSS_PCT, US_FIRST_EXIT_RSI, US_FIRST_EXIT_SELL_PCT, US_TRAILING_STOP_PCT, US_MAX_PER_POSITION, US_MAX_CAPITAL_RATIO, US_ATR_STOP_MULT, ) # ─ 공통 픽스처 ───────────────────────────────────────────────────────────────── @pytest.fixture def bb_params(): """볼린저 밴드 기본 파라미터 (하단=95, 중앙=100, 상단=105)""" return { "bb_upper": 105.0, "bb_middle": 100.0, "bb_lower": 95.0, } @pytest.fixture def buy_signal_base(bb_params): """모든 매수 조건을 충족하는 기본 파라미터""" return { "rsi": 30.0, "close": 94.0, # BB 하단(95) * 0.97=92.15 ~ 95*1.02=96.9 구간 "avg_volume_20d": 1_000_000, "current_volume": 1_500_000, "current_price_usd": 50.0, "rsi_prev": 25.0, # RSI 반등 (25 → 30) **bb_params, } # ─ check_us_buy_signal 테스트 ────────────────────────────────────────────────── class TestCheckUsBuySignal: """미국 주식 매수 신호 테스트""" def test_all_conditions_met_returns_signal(self, buy_signal_base): """모든 조건 충족 시 매수 신호 반환""" result = check_us_buy_signal(**buy_signal_base) assert result is not None assert result["signal"] == "BUY" assert result["strategy"] == "US_MEAN_REVERSION" assert result["market"] == "US" def test_penny_stock_filtered(self, buy_signal_base): """$4 미만 페니스탁 필터링""" buy_signal_base["current_price_usd"] = 3.99 result = check_us_buy_signal(**buy_signal_base) assert result is None def test_exactly_min_price_passes(self, buy_signal_base): """정확히 $4.00은 필터링됨 (CLAUDE.md 5.4: $4 '초과' 조건, $4.00은 불가) [수정] us_stock_strategy.py의 `<= US_MIN_PRICE_USD` 조건과 일치: CLAUDE.md: "$4 초과" → $4.00 이하(=포함)는 진입 불가. $4.01부터 통과. """ buy_signal_base["current_price_usd"] = 4.0 result = check_us_buy_signal(**buy_signal_base) assert result is None # $4.00은 "초과" 조건 미충족 → 차단 def test_low_volume_filtered(self, buy_signal_base): """거래량 부족 시 필터링""" buy_signal_base["current_volume"] = 500_000 # < avg_volume_20d result = check_us_buy_signal(**buy_signal_base) assert result is None def test_zero_volume_filtered(self, buy_signal_base): """거래량 0 시 필터링""" buy_signal_base["current_volume"] = 0 result = check_us_buy_signal(**buy_signal_base) assert result is None def test_rsi_above_threshold_filtered(self, buy_signal_base): """RSI > 35 시 매수 거부""" buy_signal_base["rsi"] = 36.0 result = check_us_buy_signal(**buy_signal_base) assert result is None def test_rsi_exactly_threshold_passes(self, buy_signal_base): """RSI == 35 시 매수 허용""" buy_signal_base["rsi"] = 35.0 buy_signal_base["rsi_prev"] = 30.0 result = check_us_buy_signal(**buy_signal_base) assert result is not None def test_price_below_bb_lower_range(self, buy_signal_base): """가격이 BB 하단 -3% 미만이면 필터링""" # bb_lower=95, 95*0.97=92.15 → 92.0은 범위 밖 buy_signal_base["close"] = 92.0 result = check_us_buy_signal(**buy_signal_base) assert result is None def test_price_above_bb_lower_range(self, buy_signal_base): """가격이 BB 하단 +2% 초과이면 필터링""" # bb_lower=95, 95*1.02=96.9 → 97.0은 범위 밖 buy_signal_base["close"] = 97.0 result = check_us_buy_signal(**buy_signal_base) assert result is None def test_rsi_not_bouncing_filtered(self, buy_signal_base): """RSI 하락 중 (반등 미확인) 시 필터링""" buy_signal_base["rsi"] = 28.0 buy_signal_base["rsi_prev"] = 30.0 # rsi <= rsi_prev → 하락 중 result = check_us_buy_signal(**buy_signal_base) assert result is None def test_rsi_prev_none_skips_bounce_check(self, buy_signal_base): """rsi_prev=None이면 반등 체크 생략""" buy_signal_base["rsi_prev"] = None result = check_us_buy_signal(**buy_signal_base) assert result is not None def test_signal_contains_split_plan(self, buy_signal_base): """매수 신호에 분할 매수 계획 포함""" result = check_us_buy_signal(**buy_signal_base) assert "split_plan" in result assert isinstance(result["split_plan"], list) assert len(result["split_plan"]) == 2 # ─ check_us_exit_signal 테스트 ───────────────────────────────────────────────── class TestCheckUsExitSignal: """미국 주식 청산 신호 테스트""" @pytest.fixture def exit_base(self, bb_params): """청산 신호 기본 파라미터 (보유 중, 수익 상태)""" return { "rsi": 50.0, "close": 110.0, "entry_price": 100.0, "atr": 3.0, "remaining_pct": 1.0, "trailing_high": None, **bb_params, } def test_stop_loss_by_percentage(self, exit_base): """-7% 손절 작동""" # stop by pct: 100 * (1 + (-7/100)) = 93.0 # stop by atr: 100 - 2.5*3 = 92.5 # stop_price = max(92.5, 93.0) = 93.0 exit_base["close"] = 92.5 # <= 93.0 result = check_us_exit_signal(**exit_base) assert result is not None assert result.action == "SELL_ALL" assert result.sell_pct == 1.0 assert "손절" in result.reason def test_stop_loss_by_atr(self, exit_base): """ATR 기반 손절 (ATR 손절이 % 손절보다 높을 때)""" exit_base["atr"] = 2.0 # atr_stop = 100 - 2.5*2 = 95.0 # pct_stop = 100 * 0.93 = 93.0 # stop_price = max(95.0, 93.0) = 95.0 exit_base["close"] = 94.5 # <= 95.0 result = check_us_exit_signal(**exit_base) assert result is not None assert result.action == "SELL_ALL" assert "손절" in result.reason def test_first_exit_profit_taking(self, exit_base): """1차 익절 (RSI >= 55 + BB 중앙선 상회 + 1xATR 수익)""" exit_base["rsi"] = 56.0 exit_base["close"] = 104.0 # > bb_middle(100) + > entry(100) + 1*atr(3) = 103 exit_base["remaining_pct"] = 1.0 # > 0.7 → 1차 익절 가능 result = check_us_exit_signal(**exit_base) assert result is not None assert result.action == "SELL_PARTIAL" assert result.sell_pct == US_FIRST_EXIT_SELL_PCT assert "1차 익절" in result.reason def test_first_exit_blocked_if_insufficient_profit(self, exit_base): """1xATR 수익 미달 시 1차 익절 안 함""" exit_base["rsi"] = 56.0 exit_base["close"] = 102.0 # > bb_middle(100) BUT < entry(100)+1*atr(3)=103 exit_base["remaining_pct"] = 1.0 result = check_us_exit_signal(**exit_base) assert result is None def test_first_exit_skipped_if_already_partial_sold(self, exit_base): """이미 1차 익절 완료 (remaining <= 0.7) → 1차 익절 스킵""" exit_base["rsi"] = 56.0 exit_base["close"] = 104.0 exit_base["remaining_pct"] = 0.7 # <= 0.7 → 1차 익절 안 함 result = check_us_exit_signal(**exit_base) # 트레일링 스탑 조건 미충족이면 None assert result is None or result.action != "SELL_PARTIAL" or "1차" not in result.reason def test_trailing_stop_triggered(self, exit_base): """트레일링 스탑 -4% 작동""" exit_base["remaining_pct"] = 0.7 # <= 0.7 → 2차 익절 구간 exit_base["trailing_high"] = 115.0 # 최고가 # trail_stop = 115 * (1 - 0.04) = 110.4 exit_base["close"] = 110.0 # < 110.4 result = check_us_exit_signal(**exit_base) assert result is not None assert result.action == "SELL_ALL" assert "트레일링" in result.reason def test_trailing_stop_not_triggered_above_threshold(self, exit_base): """트레일링 스탑 미도달 시 청산 안 함""" exit_base["remaining_pct"] = 0.7 exit_base["trailing_high"] = 115.0 # trail_stop = 115 * 0.96 = 110.4 exit_base["close"] = 111.0 # > 110.4 exit_base["rsi"] = 50.0 # < 65 → BB 상단 익절도 안 함 result = check_us_exit_signal(**exit_base) assert result is None def test_bb_upper_exit(self, exit_base): """BB 상단 도달 시 50% 익절""" exit_base["remaining_pct"] = 0.5 # <= 0.7, > 0.25 exit_base["rsi"] = 66.0 # >= 65 exit_base["close"] = 104.5 # >= bb_upper(105) * 0.99 = 103.95 exit_base["trailing_high"] = None # 트레일링 안 함 result = check_us_exit_signal(**exit_base) assert result is not None assert result.sell_pct == 0.50 assert "2차 익절" in result.reason def test_no_exit_signal_in_normal_conditions(self, exit_base): """정상 보유 상태에서는 청산 신호 없음""" exit_base["rsi"] = 45.0 exit_base["close"] = 102.0 # 소폭 수익, 손절/익절 미도달 exit_base["remaining_pct"] = 1.0 result = check_us_exit_signal(**exit_base) assert result is None def test_stop_loss_priority_over_profit(self, exit_base): """손절이 익절보다 우선""" exit_base["close"] = 92.0 # 손절 라인 미만 exit_base["rsi"] = 56.0 # 익절 RSI 기준 충족하지만 가격이 낮음 result = check_us_exit_signal(**exit_base) assert result is not None assert result.action == "SELL_ALL" assert "손절" in result.reason # ─ calculate_us_position_size 테스트 ─────────────────────────────────────────── class TestCalculateUsPositionSize: """미국 주식 포지션 사이징 테스트""" def test_basic_position_size(self): """기본 포지션 사이징 (종목당 5%)""" result = calculate_us_position_size( total_assets_krw=100_000_000, # 1억원 available_cash_krw=50_000_000, # 5천만원 현금 current_us_allocation_krw=0, # 미국 주식 없음 exchange_rate=1350.0, ) # 종목당 최대: 1억 * 0.05 = 500만 # 미국 잔여: 1억 * 0.30 - 0 = 3000만 # 현금: 5000만 # min(500만, 3000만, 5000만) = 500만 assert result == 5_000_000 def test_limited_by_us_budget(self): """미국 주식 비중 한도 도달 시 제한""" result = calculate_us_position_size( total_assets_krw=100_000_000, available_cash_krw=50_000_000, current_us_allocation_krw=28_000_000, # 이미 28% 할당 exchange_rate=1350.0, ) # 미국 잔여: 1억 * 0.30 - 2800만 = 200만 # min(500만, 200만, 5000만) = 200만 assert result == 2_000_000 def test_limited_by_cash(self): """현금 부족 시 현금으로 제한""" result = calculate_us_position_size( total_assets_krw=100_000_000, available_cash_krw=3_000_000, # 300만 현금 current_us_allocation_krw=0, exchange_rate=1350.0, ) # min(500만, 3000만, 300만) = 300만 assert result == 3_000_000 def test_us_budget_exhausted(self): """미국 주식 예산 소진 시 0 반환""" result = calculate_us_position_size( total_assets_krw=100_000_000, available_cash_krw=50_000_000, current_us_allocation_krw=30_000_000, # 30% 이미 할당 exchange_rate=1350.0, ) assert result == 0.0 def test_over_allocated_returns_zero(self): """미국 주식 초과 할당 시 0 반환""" result = calculate_us_position_size( total_assets_krw=100_000_000, available_cash_krw=50_000_000, current_us_allocation_krw=35_000_000, # 35% > 30% 한도 exchange_rate=1350.0, ) assert result == 0.0 def test_zero_total_assets(self): """총 자산 0 시 0 반환""" result = calculate_us_position_size( total_assets_krw=0, available_cash_krw=0, current_us_allocation_krw=0, exchange_rate=1350.0, ) assert result == 0.0 def test_zero_exchange_rate(self): """환율 0 시 0 반환""" result = calculate_us_position_size( total_assets_krw=100_000_000, available_cash_krw=50_000_000, current_us_allocation_krw=0, exchange_rate=0, ) assert result == 0.0 def test_negative_total_assets(self): """총 자산 음수 시 0 반환""" result = calculate_us_position_size( total_assets_krw=-100_000_000, available_cash_krw=50_000_000, current_us_allocation_krw=0, exchange_rate=1350.0, ) assert result == 0.0 def test_custom_max_per_position(self): """종목당 비중 커스텀 설정""" result = calculate_us_position_size( total_assets_krw=100_000_000, available_cash_krw=50_000_000, current_us_allocation_krw=0, exchange_rate=1350.0, max_per_position=0.10, # 10% ) # min(1000만, 3000만, 5000만) = 1000만 assert result == 10_000_000 # ─ get_us_watchlist 테스트 ───────────────────────────────────────────────────── class TestGetUsWatchlist: """감시 목록 테스트""" def test_returns_non_empty_list(self): """빈 리스트가 아님""" watchlist = get_us_watchlist() assert len(watchlist) > 0 def test_returns_valid_tickers(self): """모든 항목이 문자열이고 비어있지 않음""" watchlist = get_us_watchlist() for ticker in watchlist: assert isinstance(ticker, str) assert len(ticker) > 0 def test_contains_major_tickers(self): """주요 대형주 포함 확인""" watchlist = get_us_watchlist() major = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA"] for ticker in major: assert ticker in watchlist, f"{ticker} not in watchlist" def test_no_duplicates(self): """중복 티커 없음""" watchlist = get_us_watchlist() assert len(watchlist) == len(set(watchlist)) def test_sufficient_count(self): """충분한 수의 종목 (최소 30개)""" watchlist = get_us_watchlist() assert len(watchlist) >= 30 if __name__ == "__main__": pytest.main([__file__, "-v"])