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