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

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

View File

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