업데이트
This commit is contained in:
591
src/order.py
591
src/order.py
@@ -1,12 +1,64 @@
|
||||
import os
|
||||
import time
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyupbit
|
||||
from .common import logger, MIN_KRW_ORDER, HOLDINGS_FILE, TRADES_FILE, PENDING_ORDERS_FILE
|
||||
import requests
|
||||
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .common import HOLDINGS_FILE, MIN_KRW_ORDER, PENDING_ORDERS_FILE, logger
|
||||
from .notifications import send_telegram
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import RuntimeConfig
|
||||
|
||||
|
||||
def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Upbit API 키의 유효성을 검증합니다.
|
||||
|
||||
Args:
|
||||
access_key: Upbit 액세스 키
|
||||
secret_key: Upbit 시크릿 키
|
||||
|
||||
Returns:
|
||||
(유효성 여부, 메시지)
|
||||
True, "OK": 유효한 키
|
||||
False, "에러 메시지": 유효하지 않은 키
|
||||
"""
|
||||
if not access_key or not secret_key:
|
||||
return False, "API 키가 설정되지 않았습니다"
|
||||
|
||||
try:
|
||||
upbit = pyupbit.Upbit(access_key, secret_key)
|
||||
# 간단한 테스트: 잔고 조회
|
||||
balances = upbit.get_balances()
|
||||
|
||||
if balances is None:
|
||||
return False, "잔고 조회 실패: None 응답"
|
||||
|
||||
if isinstance(balances, dict) and "error" in balances:
|
||||
error_msg = balances.get("error", {}).get("message", "Unknown error")
|
||||
return False, f"Upbit 오류: {error_msg}"
|
||||
|
||||
# 성공: 유효한 키
|
||||
logger.info(
|
||||
"[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d개", len(balances) if isinstance(balances, list) else 0
|
||||
)
|
||||
return True, "OK"
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return False, "API 연결 타임아웃 (네트워크 확인 필요)"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, "API 연결 실패 (인터넷 연결 확인 필요)"
|
||||
except Exception as e:
|
||||
return False, f"API 키 검증 실패: {str(e)}"
|
||||
|
||||
|
||||
def adjust_price_to_tick_size(price: float) -> float:
|
||||
"""
|
||||
@@ -34,7 +86,7 @@ def _write_pending_order(token: str, order: dict, pending_file: str = PENDING_OR
|
||||
try:
|
||||
pending = []
|
||||
if os.path.exists(pending_file):
|
||||
with open(pending_file, "r", encoding="utf-8") as f:
|
||||
with open(pending_file, encoding="utf-8") as f:
|
||||
try:
|
||||
pending = json.load(f)
|
||||
except Exception:
|
||||
@@ -110,7 +162,7 @@ def _calculate_and_add_profit_rate(trade_record: dict, symbol: str, monitor: dic
|
||||
매도 거래 기록에 수익률 정보를 계산하여 추가합니다.
|
||||
"""
|
||||
try:
|
||||
from .holdings import load_holdings, get_current_price
|
||||
from .holdings import get_current_price, load_holdings
|
||||
|
||||
holdings = load_holdings(HOLDINGS_FILE)
|
||||
if symbol not in holdings:
|
||||
@@ -150,17 +202,117 @@ def _calculate_and_add_profit_rate(trade_record: dict, symbol: str, monitor: dic
|
||||
trade_record["profit_rate"] = None
|
||||
|
||||
|
||||
def place_buy_order_upbit(market: str, amount_krw: float, cfg: "RuntimeConfig") -> dict:
|
||||
def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60):
|
||||
"""
|
||||
Upbit API를 이용한 매수 주문 (시장가 또는 지정가)
|
||||
Find a recently placed order matching criteria to handle ReadTimeout.
|
||||
|
||||
Args:
|
||||
market: 거래 시장 (예: KRW-BTC)
|
||||
amount_krw: 매수할 KRW 금액
|
||||
cfg: RuntimeConfig 객체
|
||||
upbit: Upbit 인스턴스
|
||||
market: 마켓 (예: KRW-BTC)
|
||||
side: 'bid' (매수) 또는 'ask' (매도)
|
||||
volume: 매수/매도 수량
|
||||
price: 지정가 (시장가인 경우 None)
|
||||
|
||||
Returns:
|
||||
주문 결과 딕셔너리
|
||||
매칭하는 주문 딕셔너리, 또는 없으면 None
|
||||
"""
|
||||
try:
|
||||
# 1. Check open orders (wait) - 우선순위: 진행 중인 주문
|
||||
orders = upbit.get_orders(ticker=market, state="wait")
|
||||
if orders:
|
||||
for order in orders:
|
||||
if order.get("side") != side:
|
||||
continue
|
||||
# Volume check (approximate due to float precision)
|
||||
if abs(float(order.get("volume")) - volume) > 1e-8:
|
||||
continue
|
||||
# Price check for limit orders
|
||||
if price is not None and abs(float(order.get("price")) - price) > 1e-4:
|
||||
continue
|
||||
logger.info("📋 진행 중인 주문 발견: %s (side=%s, volume=%.8f)", order.get("uuid"), side, volume)
|
||||
return order
|
||||
|
||||
# 2. Check done orders (filled) - 최근 주문부터 확인
|
||||
dones = upbit.get_orders(ticker=market, state="done", limit=5)
|
||||
if dones:
|
||||
for order in dones:
|
||||
if order.get("side") != side:
|
||||
continue
|
||||
if abs(float(order.get("volume")) - volume) > 1e-8:
|
||||
continue
|
||||
if price is not None and abs(float(order.get("price")) - price) > 1e-4:
|
||||
continue
|
||||
# Done order: 완료된 주문 발견
|
||||
logger.info("✅ 완료된 주문 발견: %s (side=%s, volume=%.8f)", order.get("uuid"), side, volume)
|
||||
return order
|
||||
except Exception as e:
|
||||
logger.warning("❌ 주문 확인 중 오류 발생: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
|
||||
"""
|
||||
Retry 전에 중복된 미체결/완료된 주문이 있는지 확인합니다.
|
||||
|
||||
Returns:
|
||||
(is_duplicate: bool, order_info: dict or None)
|
||||
is_duplicate=True: 중복 주문 발견, order_info 반환
|
||||
is_duplicate=False: 중복 없음, order_info=None
|
||||
"""
|
||||
try:
|
||||
# 1. 미체결 주문 확인 (진행 중)
|
||||
open_orders = upbit.get_orders(ticker=market, state="wait")
|
||||
if open_orders:
|
||||
for order in open_orders:
|
||||
if order.get("side") != side:
|
||||
continue
|
||||
order_vol = float(order.get("volume", 0))
|
||||
order_price = float(order.get("price", 0))
|
||||
|
||||
# 수량이 일치하는가?
|
||||
if abs(order_vol - volume) < 1e-8:
|
||||
# 지정가인 경우 가격도 확인
|
||||
if price is None or abs(order_price - price) < 1e-4:
|
||||
logger.warning(
|
||||
"[⚠️ 중복 감지] 진행 중인 주문 발견: uuid=%s, side=%s, volume=%.8f, price=%.2f",
|
||||
order.get("uuid"),
|
||||
side,
|
||||
order_vol,
|
||||
order_price,
|
||||
)
|
||||
return True, order
|
||||
|
||||
# 2. 최근 완료된 주문 확인 (지난 2분 이내)
|
||||
done_orders = upbit.get_orders(ticker=market, state="done", limit=10)
|
||||
if done_orders:
|
||||
for order in done_orders:
|
||||
if order.get("side") != side:
|
||||
continue
|
||||
order_vol = float(order.get("volume", 0))
|
||||
order_price = float(order.get("price", 0))
|
||||
|
||||
# 수량이 일치하는가?
|
||||
if abs(order_vol - volume) < 1e-8:
|
||||
if price is None or abs(order_price - price) < 1e-4:
|
||||
logger.warning(
|
||||
"[⚠️ 중복 감지] 최근 완료된 주문: uuid=%s, side=%s, volume=%.8f, price=%.2f",
|
||||
order.get("uuid"),
|
||||
side,
|
||||
order_vol,
|
||||
order_price,
|
||||
)
|
||||
return True, order
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[중복 검사] 오류 발생: %s", e)
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) -> dict:
|
||||
"""
|
||||
Upbit API를 이용한 매수 주문 (시장가 또는 지정가)
|
||||
"""
|
||||
from .holdings import get_current_price
|
||||
|
||||
@@ -168,21 +320,6 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: "RuntimeConfig")
|
||||
auto_trade_cfg = cfg.config.get("auto_trade", {})
|
||||
slippage_pct = float(auto_trade_cfg.get("buy_price_slippage_pct", 0.0))
|
||||
|
||||
if cfg.dry_run:
|
||||
price = get_current_price(market)
|
||||
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
|
||||
logger.info(
|
||||
"[place_buy_order_upbit][dry-run] %s 매수 금액=%.2f KRW, 지정가=%.2f", market, amount_krw, limit_price
|
||||
)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
"amount_krw": amount_krw,
|
||||
"price": limit_price,
|
||||
"status": "simulated",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
|
||||
msg = "Upbit API 키 없음: 매수 주문을 실행할 수 없습니다"
|
||||
logger.error(msg)
|
||||
@@ -198,57 +335,169 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: "RuntimeConfig")
|
||||
logger.error(msg)
|
||||
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
||||
|
||||
# 최소 주문 금액 검증 (KRW 기준)
|
||||
raw_min = cfg.config.get("auto_trade", {}).get("min_order_value_krw")
|
||||
try:
|
||||
min_order_value = float(raw_min) if raw_min is not None else float(MIN_KRW_ORDER)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[WARNING] min_order_value_krw 설정 누락/비정상 -> 기본값 %d 사용 (raw=%s)", MIN_KRW_ORDER, raw_min
|
||||
)
|
||||
min_order_value = float(MIN_KRW_ORDER)
|
||||
if amount_krw < min_order_value:
|
||||
msg = (
|
||||
f"[매수 건너뜀] {market}\n사유: 최소 주문 금액 미만"
|
||||
f"\n요청 금액: {amount_krw:.0f} KRW < 최소 {min_order_value:.0f} KRW"
|
||||
)
|
||||
logger.warning(msg)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
"amount_krw": amount_krw,
|
||||
"status": "skipped_too_small",
|
||||
"reason": "min_order_value",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
|
||||
|
||||
if cfg.dry_run:
|
||||
logger.info(
|
||||
"[place_buy_order_upbit][dry-run] %s 매수 금액=%.2f KRW, 지정가=%.2f", market, amount_krw, limit_price
|
||||
)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
"amount_krw": amount_krw,
|
||||
"price": limit_price,
|
||||
"status": "simulated",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
resp = None
|
||||
|
||||
if slippage_pct > 0 and limit_price > 0:
|
||||
# 지정가 매수: 호가 단위에 맞춰 가격 조정
|
||||
adjusted_limit_price = adjust_price_to_tick_size(limit_price)
|
||||
volume = amount_krw / adjusted_limit_price
|
||||
# Retry loop for robust order placement
|
||||
max_retries = 3
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
if slippage_pct > 0 and limit_price > 0:
|
||||
# 지정가 매수
|
||||
adjusted_limit_price = adjust_price_to_tick_size(limit_price)
|
||||
volume = amount_krw / adjusted_limit_price
|
||||
|
||||
# 🔒 안전성 검증: 파라미터 최종 확인
|
||||
if adjusted_limit_price <= 0 or volume <= 0:
|
||||
msg = f"[매수 실패] {market}: 비정상 파라미터 (price={adjusted_limit_price}, volume={volume})"
|
||||
logger.error(msg)
|
||||
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
||||
if adjusted_limit_price <= 0 or volume <= 0:
|
||||
raise ValueError(f"Invalid params: price={adjusted_limit_price}, volume={volume}")
|
||||
|
||||
# pyupbit API: buy_limit_order(ticker, price, volume)
|
||||
# - ticker: 마켓 심볼 (예: "KRW-BTC")
|
||||
# - price: 지정가 (KRW, 예: 50000000)
|
||||
# - volume: 매수 수량 (코인 개수, 예: 0.001)
|
||||
logger.info(
|
||||
"[매수 주문 전 검증] %s | 지정가=%.2f KRW | 수량=%.8f개 | 예상 총액=%.2f KRW",
|
||||
market,
|
||||
adjusted_limit_price,
|
||||
volume,
|
||||
adjusted_limit_price * volume,
|
||||
)
|
||||
if attempt == 1:
|
||||
logger.info(
|
||||
"[매수 주문] %s | 지정가=%.2f KRW | 수량=%.8f개 | 시도 %d/%d",
|
||||
market,
|
||||
adjusted_limit_price,
|
||||
volume,
|
||||
attempt,
|
||||
max_retries,
|
||||
)
|
||||
|
||||
resp = upbit.buy_limit_order(market, adjusted_limit_price, volume)
|
||||
resp = upbit.buy_limit_order(market, adjusted_limit_price, volume)
|
||||
|
||||
logger.info(
|
||||
"✅ Upbit 지정가 매수 주문 완료: %s | 지정가=%.2f (조정전: %.2f) | 수량=%.8f | 목표금액=%.2f KRW",
|
||||
market,
|
||||
adjusted_limit_price,
|
||||
limit_price,
|
||||
volume,
|
||||
amount_krw,
|
||||
)
|
||||
else:
|
||||
# 시장가 매수: amount_krw 금액만큼 시장가로 매수
|
||||
# pyupbit API: buy_market_order(ticker, price)
|
||||
# - ticker: 마켓 심볼
|
||||
# - price: 매수할 KRW 금액 (예: 15000)
|
||||
logger.info("[매수 주문 전 검증] %s | 시장가 매수 | 금액=%.2f KRW", market, amount_krw)
|
||||
if attempt == 1:
|
||||
logger.info("✅ Upbit 지정가 매수 주문 완료")
|
||||
|
||||
resp = upbit.buy_market_order(market, amount_krw)
|
||||
else:
|
||||
# 시장가 매수
|
||||
if attempt == 1:
|
||||
logger.info(
|
||||
"[매수 주문] %s | 시장가 매수 | 금액=%.2f KRW | 시도 %d/%d",
|
||||
market,
|
||||
amount_krw,
|
||||
attempt,
|
||||
max_retries,
|
||||
)
|
||||
|
||||
resp = upbit.buy_market_order(market, amount_krw)
|
||||
|
||||
if attempt == 1:
|
||||
logger.info("✅ Upbit 시장가 매수 주문 완료")
|
||||
|
||||
# If successful, break retry loop
|
||||
break
|
||||
|
||||
except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError) as e:
|
||||
logger.warning("[매수 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
except requests.exceptions.ReadTimeout:
|
||||
logger.warning("[매수 확인] ReadTimeout 발생 (%d/%d). 주문 확인 시도...", attempt, max_retries)
|
||||
|
||||
# 1단계: 중복 주문 여부 확인 (Retry 전)
|
||||
check_price = adjusted_limit_price if (slippage_pct > 0 and limit_price > 0) else None
|
||||
|
||||
if slippage_pct > 0 and limit_price > 0:
|
||||
# 지정가 주문: 중복 체크 + 확인
|
||||
is_dup, dup_order = _has_duplicate_pending_order(upbit, market, "bid", volume, check_price)
|
||||
if is_dup and dup_order:
|
||||
logger.error(
|
||||
"[⛔ 중복 방지] 이미 동일한 주문이 존재함: uuid=%s. Retry 취소.", dup_order.get("uuid")
|
||||
)
|
||||
resp = dup_order
|
||||
break
|
||||
|
||||
# 중복 없음 -> 기존 주문 확인
|
||||
found = _find_recent_order(upbit, market, "bid", volume, check_price)
|
||||
if found:
|
||||
logger.info("✅ 주문 확인됨: %s", found.get("uuid"))
|
||||
resp = found
|
||||
break
|
||||
|
||||
logger.warning("주문 확인 실패. 재시도합니다.")
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
# Other exceptions (e.g. ValueError from pyupbit) - do not retry
|
||||
logger.error("[매수 실패] 예외 발생: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
# ===== 주문 응답 검증 =====
|
||||
if not isinstance(resp, dict):
|
||||
logger.error("[매수 실패] %s: 비정상 응답 타입: %r", market, resp)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
"amount_krw": amount_krw,
|
||||
"status": "failed",
|
||||
"error": "invalid_response_type",
|
||||
"response": resp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
order_uuid = resp.get("uuid") or resp.get("id") or resp.get("txid")
|
||||
if not order_uuid:
|
||||
# Upbit 오류 포맷 대응: {"error": {...}}
|
||||
err_obj = resp.get("error")
|
||||
if isinstance(err_obj, dict):
|
||||
err_name = err_obj.get("name")
|
||||
err_msg = err_obj.get("message")
|
||||
logger.error("[매수 실패] %s: Upbit 오류 name=%s, message=%s", market, err_name, err_msg)
|
||||
else:
|
||||
logger.error("[매수 실패] %s: uuid 누락 응답: %s", market, resp)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
"amount_krw": amount_krw,
|
||||
"status": "failed",
|
||||
"error": "order_rejected",
|
||||
"response": resp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
logger.info("Upbit 주문 응답: uuid=%s", order_uuid)
|
||||
|
||||
logger.info("✅ Upbit 시장가 매수 주문 완료: %s | 금액=%.2f KRW", market, amount_krw)
|
||||
if isinstance(resp, dict):
|
||||
order_uuid = resp.get("uuid")
|
||||
logger.info("Upbit 주문 응답: uuid=%s", order_uuid)
|
||||
else:
|
||||
logger.info("Upbit 주문 응답: %s", resp)
|
||||
result = {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
@@ -260,9 +509,6 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: "RuntimeConfig")
|
||||
}
|
||||
|
||||
try:
|
||||
order_uuid = None
|
||||
if isinstance(resp, dict):
|
||||
order_uuid = resp.get("uuid") or resp.get("id") or resp.get("txid")
|
||||
if order_uuid:
|
||||
monitor_res = monitor_order_upbit(order_uuid, cfg.upbit_access_key, cfg.upbit_secret_key)
|
||||
result["monitor"] = monitor_res
|
||||
@@ -275,7 +521,7 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: "RuntimeConfig")
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
|
||||
def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") -> dict:
|
||||
def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> dict:
|
||||
"""
|
||||
Upbit API를 이용한 시장가 매도 주문
|
||||
|
||||
@@ -287,10 +533,6 @@ def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") ->
|
||||
Returns:
|
||||
주문 결과 딕셔너리
|
||||
"""
|
||||
if cfg.dry_run:
|
||||
logger.info("[place_sell_order_upbit][dry-run] %s 매도 수량=%.8f", market, amount)
|
||||
return {"market": market, "side": "sell", "amount": amount, "status": "simulated", "timestamp": time.time()}
|
||||
|
||||
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
|
||||
msg = "Upbit API 키 없음: 매도 주문을 실행할 수 없습니다"
|
||||
logger.error(msg)
|
||||
@@ -319,6 +561,18 @@ def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") ->
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
if amount <= 0:
|
||||
msg = f"[매도 실패] {market}: 비정상 수량 (amount={amount})"
|
||||
logger.error(msg)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "sell",
|
||||
"amount": amount,
|
||||
"status": "failed",
|
||||
"error": "invalid_amount",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
estimated_value = amount * current_price
|
||||
# 최소 주문 금액 안전 파싱 (누락/형식 오류 대비)
|
||||
raw_min = cfg.config.get("auto_trade", {}).get("min_order_value_krw")
|
||||
@@ -343,24 +597,9 @@ def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") ->
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
# ===== 매도 API 안전 검증 (Critical Safety Check) =====
|
||||
# pyupbit API: sell_market_order(ticker, volume)
|
||||
# - ticker: 마켓 코드 (예: "KRW-BTC")
|
||||
# - volume: 매도할 코인 수량 (개수, not KRW)
|
||||
# 잘못된 사용 예시: sell_market_order("KRW-BTC", 500000) → BTC 500,000개 매도 시도! ❌
|
||||
# 올바른 사용 예시: sell_market_order("KRW-BTC", 0.01) → BTC 0.01개 매도 ✅
|
||||
|
||||
if amount <= 0:
|
||||
msg = f"[매도 실패] {market}: 비정상 수량 (amount={amount})"
|
||||
logger.error(msg)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "sell",
|
||||
"amount": amount,
|
||||
"status": "failed",
|
||||
"error": "invalid_amount",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
if cfg.dry_run:
|
||||
logger.info("[place_sell_order_upbit][dry-run] %s 매도 수량=%.8f", market, amount)
|
||||
return {"market": market, "side": "sell", "amount": amount, "status": "simulated", "timestamp": time.time()}
|
||||
|
||||
# 매도 전 파라미터 검증 로그 (안전장치)
|
||||
logger.info(
|
||||
@@ -371,15 +610,86 @@ def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") ->
|
||||
estimated_value,
|
||||
)
|
||||
|
||||
resp = upbit.sell_market_order(market, amount)
|
||||
logger.info(
|
||||
"✅ Upbit 시장가 매도 주문 완료: %s | 수량=%.8f개 | 예상 매도액=%.2f KRW", market, amount, estimated_value
|
||||
)
|
||||
if isinstance(resp, dict):
|
||||
order_uuid = resp.get("uuid")
|
||||
logger.info("Upbit 주문 응답: uuid=%s", order_uuid)
|
||||
else:
|
||||
logger.info("Upbit 주문 응답: %s", resp)
|
||||
resp = None
|
||||
max_retries = 3
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
resp = upbit.sell_market_order(market, amount)
|
||||
logger.info(
|
||||
"✅ Upbit 시장가 매도 주문 완료: %s | 수량=%.8f개 | 예상 매도액=%.2f KRW",
|
||||
market,
|
||||
amount,
|
||||
estimated_value,
|
||||
)
|
||||
break
|
||||
except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError) as e:
|
||||
logger.warning("[매도 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
continue
|
||||
except requests.exceptions.ReadTimeout:
|
||||
logger.warning("[매도 확인] ReadTimeout 발생 (%d/%d). 주문 확인 시도...", attempt, max_retries)
|
||||
|
||||
# 1단계: 중복 주문 여부 확인 (Retry 전)
|
||||
is_dup, dup_order = _has_duplicate_pending_order(upbit, market, "ask", amount, None)
|
||||
if is_dup and dup_order:
|
||||
logger.error(
|
||||
"[⛔ 중복 방지] 이미 동일한 매도 주문이 존재함: uuid=%s. Retry 취소.", dup_order.get("uuid")
|
||||
)
|
||||
resp = dup_order
|
||||
break
|
||||
|
||||
# 중복 없음 -> 기존 주문 확인
|
||||
found = _find_recent_order(upbit, market, "ask", amount, None)
|
||||
if found:
|
||||
logger.info("✅ 매도 주문 확인됨: %s", found.get("uuid"))
|
||||
resp = found
|
||||
break
|
||||
|
||||
logger.warning("매도 주문 확인 실패. 재시도합니다.")
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error("[매도 실패] 예외 발생: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
# ===== 주문 응답 검증 =====
|
||||
if not isinstance(resp, dict):
|
||||
logger.error("[매도 실패] %s: 비정상 응답 타입: %r", market, resp)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "sell",
|
||||
"amount": amount,
|
||||
"status": "failed",
|
||||
"error": "invalid_response_type",
|
||||
"response": resp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
order_uuid = resp.get("uuid") or resp.get("id") or resp.get("txid")
|
||||
if not order_uuid:
|
||||
err_obj = resp.get("error")
|
||||
if isinstance(err_obj, dict):
|
||||
err_name = err_obj.get("name")
|
||||
err_msg = err_obj.get("message")
|
||||
logger.error("[매도 실패] %s: Upbit 오류 name=%s, message=%s", market, err_name, err_msg)
|
||||
else:
|
||||
logger.error("[매도 실패] %s: uuid 누락 응답: %s", market, resp)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "sell",
|
||||
"amount": amount,
|
||||
"status": "failed",
|
||||
"error": "order_rejected",
|
||||
"response": resp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
logger.info("Upbit 주문 응답: uuid=%s", order_uuid)
|
||||
|
||||
result = {
|
||||
"market": market,
|
||||
"side": "sell",
|
||||
@@ -390,9 +700,6 @@ def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") ->
|
||||
}
|
||||
|
||||
try:
|
||||
order_uuid = None
|
||||
if isinstance(resp, dict):
|
||||
order_uuid = resp.get("uuid") or resp.get("id") or resp.get("txid")
|
||||
if order_uuid:
|
||||
monitor_res = monitor_order_upbit(order_uuid, cfg.upbit_access_key, cfg.upbit_secret_key)
|
||||
result["monitor"] = monitor_res
|
||||
@@ -405,7 +712,7 @@ def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") ->
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
|
||||
def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: "RuntimeConfig") -> dict:
|
||||
def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: RuntimeConfig) -> dict:
|
||||
"""
|
||||
매도 주문 확인 후 실행
|
||||
"""
|
||||
@@ -424,14 +731,14 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: "Runti
|
||||
|
||||
# Telegram 확인 메시지 전송
|
||||
if cfg.telegram_parse_mode == "HTML":
|
||||
msg = f"<b>[확인필요] 자동매도 주문 대기</b>\n"
|
||||
msg = "<b>[확인필요] 자동매도 주문 대기</b>\n"
|
||||
msg += f"토큰: <code>{token}</code>\n"
|
||||
msg += f"심볼: <b>{symbol}</b>\n"
|
||||
msg += f"매도수량: <b>{amount:.8f}</b>\n\n"
|
||||
msg += f"확인 방법: 파일 생성 -> <code>confirm_{token}</code>\n"
|
||||
msg += f"타임아웃: {confirm_timeout}초"
|
||||
else:
|
||||
msg = f"[확인필요] 자동매도 주문 대기\n"
|
||||
msg = "[확인필요] 자동매도 주문 대기\n"
|
||||
msg += f"토큰: {token}\n"
|
||||
msg += f"심볼: {symbol}\n"
|
||||
msg += f"매도수량: {amount:.8f}\n\n"
|
||||
@@ -504,7 +811,7 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: "Runti
|
||||
return result
|
||||
|
||||
|
||||
def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: "RuntimeConfig") -> dict:
|
||||
def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: RuntimeConfig) -> dict:
|
||||
"""
|
||||
매수 주문 확인 후 실행 (매도와 동일한 확인 메커니즘)
|
||||
|
||||
@@ -531,14 +838,14 @@ def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: "Ru
|
||||
|
||||
# Telegram 확인 메시지 전송
|
||||
if cfg.telegram_parse_mode == "HTML":
|
||||
msg = f"<b>[확인필요] 자동매수 주문 대기</b>\n"
|
||||
msg = "<b>[확인필요] 자동매수 주문 대기</b>\n"
|
||||
msg += f"토큰: <code>{token}</code>\n"
|
||||
msg += f"심볼: <b>{symbol}</b>\n"
|
||||
msg += f"매수금액: <b>{amount_krw:,.0f} KRW</b>\n\n"
|
||||
msg += f"확인 방법: 파일 생성 -> <code>confirm_{token}</code>\n"
|
||||
msg += f"타임아웃: {confirm_timeout}초"
|
||||
else:
|
||||
msg = f"[확인필요] 자동매수 주문 대기\n"
|
||||
msg = "[확인필요] 자동매수 주문 대기\n"
|
||||
msg += f"토큰: {token}\n"
|
||||
msg += f"심볼: {symbol}\n"
|
||||
msg += f"매수금액: {amount_krw:,.0f} KRW\n\n"
|
||||
@@ -649,6 +956,45 @@ def monitor_order_upbit(
|
||||
poll_interval: int = None,
|
||||
max_retries: int = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Upbit 주문을 모니터링하고 체결 상태를 확인합니다.
|
||||
|
||||
Args:
|
||||
order_uuid: 주문 ID (Upbit API 응답의 uuid)
|
||||
access_key: Upbit API 액세스 키
|
||||
secret_key: Upbit API 시크릿 키
|
||||
timeout: 모니터링 타임아웃 (초, 기본값 120)
|
||||
poll_interval: 주문 상태 폴링 간격 (초, 기본값 3)
|
||||
max_retries: 타임아웃 시 재시도 횟수 (기본값 1)
|
||||
|
||||
Returns:
|
||||
dict: 주문 모니터링 결과
|
||||
{
|
||||
"final_status": str, # "filled" | "partial" | "timeout" | "cancelled" | "error" | "unknown"
|
||||
"attempts": int, # 재시도 횟수
|
||||
"filled_volume": float, # 체결된 수량
|
||||
"remaining_volume": float, # 미체결 수량
|
||||
"last_order": dict or None, # 마지막 주문 조회 응답
|
||||
"last_checked": float, # 마지막 확인 타임스탐프
|
||||
}
|
||||
|
||||
Error Handling & Recovery:
|
||||
1. ConnectionError / Timeout: Circuit Breaker 활성화 (5회 연속 실패 후 30초 차단)
|
||||
2. 타임아웃 발생:
|
||||
- 매도 주문: 남은 수량을 시장가로 재시도 (최대 1회)
|
||||
- 매수 주문: 부분 체결량만 인정, 재시도 안 함 (재시도 시 초과 매수 위험)
|
||||
3. 연속 에러: 5회 이상 연속 API 오류 시 모니터링 중단
|
||||
4. 주문 취소/거부: 즉시 종료
|
||||
|
||||
Circuit Breaker:
|
||||
- 실패 임계값: 5회 연속 실패
|
||||
- 복구 시간: 30초
|
||||
- 상태: closed (정상) → open (차단) → half_open (프로브) → closed (복구)
|
||||
|
||||
Note:
|
||||
- metrics.json에 성공/실패/타임아웃 카운트 기록
|
||||
- 모든 폴링 루프는 최대 timeout + 30초(여유) 후 강제 종료
|
||||
"""
|
||||
if timeout is None:
|
||||
timeout = int(os.getenv("ORDER_MONITOR_TIMEOUT", "120"))
|
||||
if poll_interval is None:
|
||||
@@ -656,6 +1002,7 @@ def monitor_order_upbit(
|
||||
if max_retries is None:
|
||||
max_retries = int(os.getenv("ORDER_MAX_RETRIES", "1"))
|
||||
upbit = pyupbit.Upbit(access_key, secret_key)
|
||||
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)
|
||||
start = time.time()
|
||||
attempts = 0
|
||||
current_uuid = order_uuid
|
||||
@@ -672,20 +1019,26 @@ def monitor_order_upbit(
|
||||
except ValueError:
|
||||
max_consecutive_errors = 5
|
||||
|
||||
from .metrics import metrics
|
||||
|
||||
while True:
|
||||
loop_start = time.time()
|
||||
# 전체 타임아웃 체크 (무한 대기 방지)
|
||||
if time.time() - start > timeout + 30: # 여유 시간 30초
|
||||
logger.error("주문 모니터링 강제 종료: 전체 타임아웃 초과")
|
||||
final_status = "timeout"
|
||||
metrics.inc("order_monitor_timeout")
|
||||
break
|
||||
|
||||
try:
|
||||
order = upbit.get_order(current_uuid)
|
||||
# Use circuit breaker for get_order
|
||||
order = cb.call(upbit.get_order, current_uuid)
|
||||
consecutive_errors = 0 # 성공 시 에러 카운터 리셋
|
||||
metrics.inc("order_monitor_get_order_success")
|
||||
last_order = order
|
||||
state = order.get("state") if isinstance(order, dict) else None
|
||||
volume = float(order.get("volume", 0)) if isinstance(order, dict) else 0.0
|
||||
executed = float(order.get("executed_volume", 0) or order.get("filled_volume", 0) or 0.0)
|
||||
executed = float(order.get("executed_volume", 0.0))
|
||||
filled = executed
|
||||
remaining = max(0.0, volume - executed)
|
||||
if state in ("done", "closed") or remaining <= 0:
|
||||
@@ -700,12 +1053,12 @@ def monitor_order_upbit(
|
||||
logger.warning("주문 타임아웃: 재시도 %d/%d, 남은량=%.8f", attempts, max_retries, remaining)
|
||||
try:
|
||||
original_side = order.get("side")
|
||||
cancel_resp = upbit.cancel_order(current_uuid)
|
||||
cancel_resp = cb.call(upbit.cancel_order, current_uuid)
|
||||
logger.info("[%s] 주문 취소 시도: %s", order.get("market"), cancel_resp)
|
||||
|
||||
# 취소가 완전히 처리될 때까지 잠시 대기 및 확인
|
||||
time.sleep(3) # 거래소 처리 시간 대기
|
||||
cancelled_order = upbit.get_order(current_uuid)
|
||||
cancelled_order = cb.call(upbit.get_order, current_uuid)
|
||||
if cancelled_order.get("state") not in ("cancel", "cancelled"):
|
||||
logger.error("[%s] 주문 취소 실패 또는 이미 체결됨. 재시도 중단.", order.get("market"))
|
||||
final_status = "error" # 또는 "filled" 상태로 재확인 필요
|
||||
@@ -723,7 +1076,7 @@ def monitor_order_upbit(
|
||||
# 매도만 시장가로 재시도
|
||||
elif original_side == "ask":
|
||||
logger.info("[%s] 취소 확인 후 시장가 매도 재시도", order.get("market"))
|
||||
now_resp = upbit.sell_market_order(order.get("market", ""), remaining)
|
||||
now_resp = cb.call(upbit.sell_market_order, order.get("market", ""), remaining)
|
||||
current_uuid = now_resp.get("uuid") if isinstance(now_resp, dict) else None
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -736,6 +1089,7 @@ def monitor_order_upbit(
|
||||
time.sleep(poll_interval)
|
||||
except Exception as e:
|
||||
consecutive_errors += 1
|
||||
metrics.inc("order_monitor_errors")
|
||||
logger.error("주문 모니터링 중 오류 (%d/%d): %s", consecutive_errors, max_consecutive_errors, e)
|
||||
|
||||
if consecutive_errors >= max_consecutive_errors:
|
||||
@@ -749,6 +1103,9 @@ def monitor_order_upbit(
|
||||
|
||||
# 에러 발생 시 잠시 대기 후 재시도
|
||||
time.sleep(min(poll_interval * 2, 10))
|
||||
finally:
|
||||
# loop duration
|
||||
metrics.observe("order_monitor_loop_ms", (time.time() - loop_start) * 1000.0)
|
||||
return {
|
||||
"final_status": final_status,
|
||||
"attempts": attempts,
|
||||
|
||||
Reference in New Issue
Block a user