1347 lines
57 KiB
Python
1347 lines
57 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal, getcontext
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pyupbit
|
|
import requests
|
|
|
|
from .circuit_breaker import CircuitBreaker
|
|
from .common import HOLDINGS_FILE, MIN_KRW_ORDER, PENDING_ORDERS_FILE, logger
|
|
from .constants import ORDER_MAX_RETRIES, ORDER_RETRY_DELAY, PENDING_ORDER_TTL
|
|
from .notifications import send_telegram
|
|
|
|
if TYPE_CHECKING:
|
|
from .config import RuntimeConfig
|
|
|
|
|
|
# Decimal 연산 정밀도 설정 (가격/수량 계산 안정화)
|
|
getcontext().prec = 28
|
|
|
|
|
|
def validate_upbit_api_keys(access_key: str, secret_key: str, check_trade_permission: bool = True) -> tuple[bool, str]:
|
|
"""
|
|
Upbit API 키의 유효성을 검증합니다 (LOW-005: 강화된 검증).
|
|
|
|
Args:
|
|
access_key: Upbit 액세스 키
|
|
secret_key: Upbit 시크릿 키
|
|
check_trade_permission: 주문 권한 검증 여부 (기본값: True)
|
|
|
|
Returns:
|
|
(유효성 여부, 메시지)
|
|
True, "OK": 유효한 키
|
|
False, "에러 메시지": 유효하지 않은 키
|
|
"""
|
|
if not access_key or not secret_key:
|
|
return False, "API 키가 설정되지 않았습니다"
|
|
|
|
try:
|
|
upbit = pyupbit.Upbit(access_key, secret_key)
|
|
|
|
# 1단계: 잔고 조회 (읽기 권한)
|
|
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}"
|
|
|
|
# 2단계: 주문 권한 검증 (선택적)
|
|
if check_trade_permission:
|
|
logger.debug("[검증] 주문 권한 확인 중...")
|
|
|
|
# 실제 주문하지 않고 권한만 확인 (극소량 테스트 주문)
|
|
# 참고: pyupbit는 실제로 주문을 생성하므로, 대신 주문 목록 조회로 권한 확인
|
|
try:
|
|
orders = upbit.get_orders(ticker="KRW-BTC", state="wait")
|
|
|
|
# 주문 목록 조회 성공 = 주문 API 접근 가능
|
|
if orders is None:
|
|
logger.warning("[검증] 주문 목록 조회 실패 (None 응답), 주문 권한 미확인")
|
|
elif isinstance(orders, dict) and "error" in orders:
|
|
error_msg = orders.get("error", {}).get("message", "Unknown error")
|
|
if "invalid" in error_msg.lower() or "permission" in error_msg.lower():
|
|
return False, f"주문 권한 없음: {error_msg}"
|
|
logger.warning("[검증] 주문 API 오류: %s (읽기 권한은 있음)", error_msg)
|
|
else:
|
|
logger.debug("[검증] 주문 권한 확인 완료 (주문 목록 조회 성공)")
|
|
|
|
except requests.exceptions.HTTPError as e:
|
|
# 401/403: 권한 없음
|
|
if e.response.status_code in [401, 403]:
|
|
return False, f"주문 권한 없음 (HTTP {e.response.status_code})"
|
|
logger.warning("[검증] 주문 권한 확인 중 HTTP 오류: %s (읽기 권한은 있음)", e)
|
|
except Exception as e:
|
|
logger.warning("[검증] 주문 권한 확인 중 예외: %s (읽기 권한은 있음)", e)
|
|
|
|
# 성공: 유효한 키
|
|
asset_count = len(balances) if isinstance(balances, list) else 0
|
|
if check_trade_permission:
|
|
logger.info("[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d개, 주문 권한 검증 완료", asset_count)
|
|
else:
|
|
logger.info("[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d개", asset_count)
|
|
|
|
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:
|
|
"""
|
|
Upbit 호가 단위에 맞춰 가격을 조정합니다.
|
|
|
|
- Decimal 기반으로 계산하여 부동소수점 오차를 최소화합니다.
|
|
- pyupbit.get_tick_size 실패 시 원본 가격을 그대로 사용합니다.
|
|
"""
|
|
try:
|
|
tick_size = pyupbit.get_tick_size(price)
|
|
if not tick_size or tick_size <= 0:
|
|
raise ValueError(f"invalid tick_size: {tick_size}")
|
|
|
|
d_price = Decimal(str(price))
|
|
d_tick = Decimal(str(tick_size))
|
|
|
|
# 호가 단위에 가장 가까운 값으로 반올림 (최근접 호가)
|
|
steps = (d_price / d_tick).to_integral_value(rounding=ROUND_HALF_UP)
|
|
adjusted_price = steps * d_tick
|
|
|
|
return float(adjusted_price)
|
|
except Exception as e:
|
|
logger.warning("호가 단위 조정 실패: %s. 원본 가격 사용.", e)
|
|
return price
|
|
|
|
|
|
def compute_limit_order_params(amount_krw: float, raw_price: float) -> tuple[float, float]:
|
|
"""
|
|
지정가 매수 주문에 필요한 가격/수량을 Decimal로 안정적으로 계산합니다.
|
|
|
|
Args:
|
|
amount_krw: 사용할 KRW 금액
|
|
raw_price: 요청된 지정가 (슬리피지 반영 후 가격 등)
|
|
|
|
Returns:
|
|
(adjusted_price, volume) where price respects tick size and volume is rounded down to 8 decimals.
|
|
|
|
Raises:
|
|
ValueError: price 또는 amount가 유효하지 않을 때
|
|
"""
|
|
d_amount = Decimal(str(amount_krw))
|
|
if d_amount <= 0:
|
|
raise ValueError("amount_krw must be positive")
|
|
|
|
adjusted_price = adjust_price_to_tick_size(raw_price)
|
|
d_price = Decimal(str(adjusted_price))
|
|
if d_price <= 0:
|
|
raise ValueError("price must be positive after tick adjustment")
|
|
|
|
# 수량은 호가 단위 가격에 맞춰 8자리로 내림 (초과 주문 방지)
|
|
volume = (d_amount / d_price).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
|
|
|
|
if volume <= 0:
|
|
raise ValueError("computed volume is non-positive")
|
|
|
|
return float(d_price), float(volume)
|
|
|
|
|
|
def _make_confirm_token(length: int = 16) -> str:
|
|
return secrets.token_hex(length)
|
|
|
|
|
|
_pending_order_lock = threading.Lock()
|
|
|
|
|
|
def _write_pending_order(token: str, order: dict, pending_file: str = PENDING_ORDERS_FILE):
|
|
with _pending_order_lock:
|
|
try:
|
|
now = time.time()
|
|
ttl_seconds = PENDING_ORDER_TTL # 24h TTL for stale pending records
|
|
|
|
pending = []
|
|
if os.path.exists(pending_file):
|
|
with open(pending_file, encoding="utf-8") as f:
|
|
try:
|
|
pending = json.load(f)
|
|
except json.JSONDecodeError:
|
|
pending = []
|
|
|
|
# TTL cleanup
|
|
pending = [p for p in pending if isinstance(p, dict) and (now - p.get("timestamp", now)) <= ttl_seconds]
|
|
|
|
pending.append({"token": token, "order": order, "timestamp": now})
|
|
|
|
os.makedirs(os.path.dirname(pending_file) or ".", exist_ok=True)
|
|
temp_file = f"{pending_file}.tmp"
|
|
with open(temp_file, "w", encoding="utf-8") as f:
|
|
json.dump(pending, f, ensure_ascii=False, indent=2)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(temp_file, pending_file)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.exception("pending_orders 기록 실패: %s", e)
|
|
raise
|
|
|
|
|
|
_confirmation_lock = threading.Lock()
|
|
|
|
|
|
def _check_confirmation(token: str, timeout: int = 300) -> bool:
|
|
start = time.time()
|
|
confirm_file = f"confirm_{token}"
|
|
while time.time() - start < timeout:
|
|
# 1. Atomic file check
|
|
try:
|
|
os.rename(confirm_file, f"{confirm_file}.processed")
|
|
logger.info("토큰 파일 확인 성공: %s", confirm_file)
|
|
return True
|
|
except FileNotFoundError:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning("토큰 파일 처리 오류: %s", e)
|
|
|
|
time.sleep(2)
|
|
return False
|
|
|
|
|
|
def notify_order_result(
|
|
symbol: str, monitor_result: dict, config: dict, telegram_token: str, telegram_chat_id: str
|
|
) -> bool:
|
|
if not (telegram_token and telegram_chat_id):
|
|
return False
|
|
notify_cfg = config.get("notify", {}) if config else {}
|
|
final_status = monitor_result.get("final_status", "unknown")
|
|
filled = monitor_result.get("filled_volume", 0.0)
|
|
remaining = monitor_result.get("remaining_volume", 0.0)
|
|
attempts = monitor_result.get("attempts", 0)
|
|
should_notify = False
|
|
msg = ""
|
|
if final_status == "filled" and notify_cfg.get("order_filled", True):
|
|
should_notify = True
|
|
msg = f"[주문완료] {symbol}\n체결량: {filled:.8f}\n상태: 완전체결"
|
|
elif final_status in ("partial", "timeout", "cancelled"):
|
|
if final_status == "partial" and notify_cfg.get("order_partial", True):
|
|
should_notify = True
|
|
msg = f"[부분체결] {symbol}\n체결량: {filled:.8f}\n잔여량: {remaining:.8f}"
|
|
elif final_status == "timeout" and notify_cfg.get("order_partial", True):
|
|
should_notify = True
|
|
msg = f"[타임아웃] {symbol}\n체결량: {filled:.8f}\n잔여량: {remaining:.8f}\n재시도: {attempts}회"
|
|
elif final_status == "cancelled" and notify_cfg.get("order_cancelled", True):
|
|
should_notify = True
|
|
msg = f"[주문취소] {symbol}\n취소 사유: 사용자 미확인 또는 오류"
|
|
elif final_status in ("error", "unknown") and notify_cfg.get("order_error", True):
|
|
should_notify = True
|
|
msg = f"[주문오류] {symbol}\n상태: {final_status}\n마지막 확인: {monitor_result.get('last_checked', 'N/A')}"
|
|
if should_notify and msg:
|
|
try:
|
|
send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False)
|
|
return True
|
|
except Exception as e:
|
|
logger.exception("주문 결과 알림 전송 실패: %s", e)
|
|
return False
|
|
return False
|
|
|
|
|
|
def _calculate_and_add_profit_rate(trade_record: dict, symbol: str, monitor: dict):
|
|
"""
|
|
매도 거래 기록에 수익률 정보를 계산하여 추가합니다.
|
|
"""
|
|
try:
|
|
from .holdings import get_current_price, load_holdings
|
|
|
|
holdings = load_holdings(HOLDINGS_FILE)
|
|
if symbol not in holdings:
|
|
return
|
|
|
|
buy_price = float(holdings[symbol].get("buy_price", 0.0) or 0.0)
|
|
|
|
# 실제 평균 매도가 계산
|
|
sell_price = 0.0
|
|
if monitor and monitor.get("last_order"):
|
|
last_order = monitor["last_order"]
|
|
trades = last_order.get("trades", [])
|
|
if trades:
|
|
total_krw = sum(float(t.get("price", 0)) * float(t.get("volume", 0)) for t in trades)
|
|
total_volume = sum(float(t.get("volume", 0)) for t in trades)
|
|
if total_volume > 0:
|
|
sell_price = total_krw / total_volume
|
|
|
|
# 매도가가 없으면 현재가 사용
|
|
if sell_price <= 0:
|
|
sell_price = get_current_price(symbol)
|
|
|
|
# 수익률 계산 및 기록 추가
|
|
if buy_price > 0 and sell_price > 0:
|
|
profit_rate = ((sell_price - buy_price) / buy_price) * 100
|
|
trade_record["buy_price"] = buy_price
|
|
trade_record["sell_price"] = sell_price
|
|
trade_record["profit_rate"] = round(profit_rate, 2)
|
|
logger.info(
|
|
"[%s] 매도 수익률: %.2f%% (매수가: %.2f, 매도가: %.2f)", symbol, profit_rate, buy_price, sell_price
|
|
)
|
|
else:
|
|
logger.warning("[%s] 수익률 계산 불가: buy_price=%.2f, sell_price=%.2f", symbol, buy_price, sell_price)
|
|
trade_record["profit_rate"] = None
|
|
except Exception as e:
|
|
logger.warning("매도 수익률 계산 중 오류 (기록은 계속 진행): %s", e)
|
|
trade_record["profit_rate"] = None
|
|
|
|
|
|
def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60):
|
|
"""
|
|
Find a recently placed order matching criteria to handle ReadTimeout.
|
|
|
|
Args:
|
|
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:
|
|
# 타임스탬프 확인
|
|
created_at = order.get("created_at")
|
|
if created_at:
|
|
try:
|
|
# ISO 8601 파싱 (Upbit: 2018-04-10T15:42:23+09:00)
|
|
# 파이썬 3.7+ fromisoformat 지원 (Z 처리 불완전할 수 있으나 Upbit는 +09:00)
|
|
dt = datetime.fromisoformat(created_at)
|
|
# 시간대 인지 (Offset Awareness) 처리
|
|
now = datetime.now(dt.tzinfo)
|
|
if (now - dt).total_seconds() > lookback_sec:
|
|
continue # 제한 시간보다 오래된 주문은 무시
|
|
except ValueError:
|
|
pass # 날짜 파싱 실패 시 안전하게 무시 (하거나 로깅)
|
|
|
|
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, lookback_sec=120):
|
|
"""
|
|
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:
|
|
# 타임스탬프 확인 (Created At)
|
|
created_at = order.get("created_at")
|
|
if created_at:
|
|
try:
|
|
dt = datetime.fromisoformat(created_at)
|
|
now = datetime.now(dt.tzinfo)
|
|
if (now - dt).total_seconds() > lookback_sec:
|
|
continue # 오래된 주문 무시
|
|
except ValueError:
|
|
pass
|
|
|
|
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
|
|
|
|
# config에서 buy_price_slippage_pct 읽기
|
|
auto_trade_cfg = cfg.config.get("auto_trade", {})
|
|
slippage_pct = float(auto_trade_cfg.get("buy_price_slippage_pct", 0.0))
|
|
|
|
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
|
|
msg = "Upbit API 키 없음: 매수 주문을 실행할 수 없습니다"
|
|
logger.error(msg)
|
|
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
|
|
|
allocation_token: str | None = None
|
|
|
|
try:
|
|
from .common import krw_balance_lock
|
|
|
|
with krw_balance_lock:
|
|
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
|
|
price = get_current_price(market)
|
|
|
|
# 현재가 검증
|
|
if price <= 0:
|
|
msg = f"[매수 실패] {market}: 현재가 조회 실패 (price={price})"
|
|
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)
|
|
|
|
# ✅ 부분 매수 지원: 잔고 확인 및 조정 (CRITICAL-005)
|
|
# ✅ Race Condition 방지: KRW 예산 할당 시스템 사용 (CRITICAL-v3-1 개선)
|
|
if not cfg.dry_run:
|
|
from .common import krw_budget_manager
|
|
|
|
success, allocated_amount, allocation_token = krw_budget_manager.allocate(
|
|
market,
|
|
amount_krw,
|
|
upbit,
|
|
min_order_value=min_order_value,
|
|
)
|
|
|
|
if not success:
|
|
msg = f"[매수 건너뜀] {market}\n사유: KRW 예산 부족\n요청 금액: {amount_krw:.0f} KRW"
|
|
logger.warning(msg)
|
|
return {
|
|
"market": market,
|
|
"side": "buy",
|
|
"amount_krw": amount_krw,
|
|
"status": "skipped_insufficient_budget",
|
|
"reason": "insufficient_budget",
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
if allocated_amount < min_order_value:
|
|
krw_budget_manager.release(allocation_token)
|
|
msg = (
|
|
f"[매수 건너뜀] {market}\n사유: 최소 주문 금액 미만"
|
|
f"\n할당 금액: {allocated_amount:.0f} KRW < 최소 {min_order_value:.0f} KRW"
|
|
)
|
|
logger.warning(msg)
|
|
return {
|
|
"market": market,
|
|
"side": "buy",
|
|
"amount_krw": allocated_amount,
|
|
"status": "skipped_insufficient_balance",
|
|
"reason": "insufficient_balance",
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
if allocated_amount < amount_krw:
|
|
logger.info("[%s] KRW 예산 부분 할당: 요청 %.0f원 → 할당 %.0f원", market, amount_krw, allocated_amount)
|
|
|
|
amount_krw = allocated_amount
|
|
|
|
# 수수료 고려 (0.05%) - Decimal 기반으로 정밀 계산
|
|
d_amount = Decimal(str(amount_krw))
|
|
d_fee_rate = Decimal("0.0005") # 0.05% 수수료
|
|
amount_krw = float(d_amount * (Decimal("1") - d_fee_rate))
|
|
|
|
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(),
|
|
}
|
|
|
|
# 슬리피지 적용 - Decimal 기반으로 정밀 계산
|
|
if price > 0 and slippage_pct > 0:
|
|
d_price = Decimal(str(price))
|
|
d_slippage = Decimal(str(slippage_pct)) / Decimal("100")
|
|
limit_price = float(d_price * (Decimal("1") + d_slippage))
|
|
else:
|
|
limit_price = 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
|
|
|
|
# Retry loop for robust order placement
|
|
max_retries = ORDER_MAX_RETRIES
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
if slippage_pct > 0 and limit_price > 0:
|
|
# 지정가 매수 (Decimal 기반 계산)
|
|
adjusted_limit_price, volume = compute_limit_order_params(amount_krw, limit_price)
|
|
|
|
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)
|
|
|
|
if attempt == 1:
|
|
logger.info("✅ Upbit 지정가 매수 주문 완료")
|
|
|
|
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(ORDER_RETRY_DELAY)
|
|
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(ORDER_RETRY_DELAY)
|
|
continue
|
|
|
|
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
|
|
# Other expected 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)
|
|
|
|
result = {
|
|
"market": market,
|
|
"side": "buy",
|
|
"amount_krw": amount_krw,
|
|
"price": limit_price if slippage_pct > 0 else None,
|
|
"status": "placed",
|
|
"response": resp,
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
try:
|
|
if order_uuid:
|
|
monitor_res = monitor_order_upbit(order_uuid, cfg.upbit_access_key, cfg.upbit_secret_key)
|
|
result["monitor"] = monitor_res
|
|
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
|
|
except Exception:
|
|
logger.debug("매수 주문 모니터링 중 예외 발생", exc_info=True)
|
|
|
|
return result
|
|
|
|
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
|
|
logger.exception("Upbit 매수 주문 실패: %s", e)
|
|
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
|
|
|
finally:
|
|
# 4. 주문 완료 후 예산 해제 (성공/실패 무관)
|
|
if not cfg.dry_run:
|
|
from .common import krw_budget_manager
|
|
|
|
krw_budget_manager.release(allocation_token)
|
|
|
|
|
|
def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> dict:
|
|
"""
|
|
Upbit API를 이용한 시장가 매도 주문
|
|
|
|
Args:
|
|
market: 거래 시장 (예: KRW-BTC)
|
|
amount: 매도할 코인 수량
|
|
cfg: RuntimeConfig 객체
|
|
|
|
Returns:
|
|
주문 결과 딕셔너리
|
|
"""
|
|
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
|
|
msg = "Upbit API 키 없음: 매도 주문을 실행할 수 없습니다"
|
|
logger.error(msg)
|
|
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
|
|
|
try:
|
|
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
|
|
# 최소 주문 금액(설정값, 기본 5,000 KRW) 이하일 경우 매도 건너뜀
|
|
try:
|
|
from .holdings import get_current_price
|
|
|
|
current_price = float(get_current_price(market))
|
|
except Exception:
|
|
current_price = 0.0
|
|
|
|
# 현재가 조회 실패 시 안전하게 매도 차단
|
|
if current_price <= 0:
|
|
msg = f"[매도 실패] {market}\n사유: 현재가 조회 실패\n매도 수량: {amount:.8f}"
|
|
logger.error(msg)
|
|
return {
|
|
"market": market,
|
|
"side": "sell",
|
|
"amount": amount,
|
|
"status": "failed",
|
|
"error": "price_unavailable",
|
|
"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")
|
|
try:
|
|
min_order_value = float(raw_min)
|
|
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 estimated_value < min_order_value:
|
|
msg = f"[매도 건너뜀] {market}\n사유: 최소 주문 금액 미만\n추정 금액: {estimated_value:.0f} KRW < 최소 {min_order_value:.0f} KRW\n매도 수량: {amount:.8f}"
|
|
logger.warning(msg)
|
|
return {
|
|
"market": market,
|
|
"side": "sell",
|
|
"amount": amount,
|
|
"status": "skipped_too_small",
|
|
"reason": "min_order_value",
|
|
"estimated_value": estimated_value,
|
|
"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(
|
|
"🔍 [매도 주문 전 검증] %s | 매도 수량=%.8f개 | 현재가=%.2f KRW | 예상 매도액=%.2f KRW",
|
|
market,
|
|
amount,
|
|
current_price,
|
|
estimated_value,
|
|
)
|
|
|
|
resp = None
|
|
max_retries = ORDER_MAX_RETRIES
|
|
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(ORDER_RETRY_DELAY)
|
|
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(ORDER_RETRY_DELAY)
|
|
continue
|
|
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) 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",
|
|
"amount": amount,
|
|
"status": "placed",
|
|
"response": resp,
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
try:
|
|
if order_uuid:
|
|
monitor_res = monitor_order_upbit(order_uuid, cfg.upbit_access_key, cfg.upbit_secret_key)
|
|
result["monitor"] = monitor_res
|
|
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
|
|
except Exception:
|
|
logger.debug("매도 주문 모니터링 중 예외 발생", exc_info=True)
|
|
return result
|
|
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
|
|
logger.exception("Upbit 매도 주문 실패: %s", e)
|
|
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
|
|
|
|
|
def execute_sell_order_with_confirmation(
|
|
symbol: str,
|
|
amount: float,
|
|
cfg: RuntimeConfig,
|
|
reason: str = "",
|
|
is_stop_loss: bool = False, # [NEW] 명시적 손절 플래그
|
|
) -> dict:
|
|
"""
|
|
매도 주문 확인 후 실행
|
|
|
|
Args:
|
|
symbol: 심볼
|
|
amount: 수량
|
|
cfg: 설정
|
|
reason: 매도 사유 (예: "stop_loss", "profit_taking" 등)
|
|
is_stop_loss: 손절 여부 (True면 확인 절차 스킵 가능)
|
|
"""
|
|
confirm_cfg = cfg.config.get("confirm", {})
|
|
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
|
|
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
|
|
confirm_stop_loss = confirm_cfg.get("confirm_stop_loss", False)
|
|
|
|
result = None
|
|
|
|
# 즉시 매도 조건:
|
|
# 1. 파일 확인 비활성화됨
|
|
# 2. 또는 (손절이고 && 손절 확인이 비활성화됨)
|
|
# is_stop_loss 플래그가 True이거나, reason 텍스트에 "stop_loss"/"손절"이 포함된 경우
|
|
final_is_stop_loss = is_stop_loss or ("stop_loss" in reason) or ("손절" in reason)
|
|
|
|
bypass_confirmation = not confirm_via_file or (final_is_stop_loss and not confirm_stop_loss)
|
|
|
|
if bypass_confirmation:
|
|
if final_is_stop_loss and confirm_via_file: # 파일 확인 모드인데 손절이라서 스킵하는 경우
|
|
logger.info("손절(Stop Loss) 조건 발동: 파일 확인을 건너뛰고 즉시 매도합니다. (reason=%s)", reason)
|
|
else:
|
|
logger.info("파일 확인 비활성화(또는 조건): 즉시 매도 주문 실행")
|
|
|
|
result = place_sell_order_upbit(symbol, amount, cfg)
|
|
else:
|
|
token = _make_confirm_token()
|
|
order_info = {"symbol": symbol, "side": "sell", "amount": amount, "timestamp": time.time()}
|
|
_write_pending_order(token, order_info)
|
|
|
|
# Telegram 확인 메시지 전송
|
|
if cfg.telegram_parse_mode == "HTML":
|
|
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 = "[확인필요] 자동매도 주문 대기\n"
|
|
msg += f"토큰: {token}\n"
|
|
msg += f"심볼: {symbol}\n"
|
|
msg += f"매도수량: {amount:.8f}\n\n"
|
|
msg += f"확인 방법: 파일 생성 -> confirm_{token}\n"
|
|
msg += f"타임아웃: {confirm_timeout}초"
|
|
|
|
if cfg.telegram_bot_token and cfg.telegram_chat_id:
|
|
send_telegram(
|
|
cfg.telegram_bot_token,
|
|
cfg.telegram_chat_id,
|
|
msg,
|
|
add_thread_prefix=False,
|
|
parse_mode=cfg.telegram_parse_mode,
|
|
)
|
|
|
|
# 테스트 환경에서는 사용자 확인을 대기하지 않고 바로 반환하여 pytest 지연을 방지
|
|
if os.getenv("PYTEST_CURRENT_TEST"):
|
|
logger.info("[TEST] 확인 대기 생략: token=%s", token)
|
|
return {"status": "user_not_confirmed", "symbol": symbol, "token": token, "timestamp": time.time()}
|
|
|
|
logger.info("[%s] 매도 확인 대기 중: 토큰=%s, 타임아웃=%d초", symbol, token, confirm_timeout)
|
|
confirmed = _check_confirmation(token, confirm_timeout)
|
|
|
|
if not confirmed:
|
|
logger.warning("[%s] 매도 확인 타임아웃: 주문 취소", symbol)
|
|
if cfg.telegram_bot_token and cfg.telegram_chat_id:
|
|
cancel_msg = f"[주문취소] {symbol} 매도\n사유: 사용자 미확인 (타임아웃)"
|
|
send_telegram(
|
|
cfg.telegram_bot_token,
|
|
cfg.telegram_chat_id,
|
|
cancel_msg,
|
|
add_thread_prefix=False,
|
|
parse_mode=cfg.telegram_parse_mode,
|
|
)
|
|
result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()}
|
|
else:
|
|
logger.info("[%s] 매도 확인 완료: 주문 실행", symbol)
|
|
result = place_sell_order_upbit(symbol, amount, cfg)
|
|
|
|
# 주문 결과 알림
|
|
if result and result.get("monitor"):
|
|
notify_order_result(symbol, result["monitor"], cfg.config, cfg.telegram_bot_token, cfg.telegram_chat_id)
|
|
|
|
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두) 및 보유 수량 차감
|
|
if result:
|
|
trade_status = result.get("status")
|
|
monitor = result.get("monitor", {})
|
|
monitor_status = monitor.get("final_status")
|
|
record_conditions = ["simulated", "filled", "partial", "timeout", "user_not_confirmed"]
|
|
if trade_status in record_conditions or monitor_status in record_conditions:
|
|
trade_record = {
|
|
"symbol": symbol,
|
|
"side": "sell",
|
|
"amount": amount,
|
|
"timestamp": time.time(),
|
|
"dry_run": cfg.dry_run,
|
|
"result": result,
|
|
}
|
|
|
|
_calculate_and_add_profit_rate(trade_record, symbol, monitor)
|
|
from .signals import record_trade
|
|
|
|
record_trade(trade_record)
|
|
|
|
# ✅ HIGH-008: 매도 후 재매수 방지 기록
|
|
if trade_status in ["simulated", "filled"]:
|
|
from .common import record_sell
|
|
|
|
record_sell(symbol) # 실전 거래이고, 일부/전부 체결됐다면 holdings에서 수량 차감
|
|
if not cfg.dry_run and monitor:
|
|
filled_volume = float(monitor.get("filled_volume", 0.0) or 0.0)
|
|
final_status = monitor.get("final_status")
|
|
if final_status in ("filled", "partial", "timeout") and filled_volume > 0:
|
|
from .holdings import update_holding_amount
|
|
|
|
min_threshold = cfg.config.get("min_amount_threshold", 1e-8)
|
|
update_holding_amount(symbol, -filled_volume, HOLDINGS_FILE, min_amount_threshold=min_threshold)
|
|
|
|
return result
|
|
|
|
|
|
def execute_buy_order_with_confirmation(
|
|
symbol: str, amount_krw: float, cfg: RuntimeConfig, indicators: dict = None
|
|
) -> dict:
|
|
"""
|
|
매수 주문 확인 후 실행 (매도와 동일한 확인 메커니즘)
|
|
|
|
Args:
|
|
symbol: 거래 심볼
|
|
amount_krw: 매수할 KRW 금액
|
|
cfg: RuntimeConfig 객체
|
|
indicators: (Optional) 지표 데이터 (백테스팅용)
|
|
|
|
Returns:
|
|
주문 결과 딕셔너리
|
|
"""
|
|
confirm_cfg = cfg.config.get("confirm", {})
|
|
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
|
|
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
|
|
|
|
result = None
|
|
if not confirm_via_file:
|
|
logger.info("파일 확인 비활성화: 즉시 매수 주문 실행")
|
|
result = place_buy_order_upbit(symbol, amount_krw, cfg)
|
|
else:
|
|
token = _make_confirm_token()
|
|
order_info = {"symbol": symbol, "side": "buy", "amount_krw": amount_krw, "timestamp": time.time()}
|
|
_write_pending_order(token, order_info)
|
|
|
|
# Telegram 확인 메시지 전송
|
|
if cfg.telegram_parse_mode == "HTML":
|
|
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 = "[확인필요] 자동매수 주문 대기\n"
|
|
msg += f"토큰: {token}\n"
|
|
msg += f"심볼: {symbol}\n"
|
|
msg += f"매수금액: {amount_krw:,.0f} KRW\n\n"
|
|
msg += f"확인 방법: 파일 생성 -> confirm_{token}\n"
|
|
msg += f"타임아웃: {confirm_timeout}초"
|
|
|
|
if cfg.telegram_bot_token and cfg.telegram_chat_id:
|
|
send_telegram(
|
|
cfg.telegram_bot_token,
|
|
cfg.telegram_chat_id,
|
|
msg,
|
|
add_thread_prefix=False,
|
|
parse_mode=cfg.telegram_parse_mode,
|
|
)
|
|
|
|
logger.info("[%s] 매수 확인 대기 중: 토큰=%s, 타임아웃=%d초", symbol, token, confirm_timeout)
|
|
confirmed = _check_confirmation(token, confirm_timeout)
|
|
|
|
if not confirmed:
|
|
logger.warning("[%s] 매수 확인 타임아웃: 주문 취소", symbol)
|
|
if cfg.telegram_bot_token and cfg.telegram_chat_id:
|
|
cancel_msg = f"[주문취소] {symbol} 매수\n사유: 사용자 미확인 (타임아웃)"
|
|
send_telegram(
|
|
cfg.telegram_bot_token,
|
|
cfg.telegram_chat_id,
|
|
cancel_msg,
|
|
add_thread_prefix=False,
|
|
parse_mode=cfg.telegram_parse_mode,
|
|
)
|
|
result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()}
|
|
else:
|
|
logger.info("[%s] 매수 확인 완료: 주문 실행", symbol)
|
|
result = place_buy_order_upbit(symbol, amount_krw, cfg)
|
|
|
|
# 주문 결과 알림
|
|
if result and result.get("monitor"):
|
|
notify_order_result(symbol, result["monitor"], cfg.config, cfg.telegram_bot_token, cfg.telegram_chat_id)
|
|
|
|
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두)
|
|
if result:
|
|
trade_status = result.get("status")
|
|
monitor_result = result.get("monitor", {})
|
|
monitor_status = monitor_result.get("final_status")
|
|
|
|
# 시뮬레이션, 완전 체결, 부분 체결, 타임아웃, 사용자 미확인 상태일 때 기록
|
|
record_conditions = ["simulated", "filled", "partial", "timeout", "user_not_confirmed"]
|
|
|
|
if trade_status in record_conditions or monitor_status in record_conditions:
|
|
trade_record = {
|
|
"symbol": symbol,
|
|
"side": "buy",
|
|
"amount_krw": amount_krw,
|
|
"timestamp": time.time(),
|
|
"dry_run": cfg.dry_run,
|
|
"result": result,
|
|
}
|
|
from .signals import record_trade
|
|
|
|
record_trade(trade_record, indicators=indicators)
|
|
|
|
# 실전 거래이고 타임아웃/부분체결 시 체결된 수량을 holdings에 반영
|
|
if not cfg.dry_run and monitor_result:
|
|
filled_volume = float(monitor_result.get("filled_volume", 0.0) or 0.0)
|
|
final_status = monitor_result.get("final_status")
|
|
|
|
if final_status in ("filled", "partial", "timeout") and filled_volume > 0:
|
|
try:
|
|
# 평균 매수가 계산
|
|
last_order = monitor_result.get("last_order", {})
|
|
avg_buy_price = 0.0
|
|
if isinstance(last_order, dict):
|
|
trades = last_order.get("trades", [])
|
|
if trades:
|
|
total_krw = sum(float(t.get("price", 0)) * float(t.get("volume", 0)) for t in trades)
|
|
total_volume = sum(float(t.get("volume", 0)) for t in trades)
|
|
if total_volume > 0:
|
|
avg_buy_price = total_krw / total_volume
|
|
|
|
if avg_buy_price <= 0:
|
|
# 평균가 계산 실패 시 현재가 사용
|
|
from .holdings import get_current_price
|
|
|
|
avg_buy_price = get_current_price(symbol)
|
|
|
|
if avg_buy_price > 0:
|
|
from .holdings import add_new_holding
|
|
|
|
if add_new_holding(symbol, avg_buy_price, filled_volume, time.time(), HOLDINGS_FILE):
|
|
logger.info(
|
|
"[%s] 타임아웃/부분체결 매수 holdings 자동 반영: 체결량=%.8f, 평균가=%.2f",
|
|
symbol,
|
|
filled_volume,
|
|
avg_buy_price,
|
|
)
|
|
else:
|
|
logger.error("[%s] 타임아웃/부분체결 매수 holdings 반영 실패", symbol)
|
|
except Exception as e:
|
|
logger.exception("[%s] 타임아웃/부분체결 매수 holdings 반영 중 오류: %s", symbol, e)
|
|
|
|
return result
|
|
|
|
|
|
def monitor_order_upbit(
|
|
order_uuid: str,
|
|
access_key: str,
|
|
secret_key: str,
|
|
timeout: int = None,
|
|
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:
|
|
poll_interval = int(os.getenv("ORDER_POLL_INTERVAL", "3"))
|
|
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
|
|
last_order = None
|
|
filled = 0.0
|
|
remaining = None
|
|
final_status = "unknown"
|
|
consecutive_errors = 0
|
|
# config에서 max_consecutive_errors 로드 (기본값 5)
|
|
max_consecutive_errors = 5
|
|
try:
|
|
# Note: config는 함수 매개변수로 전달되지 않으므로 환경변수 사용
|
|
max_consecutive_errors = int(os.getenv("ORDER_MAX_CONSECUTIVE_ERRORS", "5"))
|
|
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:
|
|
# 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.0))
|
|
filled = executed
|
|
remaining = max(0.0, volume - executed)
|
|
if state in ("done", "closed") or remaining <= 0:
|
|
final_status = "filled"
|
|
break
|
|
if state in ("cancel", "cancelled", "rejected"):
|
|
final_status = "cancelled"
|
|
break
|
|
if time.time() - start > timeout:
|
|
if attempts < max_retries and remaining and remaining > 0:
|
|
attempts += 1
|
|
logger.warning("주문 타임아웃: 재시도 %d/%d, 남은량=%.8f", attempts, max_retries, remaining)
|
|
try:
|
|
original_side = order.get("side")
|
|
cancel_resp = cb.call(upbit.cancel_order, current_uuid)
|
|
logger.info("[%s] 주문 취소 시도: %s", order.get("market"), cancel_resp)
|
|
|
|
# 취소가 완전히 처리될 때까지 잠시 대기 및 확인
|
|
time.sleep(3) # 거래소 처리 시간 대기
|
|
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" 상태로 재확인 필요
|
|
break
|
|
|
|
# 매수는 재시도하지 않음 (KRW 금액 계산 복잡도 및 리스크)
|
|
# 하지만 부분 체결된 수량이 있다면 그대로 유지
|
|
if original_side == "bid":
|
|
if filled > 0:
|
|
logger.warning("매수 주문 타임아웃: 부분 체결(%.8f) 완료, 재시도하지 않습니다.", filled)
|
|
else:
|
|
logger.warning("매수 주문 타임아웃: 체결 없음, 재시도하지 않습니다.")
|
|
final_status = "timeout"
|
|
break
|
|
# 매도만 시장가로 재시도
|
|
elif original_side == "ask":
|
|
logger.info("[%s] 취소 확인 후 시장가 매도 재시도", order.get("market"))
|
|
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:
|
|
logger.exception("재시도 주문 중 오류: %s", e)
|
|
final_status = "error"
|
|
break
|
|
else:
|
|
final_status = "timeout"
|
|
break
|
|
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:
|
|
logger.error("주문 모니터링 중단: 연속 에러 %d회 초과", max_consecutive_errors)
|
|
final_status = "error"
|
|
break
|
|
|
|
if time.time() - start > timeout:
|
|
final_status = "error"
|
|
break
|
|
|
|
# 에러 발생 시 잠시 대기 후 재시도
|
|
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,
|
|
"filled_volume": filled,
|
|
"remaining_volume": remaining,
|
|
"last_order": last_order,
|
|
"last_checked": time.time(),
|
|
}
|