업데이트

This commit is contained in:
2025-12-09 21:39:23 +09:00
parent dd9acf62a3
commit 37a150bd0d
35 changed files with 5587 additions and 493 deletions

View File

@@ -1,7 +1,17 @@
import os, json, pyupbit
from .common import logger, MIN_TRADE_AMOUNT, FLOAT_EPSILON, HOLDINGS_FILE
from .retry_utils import retry_with_backoff
from __future__ import annotations
import json
import os
import threading
from typing import TYPE_CHECKING
import pyupbit
from .common import FLOAT_EPSILON, HOLDINGS_FILE, MIN_TRADE_AMOUNT, logger
from .retry_utils import retry_with_backoff
if TYPE_CHECKING:
from .config import RuntimeConfig
# 부동소수점 비교용 임계값 (MIN_TRADE_AMOUNT와 동일한 용도)
EPSILON = FLOAT_EPSILON
@@ -16,7 +26,7 @@ def _load_holdings_unsafe(holdings_file: str) -> dict[str, dict]:
if os.path.getsize(holdings_file) == 0:
logger.debug("[DEBUG] 보유 파일이 비어있습니다: %s", holdings_file)
return {}
with open(holdings_file, "r", encoding="utf-8") as f:
with open(holdings_file, encoding="utf-8") as f:
return json.load(f)
return {}
@@ -74,7 +84,22 @@ def save_holdings(holdings: dict[str, dict], holdings_file: str = HOLDINGS_FILE)
raise # 호출자가 저장 실패를 인지하도록 예외 재발생
def get_upbit_balances(cfg: "RuntimeConfig") -> dict | None:
def get_upbit_balances(cfg: RuntimeConfig) -> dict | None:
"""
Upbit API를 통해 현재 잔고를 조회합니다.
Args:
cfg: RuntimeConfig 객체 (Upbit API 키 포함)
Returns:
심볼별 잔고 딕셔너리 (예: {"BTC": 0.5, "ETH": 10.0, "KRW": 1000000})
- MIN_TRADE_AMOUNT (1e-8) 이하의 자산은 제외됨
- API 키 미설정 시 빈 딕셔너리 {} 반환
- 네트워크 오류 또는 API 오류 시 None 반환
Raises:
Exception: Upbit API 호출 중 발생한 예외는 로깅되고 None 반환
"""
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("API 키 없음 - 빈 balances")
@@ -105,11 +130,27 @@ def get_upbit_balances(cfg: "RuntimeConfig") -> dict | None:
def get_current_price(symbol: str) -> float:
"""
주어진 심볼의 현재가를 Upbit에서 조회합니다.
Args:
symbol: 거래 심볼 (예: "BTC", "KRW-BTC", "eth")
"KRW-" 접두사 유무 자동 처리
Returns:
현재가 (KRW 기준, float 타입)
- 조회 실패 또는 API 오류 시 0.0 반환
Note:
- 조회 실패 시 WARNING 레벨로 로깅됨
- 심볼 대소문자 자동 정규화 (예: "btc""KRW-BTC")
- 재시도 로직 없음 (상위 함수에서 재시도 처리 권장)
"""
try:
if symbol.upper().startswith("KRW-"):
market = symbol.upper()
else:
market = f"KRW-{symbol.replace('KRW-','').upper()}"
market = f"KRW-{symbol.replace('KRW-', '').upper()}"
# 실시간 현재가(ticker)를 조회하도록 변경
price = pyupbit.get_current_price(market)
logger.debug("[DEBUG] 현재가 %s -> %.2f", market, price)
@@ -269,7 +310,28 @@ def set_holding_field(symbol: str, key: str, value, holdings_file: str = HOLDING
@retry_with_backoff(max_attempts=3, base_delay=2.0, max_delay=10.0, exceptions=(Exception,))
def fetch_holdings_from_upbit(cfg: "RuntimeConfig") -> dict | None:
def fetch_holdings_from_upbit(cfg: RuntimeConfig) -> dict | None:
"""
Upbit API에서 현재 보유 자산 정보를 조회하고, 로컬 상태 정보와 병합합니다.
Args:
cfg: RuntimeConfig 객체 (Upbit API 키 포함)
Returns:
심볼별 보유 정보 딕셔너리 (예: {"KRW-BTC": {...}, "KRW-ETH": {...}})
- 각 심볼: {"buy_price": float, "amount": float, "max_price": float, "buy_timestamp": null}
- API 키 미설정 시 빈 딕셔너리 {} 반환
- 네트워크 오류 또는 API 오류 시 None 반환 (상위 함수에서 재시도)
Behavior:
- Upbit API에서 잔고 정보 조회 (amount, buy_price 등)
- 기존 로컬 holdings.json의 max_price는 유지 (매도 조건 판정 용)
- 잔고 0 또는 MIN_TRADE_AMOUNT 미만 자산은 제외
- buy_price 필드 우선순위: avg_buy_price_krw > avg_buy_price
Decorator:
@retry_with_backoff: 3회 지수 백오프 재시도 (2s → 4s → 8s)
"""
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("[DEBUG] API 키 없어 Upbit holdings 사용 안함")
@@ -331,3 +393,75 @@ def fetch_holdings_from_upbit(cfg: "RuntimeConfig") -> dict | None:
except Exception as e:
logger.error("[ERROR] fetch_holdings 실패: %s", e)
return None
def backup_holdings(holdings_file: str = HOLDINGS_FILE) -> str | None:
"""
holdings.json 파일의 백업을 생성합니다 (선택사항 - 복구 전략).
Args:
holdings_file: 백업할 보유 파일 경로
Returns:
생성된 백업 파일 경로 (예: data/holdings.json.backup_20251204_120530)
백업 실패 시 None 반환
Note:
- 백업 파일명: holdings.json.backup_YYYYMMDD_HHMMSS
- 원본 파일이 없으면 None 반환
- 파일 손상 복구 시 수동으로 백업 파일을 원본 위치에 복사
"""
try:
if not os.path.exists(holdings_file):
logger.warning("[WARNING] 백업 대상 파일이 없습니다: %s", holdings_file)
return None
import shutil
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = f"{holdings_file}.backup_{timestamp}"
shutil.copy2(holdings_file, backup_file)
logger.info("[INFO] Holdings 백업 생성: %s", backup_file)
return backup_file
except Exception as e:
logger.error("[ERROR] Holdings 백업 생성 실패: %s", e)
return None
def restore_holdings_from_backup(backup_file: str, restore_to: str = HOLDINGS_FILE) -> bool:
"""
백업 파일에서 holdings.json을 복구합니다 (선택사항 - 복구 전략).
Args:
backup_file: 백업 파일 경로 (예: data/holdings.json.backup_20251204_120530)
restore_to: 복구 대상 경로 (기본값: HOLDINGS_FILE)
Returns:
복구 성공 여부 (True/False)
Note:
- 복구 전에 원본 파일이 백업됨
- 복구 중 오류 발생 시 원본 파일은 손상되지 않음
- 원래 상태로 되돌리려면 복구 전 백업 파일을 확인하세요
"""
try:
if not os.path.exists(backup_file):
logger.error("[ERROR] 백업 파일이 없습니다: %s", backup_file)
return False
# 현재 파일을 먼저 백업 (이중 백업)
if os.path.exists(restore_to):
double_backup = backup_holdings(restore_to)
logger.info("[INFO] 복구 전 현재 파일 백업: %s", double_backup)
import shutil
# 복구
shutil.copy2(backup_file, restore_to)
logger.info("[INFO] Holdings 복구 완료: %s -> %s", backup_file, restore_to)
return True
except Exception as e:
logger.error("[ERROR] Holdings 복구 실패: %s", e)
return False