업데이트
This commit is contained in:
148
src/holdings.py
148
src/holdings.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user