fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
417
backend/tests/test_us_stock_strategy.py
Normal file
417
backend/tests/test_us_stock_strategy.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""미국 주식 매매 전략 (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"])
|
||||
Reference in New Issue
Block a user