diff --git a/config/config.json b/config/config.json index a0b30eb..06fca2a 100644 --- a/config/config.json +++ b/config/config.json @@ -1,7 +1,7 @@ { "symbols_file": "config/symbols.txt", "symbol_delay": 1.0, - "candle_count": 200, + "candle_count": 201, "buy_check_interval_minutes": 240, "stop_loss_check_interval_minutes": 60, "profit_taking_check_interval_minutes": 240, diff --git a/src/indicators.py b/src/indicators.py index ec229de..58fb1e2 100644 --- a/src/indicators.py +++ b/src/indicators.py @@ -20,6 +20,8 @@ class DataFetchError(Exception): # OHLCV 데이터 캐시 (TTL 5분) +# ⚠️ 메모리 캐시: 프로그램 재시작 시 자동 초기화됨 +# tf_map 수정 후에는 반드시 프로그램을 재시작하여 오염된 캐시를 제거해야 함 _ohlcv_cache = {} _cache_lock = threading.RLock() # 캐시 동시 접근 보호 (재진입 가능) CACHE_TTL = 300 # 5분 @@ -73,14 +75,24 @@ def fetch_ohlcv( _clean_expired_cache() _buf("debug", f"[CACHE MISS] OHLCV 수집 시작: {symbol} {timeframe}") + + # ⚠️ CRITICAL: main.py의 minutes_to_timeframe()이 반환하는 형식과 일치해야 함 + # minutes_to_timeframe()은 "1m", "3m", "10m", "60m", "240m" 등을 반환 + # pyupbit는 "minute1", "minute240" 형식을 필요로 함 tf_map = { + # 분봉 (Upbit 지원: 1, 3, 5, 10, 15, 30, 60, 240분) "1m": "minute1", "3m": "minute3", "5m": "minute5", + "10m": "minute10", # main.py에서 사용 가능 "15m": "minute15", "30m": "minute30", + "60m": "minute60", # main.py에서 사용 (1시간봉) + "240m": "minute240", # ⚠️ 핵심 수정: main.py에서 4시간봉으로 사용 + # 시간 단위 별칭 (호환성) "1h": "minute60", "4h": "minute240", + # 일봉/주봉 "1d": "day", "1w": "week", } diff --git a/tests/test_timeframe_mapping.py b/tests/test_timeframe_mapping.py new file mode 100644 index 0000000..b5cd204 --- /dev/null +++ b/tests/test_timeframe_mapping.py @@ -0,0 +1,157 @@ +""" +Timeframe 매핑 및 OHLCV 데이터 무결성 테스트. + +이 테스트는 다음 버그를 사전에 발견하기 위해 작성됨: +- main.py의 minutes_to_timeframe()과 indicators.py의 tf_map 불일치 +- candle_count 설정으로 인한 SMA 계산 데이터 부족 + +버그 배경: +- minutes_to_timeframe()이 "240m"을 반환했으나 tf_map에 매핑이 없어 + pyupbit에 그대로 전달되어 일봉 데이터가 반환됨 (2025-12-17 발견) +""" + +import pytest + + +class TestTimeframeMappingIntegrity: + """main.py와 indicators.py 간 timeframe 매핑 일관성 테스트.""" + + def test_all_minutes_to_timeframe_outputs_are_in_tf_map(self): + """minutes_to_timeframe()이 반환 가능한 모든 값이 tf_map에 매핑되어 있어야 함. + + 이 테스트가 실패하면: + - indicators.py의 tf_map에 누락된 timeframe 매핑이 있음 + - 해당 timeframe 사용 시 잘못된 데이터가 반환될 수 있음 + """ + from main import minutes_to_timeframe + + # indicators.py에서 tf_map 추출 + # (함수 내부에 있으므로 직접 정의 - 동기화 필요) + tf_map = { + "1m": "minute1", + "3m": "minute3", + "5m": "minute5", + "10m": "minute10", + "15m": "minute15", + "30m": "minute30", + "60m": "minute60", + "240m": "minute240", + "1h": "minute60", + "4h": "minute240", + "1d": "day", + "1w": "week", + } + + # Upbit이 지원하는 분봉 + 일봉 입력값 + test_minutes = [1, 3, 5, 10, 15, 30, 60, 240, 1440] + + missing_mappings = [] + for minutes in test_minutes: + timeframe = minutes_to_timeframe(minutes) + if timeframe not in tf_map: + missing_mappings.append((minutes, timeframe)) + + if missing_mappings: + error_msg = f"tf_map에 누락된 매핑: {missing_mappings}" + pytest.fail(error_msg) + + def test_edge_case_timeframes_are_mapped(self): + """비표준 분봉 입력도 tf_map에 매핑된 값으로 변환되어야 함. + + 예: 120분 → 60m (근사값), 300분 → 240m + """ + from main import minutes_to_timeframe + + tf_map = { + "1m": "minute1", + "3m": "minute3", + "5m": "minute5", + "10m": "minute10", + "15m": "minute15", + "30m": "minute30", + "60m": "minute60", + "240m": "minute240", + "1h": "minute60", + "4h": "minute240", + "1d": "day", + "1w": "week", + } + + # 비표준 분봉 → 근사 변환 → tf_map에 있어야 함 + edge_cases = [2, 7, 45, 90, 120, 180, 300, 500] + + for minutes in edge_cases: + timeframe = minutes_to_timeframe(minutes) + if timeframe not in tf_map: + pytest.fail(f"minutes_to_timeframe({minutes}) = '{timeframe}'가 tf_map에 없음") + + +class TestCandleCountConfiguration: + """candle_count 설정 관련 테스트.""" + + def test_candle_count_sufficient_for_sma200(self): + """candle_count가 SMA200 계산에 충분해야 함. + + signals.py에서 미완성 봉 1개를 제외하므로, + SMA200 계산에는 최소 201개가 필요함. + """ + from src.config import load_config + + config = load_config() + candle_count = config.get("candle_count", 200) + sma_long = config.get("sma_long", 200) + + # 미완성 봉 1개 제외 후에도 SMA 계산 가능해야 함 + effective_candles = candle_count - 1 + + if effective_candles < sma_long: + error_msg = ( + f"candle_count={candle_count}, effective={effective_candles}, " + f"sma_long={sma_long}. config.json의 candle_count를 {sma_long + 1}로 설정하세요." + ) + pytest.fail(error_msg) + + +class TestOHLCVDataInterval: + """OHLCV 데이터 간격 무결성 테스트.""" + + @pytest.mark.parametrize( + "timeframe,expected_minutes", + [ + ("1m", 1), + ("5m", 5), + ("15m", 15), + ("60m", 60), + ("240m", 240), # 핵심: 4시간 = 240분 + ("1h", 60), + ("4h", 240), + ], + ) + def test_tf_map_produces_correct_pyupbit_interval(self, timeframe, expected_minutes): + """tf_map 변환 결과가 pyupbit 형식에 맞고, 올바른 간격을 의미해야 함.""" + tf_map = { + "1m": "minute1", + "3m": "minute3", + "5m": "minute5", + "10m": "minute10", + "15m": "minute15", + "30m": "minute30", + "60m": "minute60", + "240m": "minute240", + "1h": "minute60", + "4h": "minute240", + "1d": "day", + "1w": "week", + } + + pyupbit_interval = tf_map.get(timeframe) + assert pyupbit_interval is not None, f"tf_map에 '{timeframe}' 매핑 누락" + + # pyupbit 형식 검증: "minuteN" 또는 "day"/"week" + if timeframe not in ("1d", "1w"): + if not pyupbit_interval.startswith("minute"): + pytest.fail(f"분봉 timeframe '{timeframe}'의 pyupbit 형식 오류: {pyupbit_interval}") + + actual_minutes = int(pyupbit_interval.replace("minute", "")) + if actual_minutes != expected_minutes: + pytest.fail(f"'{timeframe}' 간격 불일치: 예상={expected_minutes}분, 실제={actual_minutes}분")