최초 프로젝트 업로드 (Script Auto Commit)

This commit is contained in:
2025-12-03 22:40:47 +09:00
commit dd9acf62a3
39 changed files with 5251 additions and 0 deletions

333
src/holdings.py Normal file
View File

@@ -0,0 +1,333 @@
import os, json, pyupbit
from .common import logger, MIN_TRADE_AMOUNT, FLOAT_EPSILON, HOLDINGS_FILE
from .retry_utils import retry_with_backoff
import threading
# 부동소수점 비교용 임계값 (MIN_TRADE_AMOUNT와 동일한 용도)
EPSILON = FLOAT_EPSILON
# 파일 잠금을 위한 RLock 객체 (재진입 가능)
holdings_lock = threading.RLock()
def _load_holdings_unsafe(holdings_file: str) -> dict[str, dict]:
"""내부 사용 전용: Lock 없이 holdings 파일 로드"""
if os.path.exists(holdings_file):
if os.path.getsize(holdings_file) == 0:
logger.debug("[DEBUG] 보유 파일이 비어있습니다: %s", holdings_file)
return {}
with open(holdings_file, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def load_holdings(holdings_file: str = HOLDINGS_FILE) -> dict[str, dict]:
"""
holdings 파일을 로드합니다.
Returns:
심볼별 보유 정보 (symbol -> {amount, buy_price, ...})
"""
try:
with holdings_lock:
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)
return {}
def _save_holdings_unsafe(holdings: dict[str, dict], holdings_file: str) -> None:
"""내부 사용 전용: Lock 없이 holdings 파일 저장 (원자적 쓰기)"""
os.makedirs(os.path.dirname(holdings_file) or ".", exist_ok=True)
temp_file = f"{holdings_file}.tmp"
try:
# 임시 파일에 먼저 쓰기
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(holdings, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno()) # 디스크 동기화 보장
# 원자적 교체 (rename은 원자적 연산)
os.replace(temp_file, holdings_file)
logger.debug("[DEBUG] 보유 저장 (원자적): %s", holdings_file)
except Exception as e:
logger.error("[ERROR] 보유 저장 중 오류: %s", e)
# 임시 파일 정리
if os.path.exists(temp_file):
try:
os.remove(temp_file)
except Exception:
pass
raise
def save_holdings(holdings: dict[str, dict], holdings_file: str = HOLDINGS_FILE) -> None:
"""스레드 안전 + 원자적 파일 쓰기로 holdings 저장"""
try:
with holdings_lock:
_save_holdings_unsafe(holdings, holdings_file)
except Exception as e:
logger.error("[ERROR] 보유 저장 실패: %s", e)
raise # 호출자가 저장 실패를 인지하도록 예외 재발생
def get_upbit_balances(cfg: "RuntimeConfig") -> dict | None:
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("API 키 없음 - 빈 balances")
return {}
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()
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:
logger.error("Upbit balances 실패: %s", e)
return None
def get_current_price(symbol: str) -> float:
try:
if symbol.upper().startswith("KRW-"):
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:
logger.warning("[WARNING] 현재가 조회 실패 %s: %s", symbol, e)
return 0.0
def add_new_holding(
symbol: str, buy_price: float, amount: float, buy_timestamp: float | None = None, holdings_file: str = HOLDINGS_FILE
) -> bool:
"""
새로운 보유 자산을 추가하거나 기존 보유량을 추가합니다.
Args:
symbol: 거래 심볼 (예: KRW-BTC)
buy_price: 평균 매수가
amount: 매수한 수량
buy_timestamp: 매수 시각 (None이면 현재 시각 사용)
holdings_file: 보유 파일 경로
Returns:
성공 여부 (True/False)
"""
try:
import time
timestamp = buy_timestamp if buy_timestamp is not None else time.time()
with holdings_lock:
holdings = _load_holdings_unsafe(holdings_file)
if symbol in holdings:
# 기존 보유가 있으면 평균 매수가와 수량 업데이트
prev_amount = float(holdings[symbol].get("amount", 0.0) or 0.0)
prev_price = float(holdings[symbol].get("buy_price", 0.0) or 0.0)
total_amount = prev_amount + amount
if total_amount > 0:
# 가중 평균 매수가 계산
new_avg_price = ((prev_price * prev_amount) + (buy_price * amount)) / total_amount
holdings[symbol]["buy_price"] = new_avg_price
holdings[symbol]["amount"] = total_amount
# max_price 갱신: 현재 매수가와 기존 max_price 중 큰 값
prev_max = float(holdings[symbol].get("max_price", 0.0) or 0.0)
holdings[symbol]["max_price"] = max(new_avg_price, prev_max)
logger.info(
"[INFO] [%s] holdings 추가 매수: 평균가 %.2f -> %.2f, 수량 %.8f -> %.8f",
symbol,
prev_price,
new_avg_price,
prev_amount,
total_amount,
)
else:
# 신규 보유 추가
holdings[symbol] = {
"buy_price": buy_price,
"amount": amount,
"max_price": buy_price,
"buy_timestamp": timestamp,
"partial_sell_done": False,
}
logger.info("[INFO] [%s] holdings 신규 추가: 매수가=%.2f, 수량=%.8f", symbol, buy_price, amount)
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
logger.exception("[ERROR] [%s] holdings 추가 실패: %s", symbol, e)
return False
def update_holding_amount(
symbol: str, amount_change: float, holdings_file: str = HOLDINGS_FILE, min_amount_threshold: float = 1e-8
) -> bool:
"""
보유 자산의 수량을 변경합니다 (매도 시 음수 값 전달).
수량이 0 이하가 되면 해당 심볼을 holdings에서 제거합니다.
Args:
symbol: 거래 심볼 (예: KRW-BTC)
amount_change: 변경할 수량 (매도 시 음수, 매수 시 양수)
holdings_file: 보유 파일 경로
min_amount_threshold: 0으로 간주할 최소 수량 임계값
Returns:
성공 여부 (True/False)
"""
try:
with holdings_lock:
holdings = _load_holdings_unsafe(holdings_file)
if symbol not in holdings:
logger.warning("[WARNING] [%s] holdings에 존재하지 않아 수량 업데이트 건너뜀", symbol)
return False
prev_amount = float(holdings[symbol].get("amount", 0.0) or 0.0)
new_amount = max(0.0, prev_amount + amount_change)
if new_amount <= min_amount_threshold: # 거의 0이면 제거
holdings.pop(symbol, None)
logger.info(
"[INFO] [%s] holdings 업데이트: 전량 매도 완료, 보유 제거 (이전: %.8f, 변경: %.8f)",
symbol,
prev_amount,
amount_change,
)
else:
holdings[symbol]["amount"] = new_amount
logger.info(
"[INFO] [%s] holdings 업데이트: 수량 변경 %.8f -> %.8f (변경량: %.8f)",
symbol,
prev_amount,
new_amount,
amount_change,
)
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
logger.exception("[ERROR] [%s] holdings 수량 업데이트 실패: %s", symbol, e)
return False
def set_holding_field(symbol: str, key: str, value, holdings_file: str = HOLDINGS_FILE) -> bool:
"""
보유 자산의 특정 필드 값을 설정합니다.
Args:
symbol: 거래 심볼 (예: KRW-BTC)
key: 설정할 필드의 키 (예: "partial_sell_done")
value: 설정할 값
holdings_file: 보유 파일 경로
Returns:
성공 여부 (True/False)
"""
try:
with holdings_lock:
holdings = _load_holdings_unsafe(holdings_file)
if symbol not in holdings:
logger.warning("[WARNING] [%s] holdings에 존재하지 않아 필드 설정 건너뜀", symbol)
return False
holdings[symbol][key] = value
logger.info("[INFO] [%s] holdings 업데이트: 필드 '%s''%s'(으)로 설정", symbol, key, value)
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
logger.exception("[ERROR] [%s] holdings 필드 설정 실패: %s", symbol, e)
return False
@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:
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("[DEBUG] API 키 없어 Upbit holdings 사용 안함")
return {}
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
balances = upbit.get_balances()
# 타입 체크: balances가 리스트가 아닐 경우
if not isinstance(balances, list):
logger.error(
"[ERROR] Upbit balances 형식 오류: 예상(list), 실제(%s), 값=%s", type(balances).__name__, balances
)
return None
holdings = {}
# 기존 holdings 파일에서 max_price 불러오기
existing_holdings = load_holdings(HOLDINGS_FILE)
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
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
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
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] = {
"buy_price": buy_price or 0.0,
"amount": amount,
"max_price": max_price,
"buy_timestamp": None,
}
logger.debug("[DEBUG] Upbit holdings %d", len(holdings))
return holdings
except Exception as e:
logger.error("[ERROR] fetch_holdings 실패: %s", e)
return None