- tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리: - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal) - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회) - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건 - type hints, Google docstring, 구체적 예외 타입 적용 - 50줄 초과 함수 분리 (process_signal, restore_positions) - 미사용 파일 58개 archive/ 폴더로 이동 - README.md 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
179 lines
5.1 KiB
Python
179 lines
5.1 KiB
Python
"""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
|