Files
auto-stock/backend/tests/test_stock_pattern_memory.py
shuim de7921d270 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>
2026-03-26 16:05:41 +09:00

329 lines
16 KiB
Python

"""종목별 패턴 메모리 시스템 유닛 테스트.
순수 로직 함수만 테스트 (DB/Redis 비의존).
"""
import pytest
from app.services.stock_pattern_memory import (
StockProfile,
analyze_support_resistance,
analyze_rsi_bounce_history,
analyze_volume_pattern,
calculate_stock_beta,
get_dynamic_rsi_threshold,
get_dynamic_stop_loss,
is_near_support,
is_near_resistance,
get_entry_confidence_boost,
_calculate_rsi_series,
_calculate_daily_range_pct,
)
# ═════════════════════════════════════════════════════════════════════════════
# StockProfile 직렬화
# ═════════════════════════════════════════════════════════════════════════════
class TestStockProfile:
def test_to_dict_and_from_dict_roundtrip(self):
profile = StockProfile(
stock_code="005930",
stock_name="삼성전자",
support_levels=[58000.0, 60000.0],
resistance_levels=[75000.0, 80000.0],
win_rate=0.65,
total_trades=20,
)
d = profile.to_dict()
restored = StockProfile.from_dict(d)
assert restored.stock_code == "005930"
assert restored.support_levels == [58000.0, 60000.0]
assert restored.win_rate == 0.65
def test_from_dict_ignores_unknown_keys(self):
data = {"stock_code": "005930", "unknown_field": 42}
profile = StockProfile.from_dict(data)
assert profile.stock_code == "005930"
# ═════════════════════════════════════════════════════════════════════════════
# 지지/저항선 분석
# ═════════════════════════════════════════════════════════════════════════════
class TestSupportResistance:
def _generate_sine_data(self, n=120, base=50000, amplitude=5000):
"""지지/저항선이 명확한 사인파 데이터 생성."""
import math
closes = [base + amplitude * math.sin(2 * math.pi * i / 20) for i in range(n)]
highs = [c + 200 for c in closes]
lows = [c - 200 for c in closes]
return closes, highs, lows
def test_detects_levels_from_sine_wave(self):
closes, highs, lows = self._generate_sine_data()
supports, resistances = analyze_support_resistance(closes, highs, lows, window=100)
assert len(supports) > 0, "지지선이 하나 이상 탐지되어야 한다"
assert len(resistances) > 0, "저항선이 하나 이상 탐지되어야 한다"
def test_returns_empty_on_insufficient_data(self):
supports, resistances = analyze_support_resistance([1, 2, 3], [2, 3, 4], [0, 1, 2])
assert supports == []
assert resistances == []
def test_max_five_levels(self):
"""최대 5개 레벨만 반환."""
closes, highs, lows = self._generate_sine_data(n=300)
supports, resistances = analyze_support_resistance(closes, highs, lows, window=200)
assert len(supports) <= 5
assert len(resistances) <= 5
# ═════════════════════════════════════════════════════════════════════════════
# RSI 반등 통계
# ═════════════════════════════════════════════════════════════════════════════
class TestRsiBounceHistory:
def test_default_on_insufficient_data(self):
avg, mn, top = analyze_rsi_bounce_history([100, 101, 102])
assert avg == 30.0
assert mn == 20.0
assert top == 70.0
def test_finds_bounce_points(self):
"""충분한 데이터로 반등 통계 산출."""
import math
# RSI가 진동하도록 가격 생성 (120일)
closes = [50000 + 3000 * math.sin(2 * math.pi * i / 14) for i in range(120)]
avg, mn, top = analyze_rsi_bounce_history(closes)
# 기본값이 아닌 값이 산출되어야 한다
assert isinstance(avg, float)
assert isinstance(mn, float)
assert isinstance(top, float)
# ═════════════════════════════════════════════════════════════════════════════
# 거래량 패턴
# ═════════════════════════════════════════════════════════════════════════════
class TestVolumePattern:
def test_default_on_insufficient_data(self):
threshold, avg = analyze_volume_pattern([100, 200])
assert threshold == 2.0
assert avg == 0.0
def test_spike_threshold_calculation(self):
# 일반 거래량 + 간헐적 급증
volumes = [1000] * 50 + [5000, 6000, 7000] # 53일
threshold, avg = analyze_volume_pattern(volumes)
assert threshold >= 1.5
assert threshold <= 5.0
assert avg > 0
def test_clamp_range(self):
# 거래량이 균일하면 spike_threshold가 낮게 나옴 → 1.5 하한 적용
volumes = [1000] * 60
threshold, avg = analyze_volume_pattern(volumes)
assert threshold >= 1.5
# ═════════════════════════════════════════════════════════════════════════════
# 베타 계산
# ═════════════════════════════════════════════════════════════════════════════
class TestBeta:
def test_identical_series_beta_one(self):
"""동일한 수익률이면 베타 = 1.0."""
closes = [100 + i for i in range(60)]
beta = calculate_stock_beta(closes, closes)
assert abs(beta - 1.0) < 0.1
def test_insufficient_data(self):
beta = calculate_stock_beta([100, 101], [200, 201])
assert beta == 1.0
def test_inverse_beta_negative(self):
"""역방향 수익률이면 베타 < 0."""
import math
# 사인파 사용: 주기가 같고 위상이 반대
stock = [100 + 10 * math.sin(2 * math.pi * i / 20) for i in range(60)]
index = [100 - 10 * math.sin(2 * math.pi * i / 20) for i in range(60)]
beta = calculate_stock_beta(stock, index)
assert beta < 0
# ═════════════════════════════════════════════════════════════════════════════
# 동적 RSI 임계값
# ═════════════════════════════════════════════════════════════════════════════
class TestDynamicRsiThreshold:
def test_insufficient_trades_returns_default(self):
profile = StockProfile(stock_code="005930", total_trades=1)
# total_trades < 3 → 기본값 settings.MEAN_REVERSION_RSI_THRESHOLD (46.0)
threshold = get_dynamic_rsi_threshold(profile)
assert threshold == 46.0
def test_low_bounce_avg(self):
"""반등 평균 RSI가 낮은 종목 → 낮은 임계값."""
profile = StockProfile(
stock_code="373220", stock_name="에코프로",
rsi_bounce_avg=22.0, total_trades=10,
)
threshold = get_dynamic_rsi_threshold(profile)
assert threshold == 35.0 # 22 + 5 = 27 → 하한 클램프 35 (BEAR_RSI_THRESHOLD)
def test_high_bounce_avg(self):
"""반등 평균 RSI가 높은 종목 → 상한 클램프."""
profile = StockProfile(
stock_code="005930", stock_name="삼성전자",
rsi_bounce_avg=38.0, total_trades=10,
)
threshold = get_dynamic_rsi_threshold(profile)
assert threshold == 43.0 # 38 + 5 = 43 → 상한 46이므로 클램프 안 됨
def test_normal_bounce_avg(self):
profile = StockProfile(
stock_code="035720", stock_name="카카오",
rsi_bounce_avg=32.0, total_trades=10,
)
threshold = get_dynamic_rsi_threshold(profile)
assert threshold == 37.0 # 32 + 5 = 37
# ═════════════════════════════════════════════════════════════════════════════
# 동적 손절
# ═════════════════════════════════════════════════════════════════════════════
class TestDynamicStopLoss:
def test_low_volatility(self):
profile = StockProfile(stock_code="005930", volatility_percentile=10.0)
sl = get_dynamic_stop_loss(profile)
assert sl == -5.0
def test_high_volatility(self):
profile = StockProfile(stock_code="373220", volatility_percentile=90.0)
sl = get_dynamic_stop_loss(profile)
assert sl == -10.0
def test_mid_volatility(self):
profile = StockProfile(stock_code="035720", volatility_percentile=50.0)
sl = get_dynamic_stop_loss(profile)
assert -10.0 < sl < -5.0
# ═════════════════════════════════════════════════════════════════════════════
# 지지/저항선 근접 판별
# ═════════════════════════════════════════════════════════════════════════════
class TestNearSupport:
def test_near_support(self):
profile = StockProfile(stock_code="005930", support_levels=[60000.0, 58000.0])
assert is_near_support(profile, 59500.0) is True # 58000 기준 +2.6%
assert is_near_support(profile, 70000.0) is False
def test_near_resistance(self):
profile = StockProfile(stock_code="005930", resistance_levels=[80000.0])
assert is_near_resistance(profile, 79000.0) is True # -1.25%
assert is_near_resistance(profile, 60000.0) is False
def test_empty_levels(self):
profile = StockProfile(stock_code="005930")
assert is_near_support(profile, 50000.0) is False
assert is_near_resistance(profile, 50000.0) is False
# ═════════════════════════════════════════════════════════════════════════════
# 신뢰도 보정
# ═════════════════════════════════════════════════════════════════════════════
class TestEntryConfidenceBoost:
def test_boost_range(self):
"""결과가 항상 -0.3 ~ +0.3 범위."""
profile = StockProfile(
stock_code="005930",
support_levels=[60000.0],
resistance_levels=[80000.0],
rsi_bounce_avg=30.0,
rsi_bounce_min=20.0,
volume_spike_threshold=2.0,
avg_volume_20d=1000000,
win_rate=0.7,
total_trades=10,
)
boost = get_entry_confidence_boost(profile, rsi=28.0, current_price=60500.0, current_volume=3000000)
assert -0.3 <= boost <= 0.3
def test_all_positive_signals(self):
"""지지선 + RSI 반등 + 높은 승률 + 거래량 급증 → 양수 boost."""
profile = StockProfile(
stock_code="005930",
support_levels=[60000.0],
resistance_levels=[90000.0], # 먼 저항선
rsi_bounce_avg=28.0,
rsi_bounce_min=20.0,
volume_spike_threshold=2.0,
avg_volume_20d=1000000,
win_rate=0.7,
total_trades=10,
)
boost = get_entry_confidence_boost(profile, rsi=28.0, current_price=60500.0, current_volume=3000000)
assert boost > 0
def test_all_negative_signals(self):
"""저항선 + 낮은 승률 → 음수 boost."""
profile = StockProfile(
stock_code="005930",
support_levels=[40000.0], # 먼 지지선
resistance_levels=[60000.0],
win_rate=0.2,
total_trades=10,
avg_volume_20d=1000000,
volume_spike_threshold=3.0,
)
boost = get_entry_confidence_boost(profile, rsi=50.0, current_price=60500.0, current_volume=500000)
assert boost < 0
def test_no_profile_data(self):
"""기본값 프로필 → boost 0."""
profile = StockProfile(stock_code="005930")
boost = get_entry_confidence_boost(profile, rsi=35.0, current_price=70000.0, current_volume=0)
assert boost == 0.0
# ═════════════════════════════════════════════════════════════════════════════
# RSI 시리즈 계산
# ═════════════════════════════════════════════════════════════════════════════
class TestRsiSeries:
def test_length(self):
closes = [100 + i * 0.5 for i in range(50)]
series = _calculate_rsi_series(closes, 14)
assert len(series) > 0
assert len(series) == len(closes) - 14 - 1 # deltas = n-1, skip first period
def test_monotonic_up_high_rsi(self):
"""단조 증가 → RSI > 50."""
closes = list(range(100, 200))
series = _calculate_rsi_series(closes, 14)
for val in series:
assert val > 50
def test_insufficient_data(self):
series = _calculate_rsi_series([100, 101, 102], 14)
assert series == []
# ═════════════════════════════════════════════════════════════════════════════
# 일일 변동폭
# ═════════════════════════════════════════════════════════════════════════════
class TestDailyRange:
def test_basic_calculation(self):
highs = [105.0] * 20
lows = [95.0] * 20
closes = [100.0] * 20
result = _calculate_daily_range_pct(highs, lows, closes)
assert abs(result - 10.0) < 0.1 # (105-95)/100 = 10%
def test_insufficient_data(self):
result = _calculate_daily_range_pct([105], [95], [100])
assert result == 0.0