테스트 강화 및 코드 품질 개선
This commit is contained in:
150
src/config.py
150
src/config.py
@@ -1,6 +1,7 @@
|
||||
import os, json
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .common import logger
|
||||
|
||||
|
||||
@@ -37,15 +38,136 @@ def get_default_config() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def validate_config(cfg: dict) -> tuple[bool, str]:
|
||||
"""설정 파일의 필수 항목을 검증합니다 (MEDIUM-001 + HIGH-002)
|
||||
|
||||
Args:
|
||||
cfg: 설정 딕셔너리
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
- is_valid: 검증 통과 여부
|
||||
- error_message: 오류 메시지 (성공 시 빈 문자열)
|
||||
"""
|
||||
required_keys = [
|
||||
"buy_check_interval_minutes",
|
||||
"stop_loss_check_interval_minutes",
|
||||
"profit_taking_check_interval_minutes",
|
||||
"dry_run",
|
||||
"auto_trade",
|
||||
]
|
||||
|
||||
# 필수 항목 확인
|
||||
for key in required_keys:
|
||||
if key not in cfg:
|
||||
return False, f"필수 설정 항목 누락: '{key}'"
|
||||
|
||||
# 범위 검증
|
||||
try:
|
||||
buy_interval = cfg.get("buy_check_interval_minutes", 0)
|
||||
if not isinstance(buy_interval, (int, float)) or buy_interval < 1:
|
||||
return False, "buy_check_interval_minutes는 1 이상이어야 합니다"
|
||||
|
||||
stop_loss_interval = cfg.get("stop_loss_check_interval_minutes", 0)
|
||||
if not isinstance(stop_loss_interval, (int, float)) or stop_loss_interval < 1:
|
||||
return False, "stop_loss_check_interval_minutes는 1 이상이어야 합니다"
|
||||
|
||||
profit_interval = cfg.get("profit_taking_check_interval_minutes", 0)
|
||||
if not isinstance(profit_interval, (int, float)) or profit_interval < 1:
|
||||
return False, "profit_taking_check_interval_minutes는 1 이상이어야 합니다"
|
||||
|
||||
# auto_trade 설정 검증
|
||||
auto_trade = cfg.get("auto_trade", {})
|
||||
if not isinstance(auto_trade, dict):
|
||||
return False, "auto_trade는 딕셔너리 형식이어야 합니다"
|
||||
|
||||
# confirm 설정 검증
|
||||
confirm = cfg.get("confirm", {})
|
||||
if isinstance(confirm, dict):
|
||||
if not isinstance(confirm.get("confirm_stop_loss", False), bool):
|
||||
return False, "confirm_stop_loss는 boolean 타입이어야 합니다"
|
||||
else:
|
||||
return False, "confirm 설정은 딕셔너리 형식이어야 합니다"
|
||||
|
||||
# dry_run 타입 검증
|
||||
if not isinstance(cfg.get("dry_run"), bool):
|
||||
return False, "dry_run은 true 또는 false여야 합니다"
|
||||
|
||||
# ============================================================================
|
||||
# HIGH-002: 추가 검증 로직 (상호 의존성, 논리적 모순, 위험 설정)
|
||||
# ============================================================================
|
||||
|
||||
# 1. Auto Trade 활성화 시 API 키 필수 검증
|
||||
if auto_trade.get("enabled") or auto_trade.get("buy_enabled"):
|
||||
access_key = get_env_or_none("UPBIT_ACCESS_KEY")
|
||||
secret_key = get_env_or_none("UPBIT_SECRET_KEY")
|
||||
if not access_key or not secret_key:
|
||||
return False, "auto_trade 활성화 시 UPBIT_ACCESS_KEY와 UPBIT_SECRET_KEY 환경변수 필수"
|
||||
|
||||
# 2. 손절/익절 주기 논리 검증 (손절은 더 자주 체크해야 안전)
|
||||
if stop_loss_interval > profit_interval:
|
||||
logger.warning(
|
||||
"[설정 경고] 손절 주기(%d분)가 익절 주기(%d분)보다 깁니다. "
|
||||
"급락 시 손절이 늦어질 수 있으므로 손절을 더 자주 체크하는 것이 안전합니다.",
|
||||
stop_loss_interval,
|
||||
profit_interval,
|
||||
)
|
||||
|
||||
# 3. 스레드 수 검증 (과도한 스레드는 Rate Limit 초과 위험)
|
||||
max_threads = cfg.get("max_threads", 3)
|
||||
if not isinstance(max_threads, int) or max_threads < 1:
|
||||
return False, "max_threads는 1 이상의 정수여야 합니다"
|
||||
|
||||
if max_threads > 10:
|
||||
logger.warning(
|
||||
"[설정 경고] max_threads=%d는 과도할 수 있습니다. "
|
||||
"Upbit API Rate Limit(초당 8회, 분당 590회)을 고려하면 10 이하 권장.",
|
||||
max_threads,
|
||||
)
|
||||
|
||||
# 4. 최소 주문 금액 검증
|
||||
min_order = auto_trade.get("min_order_value_krw")
|
||||
if min_order is not None:
|
||||
if not isinstance(min_order, (int, float)) or min_order < 5000:
|
||||
return False, "min_order_value_krw는 5000원 이상이어야 합니다 (Upbit 최소 주문 금액)"
|
||||
|
||||
# 5. 매수 금액 검증
|
||||
buy_amount = auto_trade.get("buy_amount_krw")
|
||||
if buy_amount is not None:
|
||||
if not isinstance(buy_amount, (int, float)) or buy_amount < 5000:
|
||||
return False, "buy_amount_krw는 5000원 이상이어야 합니다"
|
||||
|
||||
# 최소 주문 금액보다 매수 금액이 작은 경우
|
||||
if min_order and buy_amount < min_order:
|
||||
logger.warning(
|
||||
"[설정 경고] buy_amount_krw(%d원)가 min_order_value_krw(%d원)보다 작습니다. "
|
||||
"주문이 실행되지 않을 수 있습니다.",
|
||||
buy_amount,
|
||||
min_order,
|
||||
)
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
return False, f"설정값 타입 오류: {e}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
paths = [os.path.join("config", "config.json"), "config.json"]
|
||||
example_paths = [os.path.join("config", "config.example.json"), "config.example.json"]
|
||||
for p in paths:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
logger.info("설정 파일 로드: %s", p)
|
||||
|
||||
# ✅ MEDIUM-001: 설정 파일 검증
|
||||
is_valid, error_msg = validate_config(cfg)
|
||||
if not is_valid:
|
||||
logger.error("설정 파일 검증 실패: %s. 기본 설정 사용.", error_msg)
|
||||
return get_default_config()
|
||||
|
||||
logger.info("설정 파일 로드 및 검증 완료: %s", p)
|
||||
return cfg
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("설정 파일 JSON 파싱 실패: %s, 기본 설정 사용", e)
|
||||
@@ -53,7 +175,7 @@ def load_config() -> dict:
|
||||
for p in example_paths:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
logger.warning("기본 설정 없음; 예제 사용: %s", p)
|
||||
return cfg
|
||||
@@ -67,7 +189,7 @@ def read_symbols(path: str) -> list:
|
||||
syms = []
|
||||
syms_set = set()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
@@ -93,12 +215,12 @@ class RuntimeConfig:
|
||||
loop: bool
|
||||
dry_run: bool
|
||||
max_threads: int
|
||||
telegram_parse_mode: Optional[str]
|
||||
telegram_parse_mode: str | None
|
||||
trading_mode: str
|
||||
telegram_bot_token: Optional[str]
|
||||
telegram_chat_id: Optional[str]
|
||||
upbit_access_key: Optional[str]
|
||||
upbit_secret_key: Optional[str]
|
||||
telegram_bot_token: str | None
|
||||
telegram_chat_id: str | None
|
||||
upbit_access_key: str | None
|
||||
upbit_secret_key: str | None
|
||||
aggregate_alerts: bool = False
|
||||
benchmark: bool = False
|
||||
telegram_test: bool = False
|
||||
@@ -156,7 +278,7 @@ def build_runtime_config(cfg_dict: dict) -> RuntimeConfig:
|
||||
loss_threshold = -5.0
|
||||
elif loss_threshold < -50:
|
||||
logger.warning(
|
||||
"[WARNING] loss_threshold(%.2f)가 너무 작습니다 (최대 손실 50%% 초과). " "극단적인 손절선입니다.",
|
||||
"[WARNING] loss_threshold(%.2f)가 너무 작습니다 (최대 손실 50%% 초과). 극단적인 손절선입니다.",
|
||||
loss_threshold,
|
||||
)
|
||||
|
||||
@@ -166,12 +288,12 @@ def build_runtime_config(cfg_dict: dict) -> RuntimeConfig:
|
||||
p1, p2 = 10.0, 30.0
|
||||
elif p1 >= p2:
|
||||
logger.warning(
|
||||
"[WARNING] profit_threshold_1(%.2f) < profit_threshold_2(%.2f) 조건 위반 " "-> 기본값 10/30 적용", p1, p2
|
||||
"[WARNING] profit_threshold_1(%.2f) < profit_threshold_2(%.2f) 조건 위반 -> 기본값 10/30 적용", p1, p2
|
||||
)
|
||||
p1, p2 = 10.0, 30.0
|
||||
elif p1 < 5 or p2 > 200:
|
||||
logger.warning(
|
||||
"[WARNING] 수익률 임계값 범위 권장 벗어남 (p1=%.2f, p2=%.2f). " "권장 범위: 5%% <= p1 < p2 <= 200%%", p1, p2
|
||||
"[WARNING] 수익률 임계값 범위 권장 벗어남 (p1=%.2f, p2=%.2f). 권장 범위: 5%% <= p1 < p2 <= 200%%", p1, p2
|
||||
)
|
||||
|
||||
# 드로우다운 임계값 검증 (양수, 순서 관계, 합리적 범위)
|
||||
|
||||
Reference in New Issue
Block a user