"""Upbit 주문 실행 모듈. 주문 제출, 취소, 체결 조회, 시장가 매도 등 Upbit REST API와 직접 통신하는 로직을 담당한다. """ from __future__ import annotations import logging import math import time from typing import Optional, Tuple import pyupbit log = logging.getLogger(__name__) def round_price(price: float) -> float: """Upbit 호가 단위로 내림 처리. Args: price: 원본 가격. Returns: 호가 단위에 맞춰 내림된 가격. """ if price >= 2_000_000: unit = 1000 elif price >= 1_000_000: unit = 500 elif price >= 100_000: unit = 100 elif price >= 10_000: unit = 10 elif price >= 1_000: unit = 5 elif price >= 100: unit = 1 elif price >= 10: unit = 0.1 else: unit = 0.01 return math.floor(price / unit) * unit def submit_limit_buy( client: pyupbit.Upbit, ticker: str, price: float, qty: float, sim_mode: bool = False, ) -> Optional[str]: """지정가 매수 주문 제출. Returns: 주문 UUID. 실패 시 None. """ price = round_price(price) if sim_mode: return f"sim-buy-{ticker}" try: order = client.buy_limit_order(ticker, price, qty) if not order or 'error' in str(order): log.error(f"지정가 매수 실패 {ticker}: {order}") return None return order.get('uuid') except (ConnectionError, TimeoutError, ValueError) as e: log.error(f"지정가 매수 오류 {ticker}: {e}") return None def submit_limit_sell( client: pyupbit.Upbit, ticker: str, qty: float, price: float, sim_mode: bool = False, ) -> Optional[str]: """지정가 매도 주문 제출. Returns: 주문 UUID. 실패 시 None. """ price = round_price(price) if sim_mode: return f"sim-{ticker}" try: order = client.sell_limit_order(ticker, price, qty) if not order or 'error' in str(order): log.error(f"지정가 매도 실패 {ticker}: price={price} qty={qty} -> {order}") return None return order.get('uuid') except (ConnectionError, TimeoutError, ValueError) as e: log.error(f"지정가 매도 오류 {ticker}: {e}") return None def cancel_order( client: pyupbit.Upbit, uuid: Optional[str], sim_mode: bool = False, ) -> None: """주문 취소. sim_mode이거나 uuid가 없으면 무시.""" if sim_mode or not uuid or uuid.startswith('sim-'): return try: client.cancel_order(uuid) except (ConnectionError, TimeoutError, ValueError) as e: log.warning(f"주문 취소 실패 {uuid}: {e}") def check_order_state( client: pyupbit.Upbit, uuid: str, ) -> Tuple[Optional[str], Optional[float]]: """주문 상태 조회. Returns: (state, avg_price) 튜플. state: 'done'|'wait'|'cancel'|None. """ try: detail = client.get_order(uuid) if not detail: return None, None state = detail.get('state') avg_price = float(detail.get('avg_price') or 0) or None return state, avg_price except (ConnectionError, TimeoutError, ValueError) as e: log.warning(f"주문 조회 실패 {uuid}: {e}") return None, None def _avg_price_from_order( client: pyupbit.Upbit, uuid: str, ) -> Optional[float]: """체결 내역에서 가중평균 체결가를 계산.""" try: detail = client.get_order(uuid) if not detail: return None trades = detail.get('trades', []) if trades: total_funds = sum(float(t['funds']) for t in trades) total_vol = sum(float(t['volume']) for t in trades) return total_funds / total_vol if total_vol > 0 else None avg = detail.get('avg_price') return float(avg) if avg else None except (ConnectionError, TimeoutError, ValueError) as e: log.warning(f"체결가 조회 실패 {uuid}: {e}") return None def sell_market( client: pyupbit.Upbit, ticker: str, qty: float, sim_mode: bool = False, ) -> Optional[float]: """시장가 매도. 체결가를 반환. Args: client: Upbit 클라이언트. ticker: 종목 코드. qty: 매도 수량. sim_mode: 시뮬레이션 모드. Returns: 체결 평균가. 실패 시 None. """ if sim_mode: price = pyupbit.get_current_price(ticker) log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}") return price try: order = client.sell_market_order(ticker, qty) if not order or 'error' in str(order): log.error(f"시장가 매도 실패: {order}") return None uuid = order.get('uuid') time.sleep(1.5) avg_price = _avg_price_from_order(client, uuid) if uuid else None return avg_price or pyupbit.get_current_price(ticker) except (ConnectionError, TimeoutError, ValueError) as e: log.error(f"시장가 매도 오류 {ticker}: {e}") return None