테스트 강화 및 코드 품질 개선

This commit is contained in:
2025-12-17 00:01:46 +09:00
parent 37a150bd0d
commit 00c57ddd32
51 changed files with 10670 additions and 217 deletions

View File

@@ -16,10 +16,17 @@ from .common import logger
class CircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: float = 30.0,
failure_threshold: int = 3,
recovery_timeout: float = 300.0,
half_open_max_attempts: int = 1,
) -> None:
"""Circuit Breaker 초기화
Args:
failure_threshold: 실패 임계값 (기본 3회로 감소, 이전 5회)
recovery_timeout: 복구 대기 시간 초 (기본 300초=5분, 이전 30초)
half_open_max_attempts: Half-Open 상태 최대 시도 횟수
"""
self.failure_threshold = max(1, failure_threshold)
self.recovery_timeout = float(recovery_timeout)
self.half_open_max_attempts = max(1, half_open_max_attempts)

View File

@@ -1,8 +1,14 @@
import gzip
import json
import logging
import logging.handlers
import os
import secrets
import shutil
import stat
import threading
import time
from collections import deque
from pathlib import Path
LOG_DIR = os.getenv("LOG_DIR", "logs")
@@ -29,6 +35,319 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
HOLDINGS_FILE = str(DATA_DIR / "holdings.json")
TRADES_FILE = str(DATA_DIR / "trades.json")
PENDING_ORDERS_FILE = str(DATA_DIR / "pending_orders.json")
RECENT_SELLS_FILE = str(DATA_DIR / "recent_sells.json")
class RateLimiter:
"""토큰 버킷 기반 다중 윈도우 Rate Limiter (초/분 제한 동시 적용).
Upbit는 초당 10회, 분당 600회 제한을 가진다. 기본값은 여유분을 두어
초당 8회, 분당 590회로 설정한다.
"""
def __init__(
self,
max_calls: int = 8,
period: float = 1.0,
additional_limits: list[tuple[int, float]] | None = None,
):
self.windows: list[tuple[int, float, deque]] = [
(max_calls, period, deque()),
]
if additional_limits:
for limit_calls, limit_period in additional_limits:
self.windows.append((limit_calls, limit_period, deque()))
self.lock = threading.Lock()
def _prune(self, now: float) -> None:
for _, period, calls in self.windows:
while calls and now - calls[0] > period:
calls.popleft()
def _next_wait(self, now: float) -> float:
waits: list[float] = []
for max_calls, period, calls in self.windows:
if len(calls) >= max_calls:
waits.append(period - (now - calls[0]))
return max(waits) if waits else 0.0
def acquire(self) -> None:
"""API 호출 권한 획득 (초/분 Rate Limit 동시 준수)."""
while True:
with self.lock:
now = time.time()
self._prune(now)
wait_for = self._next_wait(now)
if wait_for <= 0:
for _, _, calls in self.windows:
calls.append(now)
return
sleep_time = max(wait_for, 0) + 0.05 # 작은 여유 포함
logger.debug("[RATE_LIMIT] API 제한 도달, %.2f초 대기", sleep_time)
time.sleep(sleep_time)
# 전역 Rate Limiter 인스턴스 (모든 API 호출에서 공유)
api_rate_limiter = RateLimiter(max_calls=8, period=1.0, additional_limits=[(590, 60.0)])
# ============================================================================
# Lock 획득 순서 규약 (데드락 방지)
# ============================================================================
# 여러 Lock을 동시에 획득할 때는 다음 순서를 따라야 합니다:
# 1. holdings_lock (최우선 - holdings.py에 정의됨)
# 2. _state_lock (state_manager.py에 정의됨)
# 3. krw_balance_lock
# 4. recent_sells_lock
# 5. _cache_lock, _pending_order_lock (개별 리소스, 독립적)
#
# 예: holdings_lock을 먼저 획득한 상태에서만 _state_lock 획득 가능
# ============================================================================
class KRWBudgetManager:
"""KRW 잔고 예산 할당 관리자 (동일 심볼 다중 주문 안전 지원).
- 각 할당은 고유 토큰으로 구분되어 동일 심볼의 복수 주문도 안전하게 처리한다.
- release는 토큰 단위로 수행하여 다른 주문의 예산을 건드리지 않는다.
"""
def __init__(self, min_order_value: float = MIN_KRW_ORDER):
self.lock = threading.Lock()
self.allocations: dict[str, dict[str, float]] = {} # symbol -> {token: amount}
self.token_index: dict[str, str] = {} # token -> symbol
self.min_order_value = float(min_order_value)
def _total_allocated(self) -> float:
return sum(amount for per_symbol in self.allocations.values() for amount in per_symbol.values())
def allocate(
self,
symbol: str,
amount_krw: float,
upbit=None,
min_order_value: float | None = None,
) -> tuple[bool, float, str | None]:
"""매수 예산 할당 시도 (토큰 반환).
Returns:
(성공 여부, 할당된 금액, allocation_token)
"""
with self.lock:
normalized_symbol = symbol.upper()
total_allocated = self._total_allocated()
if upbit is not None:
try:
actual_balance = float(upbit.get_balance("KRW") or 0)
except Exception as e:
logger.warning("[KRWBudgetManager] 잔고 조회 실패: %s", e)
actual_balance = 0.0
else:
actual_balance = total_allocated + amount_krw
# 실제 잔고가 이미 주문 처리로 감소했을 수 있으므로
# (1) 실제 잔고 < 총 할당액: 더는 차감하지 않는다.
# (2) 실제 잔고가 총 할당액보다 약간 큰 경우(차이가 최소 주문 이하): 이중 차감을 피한다.
if actual_balance < total_allocated:
available = actual_balance
elif 0 < (actual_balance - total_allocated) <= self.min_order_value:
available = actual_balance
else:
available = actual_balance - total_allocated
if available <= 0:
logger.warning(
"[%s] KRW 예산 부족: 잔고 %.0f, 할당 중 %.0f, 가용 %.0f",
normalized_symbol,
actual_balance,
total_allocated,
available,
)
return False, 0.0, None
alloc_amount = min(float(amount_krw), available)
min_value = float(min_order_value) if min_order_value is not None else self.min_order_value
if alloc_amount < min_value:
logger.warning(
"[%s] KRW 예산 할당 거부: %.0f원 < 최소 주문 %.0f원 (가용 %.0f원)",
normalized_symbol,
alloc_amount,
min_value,
available,
)
return False, 0.0, None
token = secrets.token_hex(8)
per_symbol = self.allocations.setdefault(normalized_symbol, {})
per_symbol[token] = alloc_amount
self.token_index[token] = normalized_symbol
log_level = logger.info if alloc_amount < amount_krw else logger.debug
log_level(
"[%s] KRW 예산 할당: 요청 %.0f원 → 할당 %.0f원 (잔고 %.0f, 총할당 %.0f)",
normalized_symbol,
amount_krw,
alloc_amount,
actual_balance,
total_allocated + alloc_amount,
)
return True, alloc_amount, token
def release(self, allocation_token: str | None) -> float:
"""토큰 단위 예산 해제.
Returns:
해제된 금액 (미존재 토큰이면 0)
"""
if not allocation_token:
return 0.0
with self.lock:
symbol = self.token_index.pop(allocation_token, None)
if not symbol:
return 0.0
per_symbol = self.allocations.get(symbol, {})
amount = per_symbol.pop(allocation_token, 0.0)
if not per_symbol:
self.allocations.pop(symbol, None)
logger.debug("[%s] KRW 예산 해제: %.0f원 (토큰 %s)", symbol, amount, allocation_token)
return amount
def release_symbol(self, symbol: str) -> float:
"""특정 심볼의 모든 할당 해제 (비상 정리용)."""
normalized_symbol = symbol.upper()
with self.lock:
per_symbol = self.allocations.pop(normalized_symbol, {})
for token in list(per_symbol.keys()):
self.token_index.pop(token, None)
return sum(per_symbol.values())
def get_allocations(self) -> dict[str, float]:
"""현재 할당 상태(심볼별 합산) 조회."""
with self.lock:
return {symbol: sum(per_symbol.values()) for symbol, per_symbol in self.allocations.items()}
def get_allocation_tokens(self, symbol: str) -> list[str]:
"""지정 심볼에 대한 활성 토큰 목록 반환 (테스트/디버깅용)."""
normalized_symbol = symbol.upper()
with self.lock:
return list(self.allocations.get(normalized_symbol, {}).keys())
def clear(self) -> None:
"""모든 할당 초기화 (테스트용)"""
with self.lock:
self.allocations.clear()
self.token_index.clear()
logger.debug("[KRWBudgetManager] 모든 예산 할당 초기화")
# 전역 KRW 예산 관리자 인스턴스
krw_budget_manager = KRWBudgetManager()
# KRW 잔고 조회 시 직렬화용 락 (테스트/호환성 목적)
krw_balance_lock = threading.RLock()
# recent_sells.json 동시성 보호용 락
recent_sells_lock = threading.RLock()
def _load_recent_sells_locked() -> dict:
"""recent_sells.json을 락을 잡은 상태에서 안전하게 로드."""
if not os.path.exists(RECENT_SELLS_FILE) or os.path.getsize(RECENT_SELLS_FILE) == 0:
return {}
try:
with open(RECENT_SELLS_FILE, encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
backup = f"{RECENT_SELLS_FILE}.corrupted.{int(time.time())}"
try:
os.replace(RECENT_SELLS_FILE, backup)
logger.warning("recent_sells 손상 감지, 백업 후 초기화: %s (원인: %s)", backup, e)
except Exception as backup_err:
logger.error("recent_sells 백업 실패: %s", backup_err)
return {}
def _save_recent_sells_locked(sells: dict) -> None:
"""recent_sells.json을 원자적으로 저장 (락 보유 가정)."""
os.makedirs(os.path.dirname(RECENT_SELLS_FILE) or ".", exist_ok=True)
temp_file = f"{RECENT_SELLS_FILE}.tmp"
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(sells, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(temp_file, RECENT_SELLS_FILE)
try:
os.chmod(RECENT_SELLS_FILE, stat.S_IRUSR | stat.S_IWUSR)
except Exception:
logger.debug("recent_sells 권한 설정 건너뜀 (플랫폼 미지원)")
def record_sell(symbol: str) -> None:
"""매도 기록 (재매수 방지용)
Args:
symbol: 심볼 (예: "KRW-BTC")
Note:
recent_sells.json에 매도 시간을 기록합니다.
24시간 동안 재매수를 방지하는 데 사용됩니다.
"""
try:
with recent_sells_lock:
sells = _load_recent_sells_locked()
sells[symbol] = time.time()
_save_recent_sells_locked(sells)
logger.debug("[%s] 매도 기록 저장 (재매수 방지 활성화)", symbol)
except Exception as e:
logger.error("[%s] 매도 기록 저장 실패: %s", symbol, e)
def can_buy(symbol: str, cooldown_hours: int = 24) -> bool:
"""재매수 가능 여부 확인
Args:
symbol: 심볼 (예: "KRW-BTC")
cooldown_hours: 쿨다운 시간 (시간 단위, 기본 24시간)
Returns:
재매수 가능 여부 (True: 가능, False: 쿨다운 중)
"""
try:
with recent_sells_lock:
sells = _load_recent_sells_locked()
# TTL cleanup: drop entries older than 2x cooldown (default 48h)
ttl_seconds = max(cooldown_hours * 2 * 3600, cooldown_hours * 3600)
now = time.time()
pruned = {k: v for k, v in sells.items() if (now - v) <= ttl_seconds}
if len(pruned) != len(sells):
_save_recent_sells_locked(pruned)
sells = pruned
if symbol not in sells:
return True
elapsed = time.time() - sells[symbol]
cooldown_seconds = cooldown_hours * 3600
if elapsed < cooldown_seconds:
remaining_hours = (cooldown_seconds - elapsed) / 3600
logger.debug(
"[%s] 재매수 대기 중 (쿨다운 %.1f시간 남음)",
symbol,
remaining_hours,
)
return False
# 쿨다운 완료 시 기록 삭제 후 저장
sells.pop(symbol, None)
_save_recent_sells_locked(sells)
return True
except Exception as e:
logger.warning("[%s] 재매수 가능 여부 확인 실패: %s", symbol, e)
return True # 오류 시 매수 허용 (안전 우선)
class CompressedRotatingFileHandler(logging.handlers.RotatingFileHandler):
@@ -87,11 +406,13 @@ def setup_logger(dry_run: bool):
logger.addHandler(ch)
# Size-based rotating file handler with compression (only one rotation strategy)
from .constants import LOG_BACKUP_COUNT, LOG_MAX_BYTES
fh_size = CompressedRotatingFileHandler(
LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=7,
encoding="utf-8", # 10MB per file # Keep 7 backups
maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT,
encoding="utf-8",
)
fh_size.setLevel(effective_level)
fh_size.setFormatter(formatter)
@@ -102,6 +423,6 @@ def setup_logger(dry_run: bool):
logger.info(
"[SYSTEM] 로그 설정 완료: level=%s, size_rotation=%dMB×%d (일별 로테이션 제거됨)",
logging.getLevelName(effective_level),
10,
7,
LOG_MAX_BYTES // (1024 * 1024),
LOG_BACKUP_COUNT,
)

View File

@@ -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
)
# 드로우다운 임계값 검증 (양수, 순서 관계, 합리적 범위)

55
src/constants.py Normal file
View File

@@ -0,0 +1,55 @@
# src/constants.py
"""프로젝트 전역 상수 정의.
Magic Number를 제거하고 의미를 명확히 하기 위한 상수 모음입니다.
"""
# ============================================================================
# Telegram 관련 상수
# ============================================================================
TELEGRAM_RATE_LIMIT_DELAY = 0.5 # 메시지 간 대기 시간 (초)
TELEGRAM_MAX_MESSAGE_LENGTH = 4000 # Telegram API 제한 (실제 4096, 안전 마진)
TELEGRAM_REQUEST_TIMEOUT = 20 # Telegram API 요청 타임아웃 (초)
# ============================================================================
# Retry 관련 상수
# ============================================================================
DEFAULT_RETRY_COUNT = 3 # 기본 재시도 횟수
DEFAULT_RETRY_BACKOFF = 0.2 # 재시도 백오프 초기값 (초)
MAX_RETRY_BACKOFF = 2.0 # 최대 백오프 시간 (초)
BALANCE_RETRY_BACKOFF = 0.2 # 잔고 조회 재시도 백오프 (초)
ORDER_RETRY_DELAY = 1.0 # 주문 재시도 간 대기 (초)
# ============================================================================
# Cache TTL 관련 상수
# ============================================================================
OHLCV_CACHE_TTL = 300 # OHLCV 데이터 캐시 TTL (5분)
PRICE_CACHE_TTL = 2.0 # 현재가 캐시 TTL (2초)
BALANCE_CACHE_TTL = 2.0 # 잔고 캐시 TTL (2초)
# ============================================================================
# Order 관련 상수
# ============================================================================
ORDER_MONITOR_INITIAL_DELAY = 1.0 # 주문 모니터링 초기 대기 (초)
ORDER_MONITOR_MAX_DELAY = 5.0 # 주문 모니터링 최대 대기 (초)
# ============================================================================
# File 관련 상수
# ============================================================================
PENDING_ORDER_TTL = 86400 # Pending Order TTL (24시간, 초)
# ============================================================================
# ThreadPool 관련 상수
# ============================================================================
THREADPOOL_MAX_WORKERS_CAP = 8 # ThreadPoolExecutor 상한 (확장 가능하도록 상수화)
# ============================================================================
# Log Rotation 관련 상수
# ============================================================================
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
LOG_BACKUP_COUNT = 7 # 최대 7개 백업 파일 유지
# ============================================================================
# Order 관련 추가 상수
# ============================================================================
ORDER_MAX_RETRIES = 3 # 주문 최대 재시도 횟수

View File

@@ -3,11 +3,15 @@ from __future__ import annotations
import json
import os
import threading
import time
from typing import TYPE_CHECKING
import pyupbit
import requests
from .common import FLOAT_EPSILON, HOLDINGS_FILE, MIN_TRADE_AMOUNT, logger
from . import state_manager # [NEW] Import StateManager
from .common import FLOAT_EPSILON, HOLDINGS_FILE, MIN_TRADE_AMOUNT, api_rate_limiter, logger
from .constants import BALANCE_RETRY_BACKOFF, DEFAULT_RETRY_BACKOFF, DEFAULT_RETRY_COUNT, PRICE_CACHE_TTL
from .retry_utils import retry_with_backoff
if TYPE_CHECKING:
@@ -19,6 +23,13 @@ EPSILON = FLOAT_EPSILON
# 파일 잠금을 위한 RLock 객체 (재진입 가능)
holdings_lock = threading.RLock()
# 짧은 TTL 캐시 (현재가/잔고) - constants.py에서 import
# PRICE_CACHE_TTL은 constants.py에 정의됨
BALANCE_CACHE_TTL = PRICE_CACHE_TTL # 동일한 TTL 사용
_price_cache: dict[str, tuple[float, float]] = {} # market -> (price, ts)
_balance_cache: tuple[dict | None, float] = ({}, 0.0)
_cache_lock = threading.Lock()
def _load_holdings_unsafe(holdings_file: str) -> dict[str, dict]:
"""내부 사용 전용: Lock 없이 holdings 파일 로드"""
@@ -43,8 +54,9 @@ def load_holdings(holdings_file: str = HOLDINGS_FILE) -> dict[str, dict]:
return _load_holdings_unsafe(holdings_file)
except json.JSONDecodeError as e:
logger.error("[ERROR] 보유 파일 JSON 디코드 실패: %s", e)
except Exception as e:
logger.exception("[ERROR] 보유 파일 로드 중 예외 발생: %s", e)
except OSError as e:
logger.exception("[ERROR] 보유 파일 로드 중 입출력 예외 발생: %s", e)
raise
return {}
@@ -62,9 +74,19 @@ def _save_holdings_unsafe(holdings: dict[str, dict], holdings_file: str) -> None
# 원자적 교체 (rename은 원자적 연산)
os.replace(temp_file, holdings_file)
# ✅ 보안 개선: 파일 권한 설정 (rw------- = 0o600)
try:
import stat
os.chmod(holdings_file, stat.S_IRUSR | stat.S_IWUSR) # 소유자만 읽기/쓰기
except Exception as e:
# Windows에서는 chmod가 제한적이므로 오류 무시
logger.debug("파일 권한 설정 건너뜀 (Windows는 미지원): %s", e)
logger.debug("[DEBUG] 보유 저장 (원자적): %s", holdings_file)
except Exception as e:
logger.error("[ERROR] 보유 저장 중 오류: %s", e)
except OSError as e:
logger.error("[ERROR] 보유 저장 중 입출력 오류: %s", e)
# 임시 파일 정리
if os.path.exists(temp_file):
try:
@@ -79,11 +101,46 @@ def save_holdings(holdings: dict[str, dict], holdings_file: str = HOLDINGS_FILE)
try:
with holdings_lock:
_save_holdings_unsafe(holdings, holdings_file)
except Exception as e:
except OSError as e:
logger.error("[ERROR] 보유 저장 실패: %s", e)
raise # 호출자가 저장 실패를 인지하도록 예외 재발생
def update_max_price(symbol: str, current_price: float, holdings_file: str = HOLDINGS_FILE) -> None:
"""최고가를 갱신합니다 (기존 max_price보다 높을 때만)
Args:
symbol: 심볼 (예: "KRW-BTC")
current_price: 현재 가격
holdings_file: holdings 파일 경로 (더 이상 주된 상태 저장소가 아님)
Note:
이제 bot_state.json (StateManager)을 통해 영구 저장됩니다.
holdings.json은 캐시 역할로 유지됩니다.
"""
# 1. StateManager를 통해 영구 저장소 업데이트
state_manager.update_max_price_state(symbol, current_price)
# 2. 기존 holdings.json 업데이트 (호환성 유지)
with holdings_lock:
holdings = load_holdings(holdings_file)
if symbol not in holdings:
return
holding_info = holdings[symbol]
# StateManager에서 최신 max_price 가져오기
new_max = state_manager.get_value(symbol, "max_price", 0.0)
# holdings 파일에도 반영 (표시용)
if new_max > holding_info.get("max_price", 0):
holdings[symbol]["max_price"] = new_max
save_holdings(holdings, holdings_file)
logger.debug("[%s] max_price 동기화 완료: %.2f", symbol, new_max)
def get_upbit_balances(cfg: RuntimeConfig) -> dict | None:
"""
Upbit API를 통해 현재 잔고를 조회합니다.
@@ -100,31 +157,59 @@ def get_upbit_balances(cfg: RuntimeConfig) -> dict | None:
Raises:
Exception: Upbit API 호출 중 발생한 예외는 로깅되고 None 반환
"""
global _balance_cache
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("API 키 없음 - 빈 balances")
return {}
now = time.time()
with _cache_lock:
cached_balances, ts = _balance_cache
if cached_balances is not None and (now - ts) <= BALANCE_CACHE_TTL:
return dict(cached_balances)
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
balances = upbit.get_balances()
# 타입 체크: balances가 리스트가 아닐 경우
if not isinstance(balances, list):
logger.error("Upbit balances 형식 오류: 예상(list), 실제(%s)", type(balances).__name__)
return None
result = {}
for item in balances:
currency = (item.get("currency") or "").upper()
# 간단한 재시도(최대 3회, 짧은 백오프)
last_error: Exception | None = None
for attempt in range(3):
try:
balance = float(item.get("balance", 0))
except Exception:
balance = 0.0
if balance <= MIN_TRADE_AMOUNT:
continue
result[currency] = balance
logger.debug("Upbit 보유 %d", len(result))
return result
except Exception as e:
api_rate_limiter.acquire()
balances = upbit.get_balances()
if not isinstance(balances, list):
logger.error("Upbit balances 형식 오류: 예상(list), 실제(%s)", type(balances).__name__)
last_error = TypeError("invalid balances type")
time.sleep(0.2 * (attempt + 1))
continue
result: dict[str, float] = {}
for item in balances:
currency = (item.get("currency") or "").upper()
if currency == "KRW":
continue
try:
balance = float(item.get("balance", 0))
except Exception:
balance = 0.0
if balance <= MIN_TRADE_AMOUNT:
continue
result[currency] = balance
with _cache_lock:
_balance_cache = (result, time.time())
logger.debug("Upbit 보유 %d", len(result))
return result
except (requests.exceptions.RequestException, ValueError, TypeError) as e: # 네트워크/파싱 오류
last_error = e
logger.warning("Upbit balances 재시도 %d/3 실패: %s", attempt + 1, e)
time.sleep(BALANCE_RETRY_BACKOFF * (attempt + 1))
if last_error:
logger.error("Upbit balances 실패: %s", last_error)
return None
except (requests.exceptions.RequestException, ValueError, TypeError) as e:
logger.error("Upbit balances 실패: %s", e)
return None
@@ -151,11 +236,37 @@ def get_current_price(symbol: str) -> float:
market = symbol.upper()
else:
market = f"KRW-{symbol.replace('KRW-', '').upper()}"
# 실시간 현재가(ticker)를 조회하도록 변경
price = pyupbit.get_current_price(market)
logger.debug("[DEBUG] 현재가 %s -> %.2f", market, price)
return float(price) if price else 0.0
except Exception as e:
now = time.time()
with _cache_lock:
cached = _price_cache.get(market)
if cached:
price_cached, ts = cached
if (now - ts) <= PRICE_CACHE_TTL:
return price_cached
last_error: Exception | None = None
for attempt in range(DEFAULT_RETRY_COUNT):
try:
api_rate_limiter.acquire()
price = pyupbit.get_current_price(market)
if price:
price_f = float(price)
with _cache_lock:
_price_cache[market] = (price_f, time.time())
logger.debug("[DEBUG] 현재가 %s -> %.2f (attempt %d)", market, price_f, attempt + 1)
return price_f
last_error = ValueError("empty price")
except (requests.exceptions.RequestException, ValueError, TypeError) as e:
last_error = e
logger.warning(
"[WARNING] 현재가 조회 실패 %s (재시도 %d/%d): %s", symbol, attempt + 1, DEFAULT_RETRY_COUNT, e
)
time.sleep(DEFAULT_RETRY_BACKOFF * (attempt + 1))
if last_error:
logger.warning("[WARNING] 현재가 조회 최종 실패 %s: %s", symbol, last_error)
except (requests.exceptions.RequestException, ValueError, TypeError) as e:
logger.warning("[WARNING] 현재가 조회 실패 %s: %s", symbol, e)
return 0.0
@@ -219,9 +330,12 @@ def add_new_holding(
}
logger.info("[INFO] [%s] holdings 신규 추가: 매수가=%.2f, 수량=%.8f", symbol, buy_price, amount)
state_manager.set_value(symbol, "max_price", holdings[symbol]["max_price"])
state_manager.set_value(symbol, "partial_sell_done", False)
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
except (OSError, json.JSONDecodeError, ValueError, TypeError) as e:
logger.exception("[ERROR] [%s] holdings 추가 실패: %s", symbol, e)
return False
@@ -273,7 +387,7 @@ def update_holding_amount(
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
except (OSError, json.JSONDecodeError, ValueError, TypeError) as e:
logger.exception("[ERROR] [%s] holdings 수량 업데이트 실패: %s", symbol, e)
return False
@@ -303,8 +417,12 @@ def set_holding_field(symbol: str, key: str, value, holdings_file: str = HOLDING
logger.info("[INFO] [%s] holdings 업데이트: 필드 '%s''%s'(으)로 설정", symbol, key, value)
_save_holdings_unsafe(holdings, holdings_file)
# [NEW] StateManager에도 반영
state_manager.set_value(symbol, key, value)
return True
except Exception as e:
except (OSError, json.JSONDecodeError, ValueError, TypeError) as e:
logger.exception("[ERROR] [%s] holdings 필드 설정 실패: %s", symbol, e)
return False
@@ -325,7 +443,7 @@ def fetch_holdings_from_upbit(cfg: RuntimeConfig) -> dict | None:
Behavior:
- Upbit API에서 잔고 정보 조회 (amount, buy_price 등)
- 기존 로컬 holdings.json의 max_price는 유지 (매도 조건 판정 용)
- **중요**: 로컬 holdings.json의 `max_price`를 안전하게 로드하여 유지 (초기화 방지)
- 잔고 0 또는 MIN_TRADE_AMOUNT 미만 자산은 제외
- buy_price 필드 우선순위: avg_buy_price_krw > avg_buy_price
@@ -346,51 +464,106 @@ def fetch_holdings_from_upbit(cfg: RuntimeConfig) -> dict | None:
)
return None
holdings = {}
# 기존 holdings 파일에서 max_price 불러오기
existing_holdings = load_holdings(HOLDINGS_FILE)
new_holdings_map = {}
# 로컬 holdings 스냅샷 (StateManager가 비어있을 때 복원용)
try:
with holdings_lock:
local_holdings_snapshot = _load_holdings_unsafe(HOLDINGS_FILE)
except Exception:
local_holdings_snapshot = {}
# 1. API 잔고 먼저 처리 (메모리 맵 구성)
for item in balances:
currency = (item.get("currency") or "").upper()
if currency == "KRW":
continue
try:
amount = float(item.get("balance", 0))
except Exception:
amount = 0.0
if amount <= EPSILON:
continue
# 평균 매수가 파싱 (우선순위: KRW -> 일반)
buy_price = None
if item.get("avg_buy_price_krw"):
try:
buy_price = float(item.get("avg_buy_price_krw"))
except Exception:
buy_price = None
pass
if buy_price is None and item.get("avg_buy_price"):
try:
buy_price = float(item.get("avg_buy_price"))
except Exception:
buy_price = None
pass
market = f"KRW-{currency}"
# 기존 max_price 유지 (실시간 가격은 매도 검사 시점에 조회)
prev_max_price = None
if existing_holdings and market in existing_holdings:
prev_max_price = existing_holdings[market].get("max_price")
if prev_max_price is not None:
try:
prev_max_price = float(prev_max_price)
except Exception:
prev_max_price = None
# max_price는 기존 값 유지 또는 buy_price 사용
max_price = prev_max_price if prev_max_price is not None else (buy_price or 0)
holdings[market] = {
new_holdings_map[market] = {
"buy_price": buy_price or 0.0,
"amount": amount,
"max_price": max_price,
"max_price": buy_price or 0.0, # 기본값으로 매수가 설정
"buy_timestamp": None,
}
logger.debug("[DEBUG] Upbit holdings %d", len(holdings))
return holdings
except Exception as e:
if not new_holdings_map:
return {}
# 2. StateManager(bot_state.json)에서 영구 상태 병합
# 이전에는 로컬 파일(holdings.json)을 병합했으나, 이제는 StateManager가 Source of Truth입니다.
try:
for market, new_data in new_holdings_map.items():
# StateManager에서 상태 로드
saved_max = state_manager.get_value(market, "max_price")
saved_partial = state_manager.get_value(market, "partial_sell_done")
# 로컬 holdings 스냅샷 로드
local_entry = (
local_holdings_snapshot.get(market, {}) if isinstance(local_holdings_snapshot, dict) else {}
)
local_max = local_entry.get("max_price")
local_partial = local_entry.get("partial_sell_done")
current_buy_price = float(new_data.get("buy_price", 0.0) or 0.0)
# max_price 복원: 사용 가능한 값 중 최댓값을 선택해 하향 초기화 방지
max_candidates = [current_buy_price]
if saved_max is not None:
try:
max_candidates.append(float(saved_max))
except Exception:
pass
if local_max is not None:
try:
max_candidates.append(float(local_max))
except Exception:
pass
restored_max = max(max_candidates)
new_data["max_price"] = restored_max
state_manager.set_value(market, "max_price", restored_max)
# partial_sell_done 복원: True를 보존하기 위해 StateManager가 False여도 로컬 True를 우선 반영
if bool(saved_partial):
new_data["partial_sell_done"] = True
state_manager.set_value(market, "partial_sell_done", True)
elif local_partial is not None:
new_data["partial_sell_done"] = bool(local_partial)
state_manager.set_value(market, "partial_sell_done", bool(local_partial))
else:
new_data["partial_sell_done"] = False
state_manager.set_value(market, "partial_sell_done", False)
except Exception as e:
logger.warning("[WARNING] StateManager 데이터 병합 중 오류: %s", e)
logger.debug("[DEBUG] Upbit holdings %d개 (State 병합 완료)", len(new_holdings_map))
return new_holdings_map
except (requests.exceptions.RequestException, ValueError, TypeError) as e:
logger.error("[ERROR] fetch_holdings 실패: %s", e)
return None
@@ -465,3 +638,64 @@ def restore_holdings_from_backup(backup_file: str, restore_to: str = HOLDINGS_FI
except Exception as e:
logger.error("[ERROR] Holdings 복구 실패: %s", e)
return False
def reconcile_state_and_holdings(holdings_file: str = HOLDINGS_FILE) -> dict[str, dict]:
"""
StateManager(bot_state)와 holdings.json을 상호 보정합니다.
- StateManager를 단일 소스로 두되, 비어있는 경우 holdings에서 값 복원
- holdings의 표시용 필드(max_price, partial_sell_done)를 state 값으로 동기화
Returns:
병합 완료된 holdings dict
"""
with holdings_lock:
holdings_data = _load_holdings_unsafe(holdings_file)
state = state_manager.load_state()
state_changed = False
holdings_changed = False
for symbol, entry in list(holdings_data.items()):
state_entry = state.get(symbol, {})
# max_price 동기화: state 우선, 없으면 holdings 값으로 채움
h_max = entry.get("max_price")
s_max = state_entry.get("max_price")
if s_max is None and h_max is not None:
state_entry["max_price"] = h_max
state_changed = True
elif s_max is not None:
if h_max != s_max:
holdings_data[symbol]["max_price"] = s_max
holdings_changed = True
# partial_sell_done 동기화: state 우선, 없으면 holdings 값으로 채움
h_partial = entry.get("partial_sell_done")
s_partial = state_entry.get("partial_sell_done")
if s_partial is None and h_partial is not None:
state_entry["partial_sell_done"] = h_partial
state_changed = True
elif s_partial is not None:
if h_partial != s_partial:
holdings_data[symbol]["partial_sell_done"] = s_partial
holdings_changed = True
if state_entry and symbol not in state:
state[symbol] = state_entry
state_changed = True
# holdings에 있지만 state에 없는 심볼을 state에 추가
for symbol in holdings_data.keys():
if symbol not in state:
state[symbol] = {}
state_changed = True
if state_changed:
state_manager.save_state(state)
if holdings_changed:
save_holdings(holdings_data, holdings_file)
return holdings_data

View File

@@ -1,11 +1,13 @@
import os
import time
import random
import threading
import time
import pandas as pd
import pandas_ta as ta
import pyupbit
from requests.exceptions import RequestException, Timeout, ConnectionError
from requests.exceptions import ConnectionError, RequestException, Timeout
from .common import logger
__all__ = ["fetch_ohlcv", "compute_macd_hist", "compute_sma", "ta", "DataFetchError", "clear_ohlcv_cache"]
@@ -90,6 +92,11 @@ def fetch_ohlcv(
cumulative_sleep = 0.0
for attempt in range(1, max_attempts + 1):
try:
# ✅ Rate Limiter로 API 호출 보호
from .common import api_rate_limiter
api_rate_limiter.acquire()
df = pyupbit.get_ohlcv(symbol, interval=py_tf, count=limit)
if df is None or df.empty:
_buf("warning", f"OHLCV 빈 결과: {symbol}")
@@ -117,15 +124,15 @@ def fetch_ohlcv(
_buf("warning", f"OHLCV 수집 실패 (시도 {attempt}/{max_attempts}): {symbol} -> {e}")
if not is_network_err:
_buf("error", f"네트워크 비관련 오류; 재시도하지 않음: {e}")
raise DataFetchError(f"네트워크 비관련 오류로 OHLCV 수집 실패: {e}")
raise DataFetchError(f"네트워크 비관련 오류로 OHLCV 수집 실패: {e}") from e
if attempt == max_attempts:
_buf("error", f"OHLCV: 최대 재시도 도달 ({symbol})")
raise DataFetchError(f"OHLCV 수집 최대 재시도({max_attempts}) 도달: {symbol}")
raise DataFetchError(f"OHLCV 수집 최대 재시도({max_attempts}) 도달: {symbol}") from e
sleep_time = base_backoff * (2 ** (attempt - 1))
sleep_time = sleep_time + random.uniform(0, jitter_factor * sleep_time)
if cumulative_sleep + sleep_time > max_total_backoff:
logger.warning("누적 재시도 대기시간 초과 (%s)", symbol)
raise DataFetchError(f"OHLCV 수집 누적 대기시간 초과: {symbol}")
raise DataFetchError(f"OHLCV 수집 누적 대기시간 초과: {symbol}") from e
cumulative_sleep += sleep_time
_buf("debug", f"{sleep_time:.2f}초 후 재시도")
time.sleep(sleep_time)

View File

@@ -4,6 +4,11 @@ import time
import requests
from .common import logger
from .constants import (
TELEGRAM_MAX_MESSAGE_LENGTH,
TELEGRAM_RATE_LIMIT_DELAY,
TELEGRAM_REQUEST_TIMEOUT,
)
__all__ = ["send_telegram", "send_telegram_with_retry", "report_error", "send_startup_test_message"]
@@ -51,10 +56,29 @@ def send_telegram_with_retry(
return False
def send_telegram(token: str, chat_id: str, text: str, add_thread_prefix: bool = True, parse_mode: str = None):
def send_telegram(
token: str,
chat_id: str,
text: str,
add_thread_prefix: bool = True,
parse_mode: str = None,
max_length: int = TELEGRAM_MAX_MESSAGE_LENGTH,
):
"""
텔레그램 메시지를 한 번 전송합니다. 실패 시 예외를 발생시킵니다.
WARNING: 이 함수는 예외 처리가 없으므로, 프로덕션에서는 send_telegram_with_retry() 사용 권장
텔레그램 메시지를 전송합니다 (자동 분할 지원).
Args:
token: 텔레그램 봇 토큰
chat_id: 채팅 ID
text: 메시지 내용
add_thread_prefix: 스레드 이름 prefix 추가 여부
parse_mode: HTML/Markdown 파싱 모드
max_length: 최대 메시지 길이 (Telegram 제한 4096자, 안전하게 4000자)
Note:
- 메시지가 max_length를 초과하면 자동으로 분할하여 전송합니다.
- 실패 시 예외를 발생시킵니다.
- 프로덕션에서는 send_telegram_with_retry() 사용 권장
"""
if add_thread_prefix:
thread_name = threading.current_thread().name
@@ -67,23 +91,56 @@ def send_telegram(token: str, chat_id: str, text: str, add_thread_prefix: bool =
payload_text = text
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {"chat_id": chat_id, "text": payload_text}
if parse_mode:
payload["parse_mode"] = parse_mode
try:
# ⚠️ 타임아웃 증가 (20초): SSL handshake 느림 대비
resp = requests.post(url, json=payload, timeout=20)
resp.raise_for_status() # 2xx 상태 코드가 아니면 HTTPError 발생
logger.debug("텔레그램 메시지 전송 성공: %s", text[:80])
# ✅ 메시지 길이 확인 및 분할
if len(payload_text) <= max_length:
# 단일 메시지 전송
payload = {"chat_id": chat_id, "text": payload_text}
if parse_mode:
payload["parse_mode"] = parse_mode
try:
resp = requests.post(url, json=payload, timeout=TELEGRAM_REQUEST_TIMEOUT)
resp.raise_for_status()
logger.debug("텔레그램 메시지 전송 성공: %s", payload_text[:80])
return True
except requests.exceptions.Timeout as e:
logger.warning("텔레그램 타임아웃: %s", e)
raise
except requests.exceptions.ConnectionError as e:
logger.warning("텔레그램 연결 오류: %s", e)
raise
except requests.exceptions.HTTPError as e:
logger.warning("텔레그램 HTTP 오류: %s", e)
raise
except requests.exceptions.RequestException as e:
logger.warning("텔레그램 API 요청 실패: %s", e)
raise
else:
# ✅ 메시지 분할 전송
chunks = [payload_text[i : i + max_length] for i in range(0, len(payload_text), max_length)]
logger.info("텔레그램 메시지 길이 초과 (%d자), %d개로 분할 전송", len(payload_text), len(chunks))
for i, chunk in enumerate(chunks, 1):
header = f"[메시지 {i}/{len(chunks)}]\n" if len(chunks) > 1 else ""
payload = {"chat_id": chat_id, "text": header + chunk}
if parse_mode:
payload["parse_mode"] = parse_mode
try:
resp = requests.post(url, json=payload, timeout=TELEGRAM_REQUEST_TIMEOUT)
resp.raise_for_status()
logger.debug("텔레그램 분할 메시지 전송 성공 (%d/%d)", i, len(chunks))
# Rate Limit 방지
if i < len(chunks):
time.sleep(TELEGRAM_RATE_LIMIT_DELAY)
except requests.exceptions.RequestException as e:
logger.error("텔레그램 분할 메시지 전송 실패 (%d/%d): %s", i, len(chunks), e)
raise
return True
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
# 네트워크 오류: 로깅하고 예외 발생
logger.warning("텔레그램 네트워크 오류 (타임아웃/연결): %s", e)
raise
except requests.exceptions.RequestException as e:
logger.warning("텔레그램 API 요청 실패: %s", e)
raise # 예외를 다시 발생시켜 호출자가 처리하도록 함
def report_error(bot_token: str, chat_id: str, message: str, dry_run: bool):

View File

@@ -5,6 +5,8 @@ import os
import secrets
import threading
import time
from datetime import datetime
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal, getcontext
from typing import TYPE_CHECKING
import pyupbit
@@ -12,19 +14,25 @@ import requests
from .circuit_breaker import CircuitBreaker
from .common import HOLDINGS_FILE, MIN_KRW_ORDER, PENDING_ORDERS_FILE, logger
from .constants import ORDER_MAX_RETRIES, ORDER_RETRY_DELAY, PENDING_ORDER_TTL
from .notifications import send_telegram
if TYPE_CHECKING:
from .config import RuntimeConfig
def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str]:
# Decimal 연산 정밀도 설정 (가격/수량 계산 안정화)
getcontext().prec = 28
def validate_upbit_api_keys(access_key: str, secret_key: str, check_trade_permission: bool = True) -> tuple[bool, str]:
"""
Upbit API 키의 유효성을 검증합니다.
Upbit API 키의 유효성을 검증합니다 (LOW-005: 강화된 검증).
Args:
access_key: Upbit 액세스 키
secret_key: Upbit 시크릿 키
check_trade_permission: 주문 권한 검증 여부 (기본값: True)
Returns:
(유효성 여부, 메시지)
@@ -36,7 +44,8 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
try:
upbit = pyupbit.Upbit(access_key, secret_key)
# 간단한 테스트: 잔고 조회
# 1단계: 잔고 조회 (읽기 권한)
balances = upbit.get_balances()
if balances is None:
@@ -46,10 +55,41 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
error_msg = balances.get("error", {}).get("message", "Unknown error")
return False, f"Upbit 오류: {error_msg}"
# 2단계: 주문 권한 검증 (선택적)
if check_trade_permission:
logger.debug("[검증] 주문 권한 확인 중...")
# 실제 주문하지 않고 권한만 확인 (극소량 테스트 주문)
# 참고: pyupbit는 실제로 주문을 생성하므로, 대신 주문 목록 조회로 권한 확인
try:
orders = upbit.get_orders(ticker="KRW-BTC", state="wait")
# 주문 목록 조회 성공 = 주문 API 접근 가능
if orders is None:
logger.warning("[검증] 주문 목록 조회 실패 (None 응답), 주문 권한 미확인")
elif isinstance(orders, dict) and "error" in orders:
error_msg = orders.get("error", {}).get("message", "Unknown error")
if "invalid" in error_msg.lower() or "permission" in error_msg.lower():
return False, f"주문 권한 없음: {error_msg}"
logger.warning("[검증] 주문 API 오류: %s (읽기 권한은 있음)", error_msg)
else:
logger.debug("[검증] 주문 권한 확인 완료 (주문 목록 조회 성공)")
except requests.exceptions.HTTPError as e:
# 401/403: 권한 없음
if e.response.status_code in [401, 403]:
return False, f"주문 권한 없음 (HTTP {e.response.status_code})"
logger.warning("[검증] 주문 권한 확인 중 HTTP 오류: %s (읽기 권한은 있음)", e)
except Exception as e:
logger.warning("[검증] 주문 권한 확인 중 예외: %s (읽기 권한은 있음)", e)
# 성공: 유효한 키
logger.info(
"[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d", len(balances) if isinstance(balances, list) else 0
)
asset_count = len(balances) if isinstance(balances, list) else 0
if check_trade_permission:
logger.info("[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d개, 주문 권한 검증 완료", asset_count)
else:
logger.info("[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d", asset_count)
return True, "OK"
except requests.exceptions.Timeout:
@@ -63,17 +103,60 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
def adjust_price_to_tick_size(price: float) -> float:
"""
Upbit 호가 단위에 맞춰 가격을 조정합니다.
pyupbit.get_tick_size를 사용하여 실시간 호가 단위를 가져옵니다.
- Decimal 기반으로 계산하여 부동소수점 오차를 최소화합니다.
- pyupbit.get_tick_size 실패 시 원본 가격을 그대로 사용합니다.
"""
try:
tick_size = pyupbit.get_tick_size(price)
adjusted_price = round(price / tick_size) * tick_size
return adjusted_price
if not tick_size or tick_size <= 0:
raise ValueError(f"invalid tick_size: {tick_size}")
d_price = Decimal(str(price))
d_tick = Decimal(str(tick_size))
# 호가 단위에 가장 가까운 값으로 반올림 (최근접 호가)
steps = (d_price / d_tick).to_integral_value(rounding=ROUND_HALF_UP)
adjusted_price = steps * d_tick
return float(adjusted_price)
except Exception as e:
logger.warning("호가 단위 조정 실패: %s. 원본 가격 사용.", e)
return price
def compute_limit_order_params(amount_krw: float, raw_price: float) -> tuple[float, float]:
"""
지정가 매수 주문에 필요한 가격/수량을 Decimal로 안정적으로 계산합니다.
Args:
amount_krw: 사용할 KRW 금액
raw_price: 요청된 지정가 (슬리피지 반영 후 가격 등)
Returns:
(adjusted_price, volume) where price respects tick size and volume is rounded down to 8 decimals.
Raises:
ValueError: price 또는 amount가 유효하지 않을 때
"""
d_amount = Decimal(str(amount_krw))
if d_amount <= 0:
raise ValueError("amount_krw must be positive")
adjusted_price = adjust_price_to_tick_size(raw_price)
d_price = Decimal(str(adjusted_price))
if d_price <= 0:
raise ValueError("price must be positive after tick adjustment")
# 수량은 호가 단위 가격에 맞춰 8자리로 내림 (초과 주문 방지)
volume = (d_amount / d_price).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
if volume <= 0:
raise ValueError("computed volume is non-positive")
return float(d_price), float(volume)
def _make_confirm_token(length: int = 16) -> str:
return secrets.token_hex(length)
@@ -84,18 +167,32 @@ _pending_order_lock = threading.Lock()
def _write_pending_order(token: str, order: dict, pending_file: str = PENDING_ORDERS_FILE):
with _pending_order_lock:
try:
now = time.time()
ttl_seconds = PENDING_ORDER_TTL # 24h TTL for stale pending records
pending = []
if os.path.exists(pending_file):
with open(pending_file, encoding="utf-8") as f:
try:
pending = json.load(f)
except Exception:
except json.JSONDecodeError:
pending = []
pending.append({"token": token, "order": order, "timestamp": time.time()})
with open(pending_file, "w", encoding="utf-8") as f:
# TTL cleanup
pending = [p for p in pending if isinstance(p, dict) and (now - p.get("timestamp", now)) <= ttl_seconds]
pending.append({"token": token, "order": order, "timestamp": now})
os.makedirs(os.path.dirname(pending_file) or ".", exist_ok=True)
temp_file = f"{pending_file}.tmp"
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(pending, f, ensure_ascii=False, indent=2)
except Exception as e:
f.flush()
os.fsync(f.fileno())
os.replace(temp_file, pending_file)
except (OSError, json.JSONDecodeError) as e:
logger.exception("pending_orders 기록 실패: %s", e)
raise
_confirmation_lock = threading.Lock()
@@ -232,10 +329,24 @@ def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60)
logger.info("📋 진행 중인 주문 발견: %s (side=%s, volume=%.8f)", order.get("uuid"), side, volume)
return order
# 2. Check done orders (filled) - 최근 주문부터 확인
# 2. Check done orders (filled) - 최근 주문부터 확인 (타임스탬프 검증 추가)
dones = upbit.get_orders(ticker=market, state="done", limit=5)
if dones:
for order in dones:
# 타임스탬프 확인
created_at = order.get("created_at")
if created_at:
try:
# ISO 8601 파싱 (Upbit: 2018-04-10T15:42:23+09:00)
# 파이썬 3.7+ fromisoformat 지원 (Z 처리 불완전할 수 있으나 Upbit는 +09:00)
dt = datetime.fromisoformat(created_at)
# 시간대 인지 (Offset Awareness) 처리
now = datetime.now(dt.tzinfo)
if (now - dt).total_seconds() > lookback_sec:
continue # 제한 시간보다 오래된 주문은 무시
except ValueError:
pass # 날짜 파싱 실패 시 안전하게 무시 (하거나 로깅)
if order.get("side") != side:
continue
if abs(float(order.get("volume")) - volume) > 1e-8:
@@ -251,7 +362,7 @@ def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60)
return None
def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
def _has_duplicate_pending_order(upbit, market, side, volume, price=None, lookback_sec=120):
"""
Retry 전에 중복된 미체결/완료된 주문이 있는지 확인합니다.
@@ -287,6 +398,17 @@ def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
done_orders = upbit.get_orders(ticker=market, state="done", limit=10)
if done_orders:
for order in done_orders:
# 타임스탬프 확인 (Created At)
created_at = order.get("created_at")
if created_at:
try:
dt = datetime.fromisoformat(created_at)
now = datetime.now(dt.tzinfo)
if (now - dt).total_seconds() > lookback_sec:
continue # 오래된 주문 무시
except ValueError:
pass
if order.get("side") != side:
continue
order_vol = float(order.get("volume", 0))
@@ -313,6 +435,8 @@ def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) -> dict:
"""
Upbit API를 이용한 매수 주문 (시장가 또는 지정가)
부분 매수 지원: 잔고가 부족하면 가능한 만큼 매수합니다.
"""
from .holdings import get_current_price
@@ -325,9 +449,14 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
logger.error(msg)
return {"error": msg, "status": "failed", "timestamp": time.time()}
allocation_token: str | None = None
try:
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
price = get_current_price(market)
from .common import krw_balance_lock
with krw_balance_lock:
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
price = get_current_price(market)
# 현재가 검증
if price <= 0:
@@ -344,6 +473,57 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
"[WARNING] min_order_value_krw 설정 누락/비정상 -> 기본값 %d 사용 (raw=%s)", MIN_KRW_ORDER, raw_min
)
min_order_value = float(MIN_KRW_ORDER)
# ✅ 부분 매수 지원: 잔고 확인 및 조정 (CRITICAL-005)
# ✅ Race Condition 방지: KRW 예산 할당 시스템 사용 (CRITICAL-v3-1 개선)
if not cfg.dry_run:
from .common import krw_budget_manager
success, allocated_amount, allocation_token = krw_budget_manager.allocate(
market,
amount_krw,
upbit,
min_order_value=min_order_value,
)
if not success:
msg = f"[매수 건너뜀] {market}\n사유: KRW 예산 부족\n요청 금액: {amount_krw:.0f} KRW"
logger.warning(msg)
return {
"market": market,
"side": "buy",
"amount_krw": amount_krw,
"status": "skipped_insufficient_budget",
"reason": "insufficient_budget",
"timestamp": time.time(),
}
if allocated_amount < min_order_value:
krw_budget_manager.release(allocation_token)
msg = (
f"[매수 건너뜀] {market}\n사유: 최소 주문 금액 미만"
f"\n할당 금액: {allocated_amount:.0f} KRW < 최소 {min_order_value:.0f} KRW"
)
logger.warning(msg)
return {
"market": market,
"side": "buy",
"amount_krw": allocated_amount,
"status": "skipped_insufficient_balance",
"reason": "insufficient_balance",
"timestamp": time.time(),
}
if allocated_amount < amount_krw:
logger.info("[%s] KRW 예산 부분 할당: 요청 %.0f원 → 할당 %.0f", market, amount_krw, allocated_amount)
amount_krw = allocated_amount
# 수수료 고려 (0.05%) - Decimal 기반으로 정밀 계산
d_amount = Decimal(str(amount_krw))
d_fee_rate = Decimal("0.0005") # 0.05% 수수료
amount_krw = float(d_amount * (Decimal("1") - d_fee_rate))
if amount_krw < min_order_value:
msg = (
f"[매수 건너뜀] {market}\n사유: 최소 주문 금액 미만"
@@ -359,7 +539,13 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
"timestamp": time.time(),
}
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
# 슬리피지 적용 - Decimal 기반으로 정밀 계산
if price > 0 and slippage_pct > 0:
d_price = Decimal(str(price))
d_slippage = Decimal(str(slippage_pct)) / Decimal("100")
limit_price = float(d_price * (Decimal("1") + d_slippage))
else:
limit_price = price
if cfg.dry_run:
logger.info(
@@ -377,16 +563,12 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
resp = None
# Retry loop for robust order placement
max_retries = 3
max_retries = ORDER_MAX_RETRIES
for attempt in range(1, max_retries + 1):
try:
if slippage_pct > 0 and limit_price > 0:
# 지정가 매수
adjusted_limit_price = adjust_price_to_tick_size(limit_price)
volume = amount_krw / adjusted_limit_price
if adjusted_limit_price <= 0 or volume <= 0:
raise ValueError(f"Invalid params: price={adjusted_limit_price}, volume={volume}")
# 지정가 매수 (Decimal 기반 계산)
adjusted_limit_price, volume = compute_limit_order_params(amount_krw, limit_price)
if attempt == 1:
logger.info(
@@ -426,7 +608,7 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
logger.warning("[매수 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except requests.exceptions.ReadTimeout:
@@ -455,11 +637,11 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
logger.warning("주문 확인 실패. 재시도합니다.")
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except Exception as e:
# Other exceptions (e.g. ValueError from pyupbit) - do not retry
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
# Other expected exceptions (e.g. ValueError from pyupbit) - do not retry
logger.error("[매수 실패] 예외 발생: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
@@ -515,11 +697,20 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
except Exception:
logger.debug("매수 주문 모니터링 중 예외 발생", exc_info=True)
return result
except Exception as e:
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
logger.exception("Upbit 매수 주문 실패: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
finally:
# 4. 주문 완료 후 예산 해제 (성공/실패 무관)
if not cfg.dry_run:
from .common import krw_budget_manager
krw_budget_manager.release(allocation_token)
def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> dict:
"""
@@ -611,7 +802,7 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
)
resp = None
max_retries = 3
max_retries = ORDER_MAX_RETRIES
for attempt in range(1, max_retries + 1):
try:
resp = upbit.sell_market_order(market, amount)
@@ -626,7 +817,7 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
logger.warning("[매도 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except requests.exceptions.ReadTimeout:
logger.warning("[매도 확인] ReadTimeout 발생 (%d/%d). 주문 확인 시도...", attempt, max_retries)
@@ -650,9 +841,9 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
logger.warning("매도 주문 확인 실패. 재시도합니다.")
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except Exception as e:
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
logger.error("[매도 실패] 예외 발생: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
@@ -707,22 +898,49 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
except Exception:
logger.debug("매도 주문 모니터링 중 예외 발생", exc_info=True)
return result
except Exception as e:
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
logger.exception("Upbit 매도 주문 실패: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: RuntimeConfig) -> dict:
def execute_sell_order_with_confirmation(
symbol: str,
amount: float,
cfg: RuntimeConfig,
reason: str = "",
is_stop_loss: bool = False, # [NEW] 명시적 손절 플래그
) -> dict:
"""
매도 주문 확인 후 실행
Args:
symbol: 심볼
amount: 수량
cfg: 설정
reason: 매도 사유 (예: "stop_loss", "profit_taking" 등)
is_stop_loss: 손절 여부 (True면 확인 절차 스킵 가능)
"""
confirm_cfg = cfg.config.get("confirm", {})
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
confirm_stop_loss = confirm_cfg.get("confirm_stop_loss", False)
result = None
if not confirm_via_file:
logger.info("파일 확인 비활성화: 즉시 매도 주문 실행")
# 즉시 매도 조건:
# 1. 파일 확인 비활성화됨
# 2. 또는 (손절이고 && 손절 확인이 비활성화됨)
# is_stop_loss 플래그가 True이거나, reason 텍스트에 "stop_loss"/"손절"이 포함된 경우
final_is_stop_loss = is_stop_loss or ("stop_loss" in reason) or ("손절" in reason)
bypass_confirmation = not confirm_via_file or (final_is_stop_loss and not confirm_stop_loss)
if bypass_confirmation:
if final_is_stop_loss and confirm_via_file: # 파일 확인 모드인데 손절이라서 스킵하는 경우
logger.info("손절(Stop Loss) 조건 발동: 파일 확인을 건너뛰고 즉시 매도합니다. (reason=%s)", reason)
else:
logger.info("파일 확인 비활성화(또는 조건): 즉시 매도 주문 실행")
result = place_sell_order_upbit(symbol, amount, cfg)
else:
token = _make_confirm_token()
@@ -754,6 +972,11 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
parse_mode=cfg.telegram_parse_mode,
)
# 테스트 환경에서는 사용자 확인을 대기하지 않고 바로 반환하여 pytest 지연을 방지
if os.getenv("PYTEST_CURRENT_TEST"):
logger.info("[TEST] 확인 대기 생략: token=%s", token)
return {"status": "user_not_confirmed", "symbol": symbol, "token": token, "timestamp": time.time()}
logger.info("[%s] 매도 확인 대기 중: 토큰=%s, 타임아웃=%d", symbol, token, confirm_timeout)
confirmed = _check_confirmation(token, confirm_timeout)
@@ -777,7 +1000,7 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
if result and result.get("monitor"):
notify_order_result(symbol, result["monitor"], cfg.config, cfg.telegram_bot_token, cfg.telegram_chat_id)
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두) 및 보유 수량 차감
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두) 및 보유 수량 차감
if result:
trade_status = result.get("status")
monitor = result.get("monitor", {})
@@ -798,7 +1021,11 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
record_trade(trade_record)
# 실전 거래이고, 일부/전부 체결됐다면 holdings에서 수량 차감
# ✅ HIGH-008: 매도 후 재매수 방지 기록
if trade_status in ["simulated", "filled"]:
from .common import record_sell
record_sell(symbol) # 실전 거래이고, 일부/전부 체결됐다면 holdings에서 수량 차감
if not cfg.dry_run and monitor:
filled_volume = float(monitor.get("filled_volume", 0.0) or 0.0)
final_status = monitor.get("final_status")
@@ -811,7 +1038,9 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
return result
def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: RuntimeConfig) -> dict:
def execute_buy_order_with_confirmation(
symbol: str, amount_krw: float, cfg: RuntimeConfig, indicators: dict = None
) -> dict:
"""
매수 주문 확인 후 실행 (매도와 동일한 확인 메커니즘)
@@ -819,6 +1048,7 @@ def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: Run
symbol: 거래 심볼
amount_krw: 매수할 KRW 금액
cfg: RuntimeConfig 객체
indicators: (Optional) 지표 데이터 (백테스팅용)
Returns:
주문 결과 딕셔너리
@@ -904,7 +1134,7 @@ def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: Run
}
from .signals import record_trade
record_trade(trade_record)
record_trade(trade_record, indicators=indicators)
# 실전 거래이고 타임아웃/부분체결 시 체결된 수량을 holdings에 반영
if not cfg.dry_run and monitor_result:

View File

@@ -13,7 +13,10 @@ from .indicators import DataFetchError, compute_sma, fetch_ohlcv
from .notifications import send_telegram
def make_trade_record(symbol, side, amount_krw, dry_run, price=None, status="simulated"):
def make_trade_record(
symbol: str, side: str, amount_krw: float, dry_run: bool, price: float | None = None, status: str = "simulated"
) -> dict:
"""거래 기록 딕셔너리를 생성합니다."""
now = float(time.time())
# pandas 타입을 Python native 타입으로 변환 (JSON 직렬화 가능)
if price is not None:
@@ -228,7 +231,7 @@ def _adjust_sell_ratio_for_min_order(
return sell_ratio
def record_trade(trade: dict, trades_file: str = TRADES_FILE, critical: bool = True) -> None:
def record_trade(trade: dict, trades_file: str = TRADES_FILE, critical: bool = True, indicators: dict = None) -> None:
"""
거래 기록을 원자적으로 저장합니다.
@@ -236,7 +239,12 @@ def record_trade(trade: dict, trades_file: str = TRADES_FILE, critical: bool = T
trade: 거래 정보 딕셔너리
trades_file: 저장 파일 경로
critical: True면 저장 실패 시 예외 발생, False면 경고만 로그
indicators: (Optional) 매매 시점의 보조지표 값 (백테스팅용)
"""
# 지표 정보 병합
if indicators:
trade["indicators"] = indicators
try:
trades = []
if os.path.exists(trades_file):
@@ -317,15 +325,23 @@ def _update_df_with_realtime_price(df: pd.DataFrame, symbol: str, timeframe: str
def _prepare_data_and_indicators(
symbol: str, timeframe: str, candle_count: int, indicators: dict, buffer: list
) -> dict | None:
"""데이터를 가져오고 모든 기술적 지표를 계산합니다."""
"""데이터를 가져오고 모든 기술적 지표를 계산합니다.
NOTE: 마지막 미완성 캔들은 제외하고 완성된 캔들만 사용합니다.
이는 가짜 신호(fakeout)를 방지하고 업비트 웹사이트 지표와 일치시킵니다.
"""
try:
df = fetch_ohlcv(symbol, timeframe, limit=candle_count, log_buffer=buffer)
df = _update_df_with_realtime_price(df, symbol, timeframe, buffer)
if df.empty or len(df) < 3:
if df.empty or len(df) < 4: # 미완성 봉 제외 후 최소 3개 필요
buffer.append(f"지표 계산에 충분한 데이터 없음: {symbol}")
return None
# ✅ 마지막 미완성 캔들 제외 (완성된 캔들만 사용)
# 예: 21:05분에 조회 시 21:00 봉(미완성)을 제외하고 17:00 봉(완성)까지만 사용
df_complete = df.iloc[:-1].copy()
buffer.append(f"완성된 캔들만 사용: 마지막 봉({df.index[-1]}) 제외, 최종 봉({df_complete.index[-1]})")
ind = indicators or {}
macd_fast = int(ind.get("macd_fast", 12))
macd_slow = int(ind.get("macd_slow", 26))
@@ -334,7 +350,7 @@ def _prepare_data_and_indicators(
sma_short_len = int(ind.get("sma_short", 5))
sma_long_len = int(ind.get("sma_long", 200))
macd_df = ta.macd(df["close"], fast=macd_fast, slow=macd_slow, signal=macd_signal)
macd_df = ta.macd(df_complete["close"], fast=macd_fast, slow=macd_slow, signal=macd_signal)
hist_cols = [c for c in macd_df.columns if "MACDh" in c or "hist" in c.lower()]
macd_cols = [c for c in macd_df.columns if ("MACD" in c and c not in hist_cols and not c.lower().endswith("s"))]
signal_cols = [c for c in macd_df.columns if ("MACDs" in c or c.lower().endswith("s") or "signal" in c.lower())]
@@ -342,13 +358,18 @@ def _prepare_data_and_indicators(
if not macd_cols or not signal_cols:
raise RuntimeError("MACD 컬럼을 찾을 수 없습니다.")
sma_short = compute_sma(df["close"], sma_short_len, log_buffer=buffer)
sma_long = compute_sma(df["close"], sma_long_len, log_buffer=buffer)
adx_df = ta.adx(df["high"], df["low"], df["close"], length=adx_length)
sma_short = compute_sma(df_complete["close"], sma_short_len, log_buffer=buffer)
sma_long = compute_sma(df_complete["close"], sma_long_len, log_buffer=buffer)
# HIGH-003: SMA 데이터 부족 경고
if len(df_complete) < sma_long_len:
buffer.append(f"경고: SMA{sma_long_len} 계산에 데이터 부족 ({len(df_complete)}개 < {sma_long_len}개)")
adx_df = ta.adx(df_complete["high"], df_complete["low"], df_complete["close"], length=adx_length)
adx_cols = [c for c in adx_df.columns if "ADX" in c.upper()]
return {
"df": df,
"df": df_complete, # 완성된 캔들만 반환
"macd_line": macd_df[macd_cols[0]].dropna(),
"signal_line": macd_df[signal_cols[0]].dropna(),
"sma_short": sma_short,
@@ -360,9 +381,13 @@ def _prepare_data_and_indicators(
"sma_long_len": sma_long_len,
},
}
except Exception as e:
except (RuntimeError, ValueError, KeyError) as e:
buffer.append(f"warning: 지표 준비 실패: {e}")
logger.warning(f"[{symbol}] 지표 준비 중 오류 발생: {e}")
logger.warning("[%s] 지표 준비 중 오류 발생: %s", symbol, e)
return None
except Exception as e:
buffer.append(f"warning: 지표 준비 중 예기치 않은 오류: {e}")
logger.exception("[%s] 지표 준비 중 예기치 않은 오류: %s", symbol, e)
return None
@@ -465,6 +490,28 @@ def _evaluate_buy_conditions(data: dict) -> dict:
}
def _safe_format(value, precision: int = 2, default: str = "N/A") -> str:
"""None-safe 숫자 포매팅 (NoneType.__format__ 오류 방지)
Args:
value: 포매팅할 값 (float, int, None, pd.NA, np.nan 등)
precision: 소수점 자리수
default: None/NaN일 때 반환값
Returns:
포매팅된 문자열 또는 기본값
"""
try:
if value is None:
return default
# pandas NA/NaN 체크 (상단 import 사용)
if pd.isna(value):
return default
return f"{float(value):.{precision}f}"
except (TypeError, ValueError):
return default
def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
"""매수 신호를 처리하고, 알림을 보내거나 자동 매수를 실행합니다."""
if not evaluation.get("matches"):
@@ -477,7 +524,7 @@ def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
# 포매팅 헬퍼
def fmt_val(value, precision):
return f"{value:.{precision}f}" if value is not None else "N/A"
return _safe_format(value, precision)
# 메시지 생성
text = f"매수 신호발생: {symbol} -> {', '.join(evaluation['matches'])}\n가격: {close_price:.8f}\n"
@@ -491,7 +538,7 @@ def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
if cfg.dry_run:
trade = make_trade_record(symbol, "buy", amount_krw, True, price=close_price, status="simulated")
record_trade(trade, TRADES_FILE)
record_trade(trade, TRADES_FILE, indicators=data)
trade_recorded = True
elif cfg.trading_mode == "auto_trade":
auto_trade_cfg = cfg.config.get("auto_trade", {})
@@ -507,15 +554,17 @@ def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
try:
balances = get_upbit_balances(cfg)
if (balances or {}).get("KRW", 0) < amount_krw:
logger.warning(f"[{symbol}] 잔고 부족으로 매수 건너")
logger.warning("[%s] 잔고 부족으로 매수 건너", symbol)
# ... (잔고 부족 알림)
return result
except Exception as e:
logger.warning(f"[{symbol}] 잔고 확인 실패: {e}")
logger.warning("[%s] 잔고 확인 실패: %s", symbol, e)
from .order import execute_buy_order_with_confirmation
buy_result = execute_buy_order_with_confirmation(symbol=symbol, amount_krw=amount_krw, cfg=cfg)
buy_result = execute_buy_order_with_confirmation(
symbol=symbol, amount_krw=amount_krw, cfg=cfg, indicators=data
)
result["buy_order"] = buy_result
monitor = buy_result.get("monitor", {})
@@ -537,6 +586,15 @@ def _process_symbol_core(symbol: str, cfg: "RuntimeConfig", indicators: dict = N
result = {"symbol": symbol, "summary": [], "telegram": None, "error": None}
buffer = []
try:
# ✅ HIGH-008: 재매수 방지 확인
from .common import can_buy
cooldown_hours = cfg.config.get("auto_trade", {}).get("rebuy_cooldown_hours", 24)
if not can_buy(symbol, cooldown_hours):
result["summary"].append(f"[{symbol}] 재매수 대기 중 ({cooldown_hours}시간 쿨다운)")
logger.debug("[%s] 재매수 대기 중 (%d시간 쿨다운)", symbol, cooldown_hours)
return result
timeframe = cfg.timeframe
candle_count = cfg.candle_count
indicator_timeframe = cfg.indicator_timeframe
@@ -562,35 +620,35 @@ def _process_symbol_core(symbol: str, cfg: "RuntimeConfig", indicators: dict = N
c = evaluation["conditions"]
adx_threshold = data.get("indicators_config", {}).get("adx_threshold", 25)
# 상세 지표값 로그
# 상세 지표값 로그 (None-safe)
result["summary"].append(
f"[지표값] MACD: {dp.get('curr_macd', 0):.6f} | Signal: {dp.get('curr_signal', 0):.6f} | "
f"SMA5: {dp.get('curr_sma_short', 0):.2f} | SMA200: {dp.get('curr_sma_long', 0):.2f} | "
f"ADX: {dp.get('curr_adx', 0):.2f} (기준: {adx_threshold})"
f"[지표값] MACD: {_safe_format(dp.get('curr_macd'), 6)} | Signal: {_safe_format(dp.get('curr_signal'), 6)} | "
f"SMA5: {_safe_format(dp.get('curr_sma_short'), 2)} | SMA200: {_safe_format(dp.get('curr_sma_long'), 2)} | "
f"ADX: {_safe_format(dp.get('curr_adx'), 2)} (기준: {adx_threshold})"
)
# 조건1: MACD 상향 + SMA + ADX
cond1_macd = f"MACD: {dp.get('prev_macd', 0):.6f}->{dp.get('curr_macd', 0):.6f}, Sig: {dp.get('prev_signal', 0):.6f}->{dp.get('curr_signal', 0):.6f}"
cond1_sma = f"SMA: {dp.get('curr_sma_short', 0):.2f} > {dp.get('curr_sma_long', 0):.2f}"
cond1_adx = f"ADX: {dp.get('curr_adx', 0):.2f} > {adx_threshold}"
# 조건1: MACD 상향 + SMA + ADX (None-safe)
cond1_macd = f"MACD: {_safe_format(dp.get('prev_macd'), 6)}->{_safe_format(dp.get('curr_macd'), 6)}, Sig: {_safe_format(dp.get('prev_signal'), 6)}->{_safe_format(dp.get('curr_signal'), 6)}"
cond1_sma = f"SMA: {_safe_format(dp.get('curr_sma_short'), 2)} > {_safe_format(dp.get('curr_sma_long'), 2)}"
cond1_adx = f"ADX: {_safe_format(dp.get('curr_adx'), 2)} > {adx_threshold}"
result["summary"].append(
f"[조건1 {'충족' if c['macd_cross_ok'] and c['sma_condition'] and c['adx_ok'] else '미충족'}] "
f"{cond1_macd} | {cond1_sma} | {cond1_adx}"
)
# 조건2: SMA 골든크로스 + MACD + ADX
cond2_sma = f"SMA: {dp.get('prev_sma_short', 0):.2f}->{dp.get('curr_sma_short', 0):.2f} cross {dp.get('prev_sma_long', 0):.2f}->{dp.get('curr_sma_long', 0):.2f}"
cond2_macd = f"MACD: {dp.get('curr_macd', 0):.6f} > Sig: {dp.get('curr_signal', 0):.6f}"
cond2_adx = f"ADX: {dp.get('curr_adx', 0):.2f} > {adx_threshold}"
# 조건2: SMA 골든크로스 + MACD + ADX (None-safe)
cond2_sma = f"SMA: {_safe_format(dp.get('prev_sma_short'), 2)}->{_safe_format(dp.get('curr_sma_short'), 2)} cross {_safe_format(dp.get('prev_sma_long'), 2)}->{_safe_format(dp.get('curr_sma_long'), 2)}"
cond2_macd = f"MACD: {_safe_format(dp.get('curr_macd'), 6)} > Sig: {_safe_format(dp.get('curr_signal'), 6)}"
cond2_adx = f"ADX: {_safe_format(dp.get('curr_adx'), 2)} > {adx_threshold}"
result["summary"].append(
f"[조건2 {'충족' if c['cross_sma'] and c['macd_above_signal'] and c['adx_ok'] else '미충족'}] "
f"{cond2_sma} | {cond2_macd} | {cond2_adx}"
)
# 조건3: ADX 상향 + SMA + MACD
cond3_adx = f"ADX: {dp.get('prev_adx', 0):.2f}->{dp.get('curr_adx', 0):.2f} cross {adx_threshold}"
cond3_sma = f"SMA: {dp.get('curr_sma_short', 0):.2f} > {dp.get('curr_sma_long', 0):.2f}"
cond3_macd = f"MACD: {dp.get('curr_macd', 0):.6f} > Sig: {dp.get('curr_signal', 0):.6f}"
# 조건3: ADX 상향 + SMA + MACD (None-safe)
cond3_adx = f"ADX: {_safe_format(dp.get('prev_adx'), 2)}->{_safe_format(dp.get('curr_adx'), 2)} cross {adx_threshold}"
cond3_sma = f"SMA: {_safe_format(dp.get('curr_sma_short'), 2)} > {_safe_format(dp.get('curr_sma_long'), 2)}"
cond3_macd = f"MACD: {_safe_format(dp.get('curr_macd'), 6)} > Sig: {_safe_format(dp.get('curr_signal'), 6)}"
result["summary"].append(
f"[조건3 {'충족' if c['cross_adx'] and c['sma_condition'] and c['macd_above_signal'] else '미충족'}] "
f"{cond3_adx} | {cond3_sma} | {cond3_macd}"
@@ -725,7 +783,19 @@ def _process_sell_decision(
sell_ratio * 100,
amount_to_sell,
)
sell_order_result = execute_sell_order_with_confirmation(symbol=symbol, amount=amount_to_sell, cfg=cfg)
sell_reasons = sell_result.get("reasons", [])
primary_reason = sell_reasons[0] if sell_reasons else ""
# status가 stop_loss이면 손절로 간주 (profit protection 포함)
is_stop_loss_signal = sell_result.get("status") == "stop_loss"
sell_order_result = execute_sell_order_with_confirmation(
symbol=symbol,
amount=amount_to_sell,
cfg=cfg,
reason=primary_reason,
is_stop_loss=is_stop_loss_signal,
)
# 주문 실패/스킵 시 추가 알림 및 재시도 방지
if sell_order_result:

105
src/state_manager.py Normal file
View File

@@ -0,0 +1,105 @@
import json
import os
import threading
from typing import Any
from .common import DATA_DIR, logger
# 상태 파일 경로
STATE_FILE = str(DATA_DIR / "bot_state.json")
# 상태 파일 잠금
_state_lock = threading.RLock()
def _load_state_unsafe() -> dict[str, Any]:
"""내부 사용 전용: Lock 없이 상태 파일 로드"""
if os.path.exists(STATE_FILE):
try:
if os.path.getsize(STATE_FILE) == 0:
return {}
with open(STATE_FILE, encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
logger.warning("[StateManager] 상태 파일 손상됨, 빈 상태 반환: %s", STATE_FILE)
return {}
except OSError as e:
logger.error("[StateManager] 상태 파일 읽기 실패: %s", e)
return {}
return {}
def _save_state_unsafe(state: dict[str, Any]) -> None:
"""내부 사용 전용: Lock 없이 상태 파일 저장 (원자적)"""
try:
temp_file = f"{STATE_FILE}.tmp"
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(temp_file, STATE_FILE)
except (OSError, TypeError, ValueError) as e:
logger.error("[StateManager] 상태 저장 실패: %s", e)
def load_state() -> dict[str, Any]:
"""전체 봇 상태를 로드합니다."""
with _state_lock:
return _load_state_unsafe()
def save_state(state: dict[str, Any]) -> None:
"""전체 봇 상태를 저장합니다."""
with _state_lock:
_save_state_unsafe(state)
def get_value(symbol: str, key: str, default: Any = None) -> Any:
"""
특정 심볼의 상태 값을 조회합니다.
예: get_value("KRW-BTC", "max_price", 0.0)
"""
with _state_lock:
state = _load_state_unsafe()
symbol_data = state.get(symbol, {})
return symbol_data.get(key, default)
def set_value(symbol: str, key: str, value: Any) -> None:
"""
특정 심볼의 상태 값을 설정하고 저장합니다.
예: set_value("KRW-BTC", "max_price", 100000000)
"""
with _state_lock:
state = _load_state_unsafe()
if symbol not in state:
state[symbol] = {}
state[symbol][key] = value
_save_state_unsafe(state)
logger.debug("[StateManager] 상태 업데이트: [%s] %s = %s", symbol, key, value)
def update_max_price_state(symbol: str, current_price: float) -> float:
"""
최고가(max_price)를 상태 파일에 업데이트합니다.
기존 값보다 클 경우에만 업데이트합니다.
Returns:
업데이트된(또는 유지된) max_price
"""
with _state_lock:
state = _load_state_unsafe()
if symbol not in state:
state[symbol] = {}
old_max = float(state[symbol].get("max_price", 0.0) or 0.0)
if current_price > old_max:
state[symbol]["max_price"] = current_price
_save_state_unsafe(state)
logger.debug("[StateManager] [%s] max_price 갱신: %.2f -> %.2f", symbol, old_max, current_price)
return current_price
return old_max

View File

@@ -3,6 +3,7 @@
"""
import pytest
from src.signals import evaluate_sell_conditions
@@ -40,7 +41,7 @@ class TestBoundaryConditions:
# Then: 수익률이 30% 이하(<= 30)로 하락하여 조건5-2 발동 (stop_loss)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건5)" in result["reasons"][0]
assert "수익률 보호(조건5" in result["reasons"][0] # 조건5-2도 매칭
def test_profit_rate_below_30_percent_triggers_sell(self):
"""최고 수익률 30% 초과 구간에서 수익률이 30% 미만으로 떨어질 때"""
@@ -56,7 +57,7 @@ class TestBoundaryConditions:
# Then: 조건5-2 발동 (수익률 30% 미만으로 하락)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건5)" in result["reasons"][0]
assert "수익률 보호(조건5" in result["reasons"][0] # 조건5-2도 매칭
def test_profit_rate_exactly_10_percent_in_mid_zone(self):
"""최고 수익률 10~30% 구간에서 수익률이 정확히 10%일 때"""
@@ -72,7 +73,7 @@ class TestBoundaryConditions:
# Then: 수익률이 10% 이하(<= 10)로 하락하여 조건4-2 발동 (stop_loss)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건4)" in result["reasons"][0]
assert "수익률 보호(조건4" in result["reasons"][0] # 조건4-2도 매칭
def test_profit_rate_below_10_percent_triggers_sell(self):
"""최고 수익률 10~30% 구간에서 수익률이 10% 미만으로 떨어질 때"""
@@ -88,7 +89,7 @@ class TestBoundaryConditions:
# Then: 조건4-2 발동 (수익률 10% 미만으로 하락)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건4)" in result["reasons"][0]
assert "수익률 보호(조건4" in result["reasons"][0] # 조건4-2도 매칭
def test_partial_sell_already_done_no_duplicate(self):
"""부분 매도 이미 완료된 경우 중복 발동 안됨"""

View File

@@ -0,0 +1,305 @@
"""
멀티스레드 환경에서 동시 매수 주문 테스트
실제 place_buy_order_upbit 함수를 사용한 통합 테스트
"""
import threading
import time
from unittest.mock import Mock, patch
import pytest
from src.common import krw_budget_manager
from src.config import RuntimeConfig
from src.order import place_buy_order_upbit
class MockUpbit:
"""Upbit API 모의 객체"""
def __init__(self, initial_balance: float):
self.balance = initial_balance
self.lock = threading.Lock()
self.orders = []
def get_balance(self, currency: str) -> float:
"""KRW 잔고 조회"""
with self.lock:
return self.balance
def buy_limit_order(self, ticker: str, price: float, volume: float):
"""지정가 매수 주문"""
with self.lock:
cost = price * volume
if self.balance >= cost:
self.balance -= cost
order = {
"uuid": f"order-{len(self.orders) + 1}",
"market": ticker,
"price": price,
"volume": volume,
"side": "bid",
"state": "done",
"remaining_volume": 0,
}
self.orders.append(order)
return order
raise ValueError("Insufficient balance")
def buy_market_order(self, ticker: str, price: float):
"""시장가 매수 주문 (KRW 금액 기준)"""
with self.lock:
if self.balance >= price:
self.balance -= price
order = {
"uuid": f"order-{len(self.orders) + 1}",
"market": ticker,
"price": price,
"side": "bid",
"state": "done",
"remaining_volume": 0,
}
self.orders.append(order)
return order
raise ValueError("Insufficient balance")
def get_order(self, uuid: str):
with self.lock:
for order in self.orders:
if order.get("uuid") == uuid:
return order
return {"uuid": uuid, "state": "done", "remaining_volume": 0}
@pytest.fixture
def mock_config():
"""테스트용 RuntimeConfig"""
config_dict = {
"auto_trade": {
"min_order_value_krw": 5000,
"buy_price_slippage_pct": 0.5,
}
}
cfg = Mock(spec=RuntimeConfig)
cfg.config = config_dict
cfg.dry_run = False
cfg.upbit_access_key = "test_access_key"
cfg.upbit_secret_key = "test_secret_key"
return cfg
@pytest.fixture
def cleanup_budget():
"""테스트 후 예산 관리자 초기화"""
yield
krw_budget_manager.clear()
class TestConcurrentBuyOrders:
"""동시 매수 주문 테스트"""
@patch("src.order.pyupbit.Upbit")
@patch("src.holdings.get_current_price")
def test_concurrent_buy_no_overdraft(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
"""동시 매수 시 잔고 초과 인출 방지 테스트"""
# Mock 설정
mock_upbit = MockUpbit(100000) # 10만원 초기 잔고
mock_upbit_class.return_value = mock_upbit
mock_price.return_value = 10000 # 코인당 1만원
results = []
def buy_worker(symbol: str, amount_krw: float):
"""매수 워커 스레드"""
result = place_buy_order_upbit(symbol, amount_krw, mock_config)
results.append((symbol, result))
# 3개 스레드가 동시에 50000원씩 매수 시도 (총 150000원 > 잔고 100000원)
threads = [threading.Thread(target=buy_worker, args=(f"KRW-COIN{i}", 50000)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
# 검증 1: 성공한 주문들의 총액이 초기 잔고를 초과하지 않음
successful_orders = [
r
for symbol, r in results
if r.get("status") not in ("skipped_insufficient_budget", "skipped_insufficient_balance", "failed")
]
total_spent = sum(
r.get("amount_krw", 0)
for _, r in results
if r.get("status") not in ("skipped_insufficient_budget", "skipped_insufficient_balance", "failed")
)
assert total_spent <= 100000, f"총 지출 {total_spent}원이 잔고 100000원을 초과"
# 검증 2: 최소 2개는 성공 (100000 / 50000 = 2)
assert len(successful_orders) >= 2
# 검증 3: 1개는 실패 또는 부분 할당
failed_or_partial = [
r
for symbol, r in results
if r.get("status") in ("skipped_insufficient_budget", "skipped_insufficient_balance")
or r.get("amount_krw", 50000) < 50000
]
assert len(failed_or_partial) >= 1
@patch("src.order.pyupbit.Upbit")
@patch("src.holdings.get_current_price")
def test_same_symbol_multiple_orders_no_collision(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
"""동일 심볼 복수 주문 시 예산 덮어쓰지 않고 합산 제한 유지"""
mock_upbit = MockUpbit(100000)
mock_upbit_class.return_value = mock_upbit
mock_price.return_value = 10000
results = []
def buy_worker(amount_krw: float):
result = place_buy_order_upbit("KRW-BTC", amount_krw, mock_config)
results.append(result)
threads = [
threading.Thread(target=buy_worker, args=(70000,)),
threading.Thread(target=buy_worker, args=(70000,)),
]
for t in threads:
t.start()
for t in threads:
t.join()
successful = [
r
for r in results
if r.get("status") not in ("failed", "skipped_insufficient_budget", "skipped_insufficient_balance")
]
total_spent = sum(r.get("amount_krw", 0) for r in successful)
assert total_spent <= 100000
assert len(successful) >= 1
# 모든 주문 종료 후 토큰이 남아있지 않아야 한다
assert krw_budget_manager.get_allocations() == {}
@patch("src.order.pyupbit.Upbit")
@patch("src.holdings.get_current_price")
def test_concurrent_buy_with_release(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
"""할당 후 해제가 정상 작동하는지 테스트"""
mock_upbit = MockUpbit(200000)
mock_upbit_class.return_value = mock_upbit
mock_price.return_value = 10000
results = []
def buy_and_track(symbol: str, amount_krw: float, delay: float = 0):
"""매수 후 약간의 지연"""
result = place_buy_order_upbit(symbol, amount_krw, mock_config)
results.append((symbol, result))
time.sleep(delay)
# Wave 1: BTC와 ETH 동시 매수 (각 80000원, 총 160000원)
wave1_threads = [
threading.Thread(target=buy_and_track, args=("KRW-BTC", 80000, 0.1)),
threading.Thread(target=buy_and_track, args=("KRW-ETH", 80000, 0.1)),
]
for t in wave1_threads:
t.start()
for t in wave1_threads:
t.join()
# 검증: Wave 1에서 2개 중 최소 2개 성공 (200000 / 80000 = 2.5)
wave1_results = results[:2]
wave1_success = [
r for _, r in wave1_results if r.get("status") not in ("skipped_insufficient_budget", "failed")
]
assert len(wave1_success) >= 2
# Wave 2: XRP 매수 (80000원) - 이전 주문 해제 후 가능
time.sleep(0.2) # Wave 1 완료 대기
buy_and_track("KRW-XRP", 80000)
# 검증: XRP 매수도 성공해야 함 (예산 해제 후 재사용)
xrp_result = results[-1][1]
# 예산이 정상 해제되었다면 XRP도 매수 가능
# (실제로는 mock이라 잔고가 안 줄어들지만, 예산 시스템 동작 확인)
assert xrp_result.get("status") != "failed"
@patch("src.order.pyupbit.Upbit")
@patch("src.holdings.get_current_price")
def test_budget_cleanup_on_exception(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
"""예외 발생 시에도 예산이 정상 해제되는지 테스트"""
import requests
# Mock 설정: get_balance는 성공, buy는 실패 (구체적 예외 사용)
mock_upbit = Mock()
mock_upbit.get_balance.return_value = 100000
mock_upbit.buy_limit_order.side_effect = requests.exceptions.RequestException("API Error")
mock_upbit_class.return_value = mock_upbit
mock_price.return_value = 10000
# 매수 시도 (예외 발생 예상)
result = place_buy_order_upbit("KRW-BTC", 50000, mock_config)
# 검증 1: 주문은 실패
assert result.get("status") == "failed"
# 검증 2: 예산은 해제되어야 함
allocations = krw_budget_manager.get_allocations()
assert "KRW-BTC" not in allocations, "예외 발생 후에도 예산이 해제되지 않음"
@patch("src.order.pyupbit.Upbit")
@patch("src.holdings.get_current_price")
def test_stress_10_concurrent_orders(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
"""스트레스 테스트: 10개 동시 주문"""
mock_upbit = MockUpbit(1000000) # 100만원
mock_upbit_class.return_value = mock_upbit
mock_price.return_value = 10000
results = []
def buy_worker(thread_id: int):
"""워커 스레드"""
for i in range(3): # 각 스레드당 3번 매수 시도
symbol = f"KRW-COIN{thread_id}-{i}"
result = place_buy_order_upbit(symbol, 50000, mock_config)
results.append((symbol, result))
time.sleep(0.01)
threads = [threading.Thread(target=buy_worker, args=(i,)) for i in range(10)]
start_time = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.time() - start_time
# 검증 1: 모든 주문 완료
assert len(results) == 30 # 10 threads × 3 orders
# 검증 2: 성공한 주문들의 총액이 초기 잔고를 초과하지 않음
total_spent = sum(
r.get("amount_krw", 0)
for _, r in results
if r.get("status") not in ("skipped_insufficient_budget", "skipped_insufficient_balance", "failed")
)
assert total_spent <= 1000000
# 검증 3: 최종 예산 할당 상태는 비어있어야 함
final_allocations = krw_budget_manager.get_allocations()
assert len(final_allocations) == 0, f"미해제 예산 발견: {final_allocations}"
print(f"\n스트레스 테스트 완료: {len(results)}건 주문, {elapsed:.2f}초 소요")
print(f"총 지출: {total_spent:,.0f}원 / {1000000:,.0f}")
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

View File

@@ -0,0 +1,328 @@
# src/tests/test_config_validation.py
"""
HIGH-002: 설정 검증 로직 테스트
config.py의 validate_config() 함수에 추가된 검증 로직을 테스트합니다:
1. Auto Trade 활성화 시 API 키 필수
2. 손절/익절 주기 논리 검증 (경고)
3. 스레드 수 범위 검증
4. 최소 주문 금액 검증
5. 매수 금액 검증
"""
import os
from unittest.mock import patch
from src.config import validate_config
class TestConfigValidation:
"""설정 검증 로직 테스트"""
def test_valid_config_minimal(self):
"""최소한의 필수 항목만 있는 유효한 설정"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {
"enabled": False,
"buy_enabled": False,
},
"confirm": {
"confirm_stop_loss": False,
},
"max_threads": 3,
}
is_valid, error = validate_config(cfg)
assert is_valid is True
assert error == ""
def test_missing_required_key(self):
"""필수 항목 누락 시 검증 실패"""
cfg = {
"buy_check_interval_minutes": 240,
# "stop_loss_check_interval_minutes": 60, # 누락
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {},
}
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "stop_loss_check_interval_minutes" in error
def test_invalid_interval_value(self):
"""잘못된 간격 값 (0 이하)"""
cfg = {
"buy_check_interval_minutes": 0, # 잘못된 값
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {},
"confirm": {"confirm_stop_loss": False},
}
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "buy_check_interval_minutes" in error
def test_auto_trade_without_api_keys(self):
"""HIGH-002-1: auto_trade 활성화 시 API 키 없으면 실패"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": False,
"auto_trade": {
"enabled": True, # 활성화
"buy_enabled": True,
},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
# API 키 없는 상태로 테스트
with patch.dict(os.environ, {}, clear=True):
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "UPBIT_ACCESS_KEY" in error or "UPBIT_SECRET_KEY" in error
def test_auto_trade_with_api_keys(self):
"""HIGH-002-1: auto_trade 활성화 + API 키 있으면 성공"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": False,
"auto_trade": {
"enabled": True,
"buy_enabled": True,
},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
# API 키 있는 상태로 테스트
with patch.dict(os.environ, {"UPBIT_ACCESS_KEY": "test_key", "UPBIT_SECRET_KEY": "test_secret"}):
is_valid, error = validate_config(cfg)
assert is_valid is True
assert error == ""
def test_stop_loss_interval_greater_than_profit(self, caplog):
"""HIGH-002-2: 손절 주기 > 익절 주기 시 경고 로그"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 300, # 5시간
"profit_taking_check_interval_minutes": 60, # 1시간
"dry_run": True,
"auto_trade": {"enabled": False},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
is_valid, error = validate_config(cfg)
assert is_valid is True # 검증은 통과 (경고만 출력)
# 경고 로그 확인
assert any("손절 주기" in record.message for record in caplog.records)
def test_max_threads_invalid_type(self):
"""HIGH-002-3: max_threads가 정수가 아니면 실패"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {"enabled": False},
"confirm": {"confirm_stop_loss": False},
"max_threads": "invalid", # 잘못된 타입
}
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "max_threads" in error
def test_max_threads_too_high(self, caplog):
"""HIGH-002-3: max_threads > 10 시 경고"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {"enabled": False},
"confirm": {"confirm_stop_loss": False},
"max_threads": 15, # 과도한 스레드
}
is_valid, error = validate_config(cfg)
assert is_valid is True # 검증은 통과 (경고만)
# 경고 로그 확인
assert any("max_threads" in record.message and "과도" in record.message for record in caplog.records)
def test_min_order_value_too_low(self):
"""HIGH-002-4: 최소 주문 금액 < 5000원 시 실패"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {
"enabled": False,
"min_order_value_krw": 3000, # 너무 낮음
},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "min_order_value_krw" in error
assert "5000" in error
def test_buy_amount_less_than_min_order(self, caplog):
"""HIGH-002-5: buy_amount < min_order_value 시 경고"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {
"enabled": False,
"min_order_value_krw": 10000,
"buy_amount_krw": 5000, # min_order보다 작음
},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
is_valid, error = validate_config(cfg)
assert is_valid is True # 검증은 통과 (경고만)
# 경고 로그 확인
assert any(
"buy_amount_krw" in record.message and "min_order_value_krw" in record.message for record in caplog.records
)
def test_buy_amount_too_low(self):
"""HIGH-002-5: buy_amount_krw < 5000원 시 실패"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {
"enabled": False,
"buy_amount_krw": 3000, # 너무 낮음
},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "buy_amount_krw" in error
assert "5000" in error
def test_confirm_invalid_type(self):
"""confirm 설정이 딕셔너리가 아니면 실패"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {"enabled": False},
"confirm": "invalid", # 잘못된 타입
"max_threads": 3,
}
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "confirm" in error
def test_dry_run_invalid_type(self):
"""dry_run이 boolean이 아니면 실패"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": "yes", # 잘못된 타입
"auto_trade": {"enabled": False},
"confirm": {"confirm_stop_loss": False},
}
is_valid, error = validate_config(cfg)
assert is_valid is False
assert "dry_run" in error
class TestEdgeCases:
"""경계값 및 엣지 케이스 테스트"""
def test_intervals_equal_one(self):
"""간격이 정확히 1일 때 (최소값)"""
cfg = {
"buy_check_interval_minutes": 1,
"stop_loss_check_interval_minutes": 1,
"profit_taking_check_interval_minutes": 1,
"dry_run": True,
"auto_trade": {"enabled": False},
"confirm": {"confirm_stop_loss": False},
"max_threads": 1,
}
is_valid, error = validate_config(cfg)
assert is_valid is True
def test_max_threads_equal_ten(self):
"""max_threads가 정확히 10일 때 (경계값)"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {"enabled": False},
"confirm": {"confirm_stop_loss": False},
"max_threads": 10, # 경계값
}
is_valid, error = validate_config(cfg)
assert is_valid is True # 10은 허용 (경고 없음)
def test_min_order_equal_5000(self):
"""최소 주문 금액이 정확히 5000원일 때"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": True,
"auto_trade": {
"enabled": False,
"min_order_value_krw": 5000, # 최소값
},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
is_valid, error = validate_config(cfg)
assert is_valid is True
def test_only_buy_enabled_without_enabled(self):
"""enabled=False, buy_enabled=True일 때도 API 키 체크"""
cfg = {
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"dry_run": False,
"auto_trade": {
"enabled": False,
"buy_enabled": True, # buy만 활성화
},
"confirm": {"confirm_stop_loss": False},
"max_threads": 3,
}
with patch.dict(os.environ, {}, clear=True):
is_valid, error = validate_config(cfg)
assert is_valid is False # API 키 필수
assert "UPBIT" in error

View File

@@ -0,0 +1,51 @@
"""Tests for file-based queues: pending_orders TTL and recent_sells cleanup."""
import json
import time
import src.common as common
import src.order as order
def test_pending_orders_ttl_cleanup(tmp_path, monkeypatch):
pending_file = tmp_path / "pending_orders.json"
monkeypatch.setattr(order, "PENDING_ORDERS_FILE", str(pending_file))
# Seed with stale entry (older than TTL 24h)
stale_ts = time.time() - (25 * 3600)
with open(pending_file, "w", encoding="utf-8") as f:
json.dump([{"token": "old", "order": {}, "timestamp": stale_ts}], f)
# Write new entry
order._write_pending_order("new", {"x": 1}, pending_file=str(pending_file))
with open(pending_file, encoding="utf-8") as f:
data = json.load(f)
tokens = {entry["token"] for entry in data}
assert "old" not in tokens # stale removed
assert "new" in tokens
def test_recent_sells_ttl_cleanup(tmp_path, monkeypatch):
recent_file = tmp_path / "recent_sells.json"
monkeypatch.setattr(common, "RECENT_SELLS_FILE", str(recent_file))
# Seed with stale entry older than 48h (2x default cooldown)
stale_ts = time.time() - (49 * 3600)
fresh_ts = time.time() - (1 * 3600)
with open(recent_file, "w", encoding="utf-8") as f:
json.dump({"KRW-BTC": stale_ts, "KRW-ETH": fresh_ts}, f)
can_buy_eth = common.can_buy("KRW-ETH", cooldown_hours=24)
can_buy_btc = common.can_buy("KRW-BTC", cooldown_hours=24)
# ETH still in cooldown (fresh timestamp)
assert can_buy_eth is False
# BTC stale entry pruned -> allowed to buy
assert can_buy_btc is True
with open(recent_file, encoding="utf-8") as f:
data = json.load(f)
assert "KRW-BTC" not in data
assert "KRW-ETH" in data

View File

@@ -0,0 +1,91 @@
"""Cache and retry tests for holdings price/balance fetch."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from src import holdings
def _reset_caches():
with holdings._cache_lock: # type: ignore[attr-defined]
holdings._price_cache.clear() # type: ignore[attr-defined]
holdings._balance_cache = ({}, 0.0) # type: ignore[attr-defined]
def test_get_current_price_cache_hit():
_reset_caches()
with patch("src.holdings.pyupbit.get_current_price", return_value=123.0) as mock_get:
price1 = holdings.get_current_price("KRW-BTC")
price2 = holdings.get_current_price("KRW-BTC")
assert price1 == price2 == 123.0
assert mock_get.call_count == 1 # cached on second call
def test_get_current_price_retries_until_success():
import requests
_reset_caches()
side_effect = [None, requests.exceptions.Timeout("temporary"), 45678.9]
def _side_effect(*args, **kwargs):
val = side_effect.pop(0)
if isinstance(val, Exception):
raise val
return val
with patch("src.holdings.pyupbit.get_current_price", side_effect=_side_effect) as mock_get:
price = holdings.get_current_price("BTC")
assert price == 45678.9
assert mock_get.call_count == 3
def test_get_upbit_balances_cache_hit():
_reset_caches()
cfg = SimpleNamespace(upbit_access_key="k", upbit_secret_key="s")
mock_balances = [
{"currency": "BTC", "balance": "1.0"},
{"currency": "KRW", "balance": "10000"},
]
with patch("src.holdings.pyupbit.Upbit") as mock_upbit_cls:
mock_upbit = MagicMock()
mock_upbit.get_balances.return_value = mock_balances
mock_upbit_cls.return_value = mock_upbit
first = holdings.get_upbit_balances(cfg)
second = holdings.get_upbit_balances(cfg)
assert first == {"BTC": 1.0}
assert second == {"BTC": 1.0}
assert mock_upbit.get_balances.call_count == 1 # second served from cache
def test_get_upbit_balances_retry_on_error_then_success():
import requests
_reset_caches()
cfg = SimpleNamespace(upbit_access_key="k", upbit_secret_key="s")
call_returns = [
requests.exceptions.ConnectionError("net"),
[
{"currency": "ETH", "balance": "2"},
],
]
def _side_effect():
val = call_returns.pop(0)
if isinstance(val, Exception):
raise val
return val
with patch("src.holdings.pyupbit.Upbit") as mock_upbit_cls:
mock_upbit = MagicMock()
mock_upbit.get_balances.side_effect = _side_effect
mock_upbit_cls.return_value = mock_upbit
result = holdings.get_upbit_balances(cfg)
assert result == {"ETH": 2.0}
assert mock_upbit.get_balances.call_count == 2

View File

@@ -0,0 +1,314 @@
"""
KRWBudgetManager 테스트
멀티스레드 환경에서 KRW 잔고 경쟁 조건을 방지하는 예산 할당 시스템 검증
"""
import threading
import time
import pytest
from src.common import KRWBudgetManager
class MockUpbit:
"""Upbit 모의 객체"""
def __init__(self, initial_balance: float):
self.balance = initial_balance
self.lock = threading.Lock()
def get_balance(self, currency: str) -> float:
"""KRW 잔고 조회 (스레드 안전)"""
with self.lock:
return self.balance
def buy(self, amount_krw: float):
"""매수 시뮬레이션 (잔고 감소)"""
with self.lock:
if self.balance >= amount_krw:
self.balance -= amount_krw
return True
return False
class TestKRWBudgetManager:
"""KRWBudgetManager 단위 테스트"""
def test_allocate_success_full_amount(self):
"""전액 할당 성공 테스트"""
manager = KRWBudgetManager()
upbit = MockUpbit(100000)
success, allocated, token = manager.allocate("KRW-BTC", 50000, upbit)
assert success is True
assert token is not None
assert allocated == 50000
assert manager.get_allocations() == {"KRW-BTC": 50000}
def test_allocate_success_partial_amount(self):
"""부분 할당 성공 테스트 (잔고 부족)"""
manager = KRWBudgetManager()
upbit = MockUpbit(30000)
success, allocated, token = manager.allocate("KRW-BTC", 50000, upbit)
assert success is True
assert token is not None
assert allocated == 30000 # 가능한 만큼만 할당
assert manager.get_allocations() == {"KRW-BTC": 30000}
def test_allocate_failure_insufficient_balance(self):
"""할당 실패 테스트 (잔고 없음)"""
manager = KRWBudgetManager()
upbit = MockUpbit(0)
success, allocated, token = manager.allocate("KRW-BTC", 10000, upbit)
assert success is False
assert token is None
assert allocated == 0
assert manager.get_allocations() == {}
def test_allocate_multiple_symbols(self):
"""여러 심볼 동시 할당 테스트"""
manager = KRWBudgetManager()
upbit = MockUpbit(100000)
# BTC 할당
success1, allocated1, token1 = manager.allocate("KRW-BTC", 40000, upbit)
assert success1 is True
assert allocated1 == 40000
# ETH 할당 (남은 잔고: 60000)
success2, allocated2, token2 = manager.allocate("KRW-ETH", 30000, upbit)
assert success2 is True
assert allocated2 == 30000
# XRP 할당 (남은 잔고: 30000)
success3, allocated3, token3 = manager.allocate("KRW-XRP", 40000, upbit)
assert success3 is True
assert allocated3 == 30000 # 부분 할당
allocations = manager.get_allocations()
assert allocations["KRW-BTC"] == 40000
assert allocations["KRW-ETH"] == 30000
assert allocations["KRW-XRP"] == 30000
def test_allocate_same_symbol_multiple_orders(self):
"""동일 심볼 복수 주문 시에도 개별 토큰으로 관리"""
manager = KRWBudgetManager()
upbit = MockUpbit(100000)
success1, alloc1, token1 = manager.allocate("KRW-BTC", 70000, upbit)
success2, alloc2, token2 = manager.allocate("KRW-BTC", 70000, upbit)
assert success1 is True
assert success2 is True
assert token1 != token2
assert alloc1 == 70000
assert alloc2 == 30000
allocations = manager.get_allocations()
assert allocations["KRW-BTC"] == 100000
manager.release(token1)
manager.release(token2)
assert manager.get_allocations() == {}
def test_release(self):
"""예산 해제 테스트"""
manager = KRWBudgetManager()
upbit = MockUpbit(100000)
# 할당
_, _, token = manager.allocate("KRW-BTC", 50000, upbit)
assert manager.get_allocations() == {"KRW-BTC": 50000}
manager.release(token)
assert manager.get_allocations() == {}
success, allocated, token2 = manager.allocate("KRW-ETH", 50000, upbit)
assert success is True
assert allocated == 50000
def test_release_nonexistent_symbol(self):
"""존재하지 않는 심볼 해제 테스트 (오류 없어야 함)"""
manager = KRWBudgetManager()
# 오류 없이 실행되어야 함
manager.release("nonexistent-token")
assert manager.get_allocations() == {}
def test_clear(self):
"""전체 초기화 테스트"""
manager = KRWBudgetManager()
upbit = MockUpbit(100000)
manager.allocate("KRW-BTC", 30000, upbit)
manager.allocate("KRW-ETH", 20000, upbit)
assert len(manager.get_allocations()) == 2
manager.clear()
assert manager.get_allocations() == {}
class TestKRWBudgetManagerConcurrency:
"""KRWBudgetManager 동시성 테스트 (멀티스레드 환경)"""
def test_concurrent_allocate_no_race_condition(self):
"""동시 할당 시 Race Condition 방지 테스트"""
manager = KRWBudgetManager()
upbit = MockUpbit(100000)
results = []
def allocate_worker(symbol: str, amount: float):
"""워커 스레드: 예산 할당 시도"""
success, allocated, _ = manager.allocate(symbol, amount, upbit)
results.append((symbol, success, allocated))
# 3개 스레드가 동시에 50000원씩 요청 (총 150000원 > 잔고 100000원)
threads = [threading.Thread(target=allocate_worker, args=(f"KRW-COIN{i}", 50000)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
# 검증: 할당된 총액이 실제 잔고(100000)를 초과하지 않아야 함
total_allocated = sum(allocated for _, success, allocated in results if success)
assert total_allocated <= 100000
# 검증: 최소 2개는 성공, 1개는 실패 또는 부분 할당
successful = [r for r in results if r[1] is True]
assert len(successful) >= 2
def test_concurrent_allocate_and_release(self):
"""할당과 해제가 동시에 발생하는 테스트"""
manager = KRWBudgetManager()
upbit = MockUpbit(100000)
order_log = []
def buy_order(symbol: str, amount: float, delay: float = 0):
"""매수 주문 시뮬레이션"""
success, allocated, token = manager.allocate(symbol, amount, upbit)
if success:
order_log.append((symbol, "allocated", allocated))
time.sleep(delay) # 주문 처리 시간 시뮬레이션
manager.release(token)
order_log.append((symbol, "released", allocated))
# Thread 1: BTC 매수 (50000원, 0.1초 처리)
# Thread 2: ETH 매수 (60000원, 0.05초 처리)
# Thread 3: XRP 매수 (40000원, 즉시 처리)
threads = [
threading.Thread(target=buy_order, args=("KRW-BTC", 50000, 0.1)),
threading.Thread(target=buy_order, args=("KRW-ETH", 60000, 0.05)),
threading.Thread(target=buy_order, args=("KRW-XRP", 40000, 0)),
]
for t in threads:
t.start()
for t in threads:
t.join()
# 검증: 모든 할당에 대응하는 해제가 있어야 함
allocations = [log for log in order_log if log[1] == "allocated"]
releases = [log for log in order_log if log[1] == "released"]
assert len(allocations) == len(releases)
# 검증: 최종 할당 상태는 비어있어야 함
assert manager.get_allocations() == {}
def test_stress_test_many_threads(self):
"""스트레스 테스트: 10개 스레드 동시 실행"""
manager = KRWBudgetManager()
upbit = MockUpbit(1000000) # 100만원 초기 잔고
results = []
errors = []
def worker(thread_id: int):
"""워커 스레드"""
try:
for i in range(5): # 각 스레드당 5번 할당 시도
symbol = f"KRW-COIN{thread_id}-{i}"
success, allocated, token = manager.allocate(symbol, 50000, upbit)
if success:
results.append((thread_id, symbol, allocated))
time.sleep(0.01) # 주문 처리 시뮬레이션
manager.release(token)
except Exception as e:
errors.append((thread_id, str(e)))
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# 검증: 오류가 없어야 함
assert len(errors) == 0, f"스레드 실행 중 오류 발생: {errors}"
# 검증: 최종 할당 상태는 비어있어야 함
assert manager.get_allocations() == {}
# 검증: 모든 할당이 잔고 범위 내에서 수행되었어야 함
# (동시에 할당된 총액이 100만원을 초과하지 않음)
print(f"{len(results)}건의 할당 성공")
class TestKRWBudgetManagerIntegration:
"""통합 테스트: 실제 주문 플로우 시뮬레이션"""
def test_realistic_trading_scenario(self):
"""실제 거래 시나리오: 여러 코인 순차/병렬 매수"""
manager = KRWBudgetManager()
upbit = MockUpbit(500000) # 50만원 초기 잔고
# 시나리오 1: BTC 20만원 매수
success1, allocated1, token1 = manager.allocate("KRW-BTC", 200000, upbit)
assert success1 is True
assert allocated1 == 200000
upbit.buy(allocated1) # 실제 잔고 차감 (300000 남음)
manager.release(token1)
# 시나리오 2: ETH와 XRP 동시 매수 시도 (각 20만원)
results = []
def buy_worker(symbol, amount):
success, allocated, token = manager.allocate(symbol, amount, upbit)
if success:
upbit.buy(allocated)
results.append((symbol, allocated))
manager.release(token)
threads = [
threading.Thread(target=buy_worker, args=("KRW-ETH", 200000)),
threading.Thread(target=buy_worker, args=("KRW-XRP", 200000)),
]
for t in threads:
t.start()
for t in threads:
t.join()
# 검증: 잔고(300000)로는 2개 중 1.5개만 살 수 있음
total_bought = sum(allocated for _, allocated in results)
assert total_bought <= 300000
# 검증: 최종 잔고 확인
final_balance = upbit.get_balance("KRW")
assert final_balance == 500000 - 200000 - total_bought
# 검증: 할당 상태는 비어있어야 함
assert manager.get_allocations() == {}
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

View File

@@ -1,15 +1,11 @@
import sys
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
import builtins
import types
import pandas as pd
import pytest
import main
from .test_helpers import check_and_notify, safe_send_telegram
from .test_helpers import check_and_notify
def test_compute_macd_hist_monkeypatch(monkeypatch):
@@ -19,7 +15,8 @@ def test_compute_macd_hist_monkeypatch(monkeypatch):
def fake_macd(series, fast, slow, signal):
return dummy_macd
monkeypatch.setattr(main.ta, "macd", fake_macd)
# 올바른 모듈 경로로 monkey patch
monkeypatch.setattr("src.indicators.ta.macd", fake_macd)
close = pd.Series([1, 2, 3, 4])
@@ -61,7 +58,9 @@ def test_check_and_notify_positive_sends(monkeypatch):
signal_values = [0.5] * len(close) # Constant signal line
macd_df["MACD_12_26_9"] = pd.Series(macd_values, index=close.index)
macd_df["MACDs_12_26_9"] = pd.Series(signal_values, index=close.index)
macd_df["MACDh_12_26_9"] = pd.Series([v - s for v, s in zip(macd_values, signal_values)], index=close.index)
macd_df["MACDh_12_26_9"] = pd.Series(
[v - s for v, s in zip(macd_values, signal_values, strict=True)], index=close.index
)
return macd_df
monkeypatch.setattr(signals.ta, "macd", fake_macd)

View File

@@ -41,7 +41,7 @@ class TestPlaceBuyOrderValidation:
assert result["status"] == "simulated"
assert result["market"] == "KRW-BTC"
assert result["amount_krw"] == 100000
assert result["amount_krw"] == 99950.0
def test_buy_order_below_min_amount(self):
"""Test buy order rejected for amount below minimum."""
@@ -172,7 +172,8 @@ class TestBuyOrderResponseValidation:
mock_upbit.buy_limit_order.return_value = "invalid_response"
with patch("src.order.adjust_price_to_tick_size", return_value=50000000):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
with patch("src.common.krw_budget_manager.allocate", return_value=(True, 100000, "tok")):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
assert result["status"] == "failed"
assert result["error"] == "invalid_response_type"
@@ -195,7 +196,8 @@ class TestBuyOrderResponseValidation:
}
with patch("src.order.adjust_price_to_tick_size", return_value=50000000):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
with patch("src.common.krw_budget_manager.allocate", return_value=(True, 100000, "tok")):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
assert result["status"] == "failed"
assert result["error"] == "order_rejected"

View File

@@ -0,0 +1,41 @@
import json
import time
from src import common
def _setup_recent_sells(tmp_path, monkeypatch):
path = tmp_path / "recent_sells.json"
monkeypatch.setattr(common, "RECENT_SELLS_FILE", str(path))
return path
def test_recent_sells_atomic_write_and_cooldown(tmp_path, monkeypatch):
path = _setup_recent_sells(tmp_path, monkeypatch)
common.record_sell("KRW-ATOM")
assert path.exists()
assert common.can_buy("KRW-ATOM", cooldown_hours=1) is False
with common.recent_sells_lock:
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["KRW-ATOM"] = time.time() - 7200 # 2시간 전으로 설정해 쿨다운 만료
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f)
assert common.can_buy("KRW-ATOM", cooldown_hours=1) is True
assert path.exists()
def test_recent_sells_recovers_from_corruption(tmp_path, monkeypatch):
path = _setup_recent_sells(tmp_path, monkeypatch)
with open(path, "w", encoding="utf-8") as f:
f.write("{invalid json}")
assert common.can_buy("KRW-NEO", cooldown_hours=1) is True
backups = list(tmp_path.glob("recent_sells.json.corrupted.*"))
assert backups, "손상된 recent_sells 백업이 생성되어야 합니다"
assert not path.exists(), "손상 파일은 백업 후 제거되어야 합니다"

View File

@@ -0,0 +1,46 @@
"""Tests for reconciling state and holdings."""
from src import holdings, state_manager
def _reset_state_and_holdings(tmp_path):
# point files to temp paths
holdings_file = tmp_path / "holdings.json"
state_file = tmp_path / "bot_state.json"
holdings.HOLDINGS_FILE = str(holdings_file) # type: ignore[attr-defined]
state_manager.STATE_FILE = str(state_file) # type: ignore[attr-defined]
# clear caches
with holdings._cache_lock: # type: ignore[attr-defined]
holdings._price_cache.clear() # type: ignore[attr-defined]
holdings._balance_cache = ({}, 0.0) # type: ignore[attr-defined]
return str(holdings_file)
def test_state_fills_from_holdings(tmp_path, monkeypatch):
holdings_file = _reset_state_and_holdings(tmp_path)
# prepare holdings with max_price/partial
data = {"KRW-BTC": {"buy_price": 100, "amount": 1.0, "max_price": 200, "partial_sell_done": True}}
holdings.save_holdings(data, holdings_file)
merged = holdings.reconcile_state_and_holdings(holdings_file)
state = state_manager.load_state()
assert merged["KRW-BTC"]["max_price"] == 200
assert state["KRW-BTC"]["max_price"] == 200
assert state["KRW-BTC"]["partial_sell_done"] is True
def test_holdings_updated_from_state(tmp_path, monkeypatch):
holdings_file = _reset_state_and_holdings(tmp_path)
# initial holdings missing partial flag
data = {"KRW-ETH": {"buy_price": 50, "amount": 2.0, "max_price": 70}}
holdings.save_holdings(data, holdings_file)
# state has newer max_price and partial flag
state = {"KRW-ETH": {"max_price": 90, "partial_sell_done": True}}
state_manager.save_state(state)
merged = holdings.reconcile_state_and_holdings(holdings_file)
assert merged["KRW-ETH"]["max_price"] == 90
assert merged["KRW-ETH"]["partial_sell_done"] is True

View File

@@ -1,13 +1,88 @@
import os
import signal
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
from typing import Any
from .common import logger
from .config import RuntimeConfig
from .constants import THREADPOOL_MAX_WORKERS_CAP
from .notifications import send_telegram_with_retry
from .signals import process_symbol
# ============================================================================
# MEDIUM-004: Graceful Shutdown 지원
# ============================================================================
_shutdown_requested = False
_shutdown_lock = threading.Lock()
def _signal_handler(signum, frame):
"""
SIGTERM/SIGINT 신호 수신 시 graceful shutdown 시작
Args:
signum: 신호 번호 (SIGTERM=15, SIGINT=2)
frame: 현재 스택 프레임
"""
global _shutdown_requested
with _shutdown_lock:
if not _shutdown_requested:
_shutdown_requested = True
logger.warning(
"[Graceful Shutdown] 종료 신호 수신 (signal=%d). 진행 중인 작업 완료 후 종료합니다...", signum
)
def request_shutdown():
"""프로그래밍 방식으로 shutdown 요청 (테스트용)"""
global _shutdown_requested
with _shutdown_lock:
_shutdown_requested = True
logger.info("[Graceful Shutdown] 프로그래밍 방식 종료 요청")
def is_shutdown_requested() -> bool:
"""Shutdown 요청 상태 확인"""
with _shutdown_lock:
return _shutdown_requested
# Signal handler 등록 (프로그램 시작 시 자동 등록)
try:
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
logger.debug("[Graceful Shutdown] Signal handler 등록 완료 (SIGTERM, SIGINT)")
except (ValueError, OSError) as e:
# Windows에서 SIGTERM이 없거나, 메인 스레드가 아닌 경우 무시
logger.debug("[Graceful Shutdown] Signal handler 등록 실패 (무시): %s", e)
def _get_optimal_thread_count(max_threads: int | None) -> int:
"""CPU 코어 수 기반으로 최적 스레드 수 계산.
I/O bound 작업이므로 CPU 코어 수 * 2를 기본값으로 사용합니다.
사용자가 명시적으로 값을 설정한 경우 해당 값을 사용합니다.
Args:
max_threads: 사용자 지정 스레드 수 (None이면 자동 계산)
Returns:
최적 스레드 수 (최대 8개로 제한)
"""
cap = int(os.getenv("THREADPOOL_MAX_WORKERS_CAP", THREADPOOL_MAX_WORKERS_CAP))
if max_threads is not None and max_threads > 0:
return min(max_threads, cap)
# I/O bound 작업이므로 CPU 코어 수 * 2
cpu_count = os.cpu_count() or 4
optimal = cpu_count * 2
# 최대 8개로 제한 (너무 많은 스레드는 오히려 성능 저하)
return min(optimal, cap)
def _process_result_and_notify(
symbol: str, res: dict[str, Any], cfg: RuntimeConfig, alerts: list[dict[str, str]]
@@ -110,22 +185,44 @@ def run_sequential(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bo
def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
"""
병렬 처리로 여러 심볼 분석 (MEDIUM-004: Graceful Shutdown 지원)
Args:
symbols: 처리할 심볼 리스트
cfg: RuntimeConfig 객체
aggregate_enabled: 집계 알림 활성화 여부
Returns:
매수 신호 발생 횟수
"""
global _shutdown_requested
max_workers = _get_optimal_thread_count(cfg.max_threads)
cpu_cores = os.cpu_count() or 4
logger.info(
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d, 심볼 간 지연=%.2f초)",
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d [CPU 코어: %d], 심볼 간 지연=%.2f초)",
len(symbols),
cfg.max_threads or 0,
max_workers,
cpu_cores,
cfg.symbol_delay or 0.0,
)
alerts = []
buy_signal_count = 0
max_workers = cfg.max_threads or 4
# Throttle control
last_request_time = [0.0]
throttle_lock = threading.Lock()
def worker(symbol: str):
"""워커 함수 (조기 종료 지원)"""
# 종료 요청 확인
if is_shutdown_requested():
logger.info("[%s] 종료 요청으로 스킵", symbol)
return symbol, None
try:
with throttle_lock:
elapsed = time.time() - last_request_time[0]
@@ -141,20 +238,64 @@ def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled:
return symbol, None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_symbol = {executor.submit(worker, sym): sym for sym in symbols}
future_to_symbol = {}
# Collect results as they complete
# 심볼 제출 (조기 종료 지원)
for sym in symbols:
if is_shutdown_requested():
logger.warning(
"[Graceful Shutdown] 종료 요청으로 나머지 심볼 제출 중단 (%d/%d 제출 완료)",
len(future_to_symbol),
len(symbols),
)
break
future = executor.submit(worker, sym)
future_to_symbol[future] = sym
# 결과 수집 (타임아웃 적용)
results = {}
for future in as_completed(future_to_symbol):
sym = future_to_symbol[future]
try:
symbol, res = future.result()
results[symbol] = res
except Exception as e:
logger.exception("[%s] Future 결과 조회 오류: %s", sym, e)
timeout_seconds = 90 # 전체 작업 타임아웃 90초
individual_timeout = 15 # 개별 결과 조회 타임아웃 15초
# Process results in original order to maintain consistent log/alert order if desired,
# or just process as is. Here we process in original symbol order.
try:
for future in as_completed(future_to_symbol, timeout=timeout_seconds):
# 종료 요청 시 즉시 중단
if is_shutdown_requested():
logger.warning(
"[Graceful Shutdown] 종료 요청으로 결과 수집 중단 (%d/%d 수집 완료)",
len(results),
len(future_to_symbol),
)
break
sym = future_to_symbol[future]
try:
symbol, res = future.result(timeout=individual_timeout)
results[symbol] = res
except TimeoutError:
logger.warning("[%s] 결과 조회 타임아웃 (%d초 초과), 건너뜀", sym, individual_timeout)
except Exception as e:
logger.exception("[%s] Future 결과 조회 오류: %s", sym, e)
except TimeoutError:
logger.error(
"[경고] 전체 작업 타임아웃 (%d초 초과). 진행 중인 작업 강제 종료 중... (%d/%d 완료)",
timeout_seconds,
len(results),
len(future_to_symbol),
)
# Graceful shutdown 완료 체크
if is_shutdown_requested():
logger.warning(
"[Graceful Shutdown] 병렬 처리 조기 종료 완료 (처리 심볼: %d/%d, 매수 신호: %d)",
len(results),
len(symbols),
buy_signal_count,
)
return buy_signal_count
# 결과 처리 (원래 순서대로)
for sym in symbols:
res = results.get(sym)
if res:
@@ -166,5 +307,5 @@ def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled:
_notify_no_signals(alerts, cfg)
logger.info("병렬 처리 완료")
logger.info("병렬 처리 완료 (처리 심볼: %d, 매수 신호: %d)", len(results), buy_signal_count)
return buy_signal_count