candle_count 및 tf_map 수정, OHLCV 데이터 무결성 테스트 추가

This commit is contained in:
2025-12-17 23:52:08 +09:00
parent f780db0567
commit 054d19511d
3 changed files with 170 additions and 1 deletions

View File

@@ -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,

View File

@@ -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",
}

View File

@@ -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}")