최초 프로젝트 업로드 (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

759
src/order.py Normal file
View File

@@ -0,0 +1,759 @@
import os
import time
import json
import secrets
import threading
import pyupbit
from .common import logger, MIN_KRW_ORDER, HOLDINGS_FILE, TRADES_FILE, PENDING_ORDERS_FILE
from .notifications import send_telegram
def adjust_price_to_tick_size(price: float) -> float:
"""
Upbit 호가 단위에 맞춰 가격을 조정합니다.
pyupbit.get_tick_size를 사용하여 실시간 호가 단위를 가져옵니다.
"""
try:
tick_size = pyupbit.get_tick_size(price)
adjusted_price = round(price / tick_size) * tick_size
return adjusted_price
except Exception as e:
logger.warning("호가 단위 조정 실패: %s. 원본 가격 사용.", e)
return price
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:
pending = []
if os.path.exists(pending_file):
with open(pending_file, "r", encoding="utf-8") as f:
try:
pending = json.load(f)
except Exception:
pending = []
pending.append({"token": token, "order": order, "timestamp": time.time()})
with open(pending_file, "w", encoding="utf-8") as f:
json.dump(pending, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.exception("pending_orders 기록 실패: %s", e)
_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 load_holdings, get_current_price
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 place_buy_order_upbit(market: str, amount_krw: float, cfg: "RuntimeConfig") -> dict:
"""
Upbit API를 이용한 매수 주문 (시장가 또는 지정가)
Args:
market: 거래 시장 (예: KRW-BTC)
amount_krw: 매수할 KRW 금액
cfg: RuntimeConfig 객체
Returns:
주문 결과 딕셔너리
"""
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 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)
return {"error": msg, "status": "failed", "timestamp": time.time()}
try:
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()}
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
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
# 🔒 안전성 검증: 파라미터 최종 확인
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()}
# 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,
)
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)
resp = upbit.buy_market_order(market, amount_krw)
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",
"amount_krw": amount_krw,
"price": limit_price if slippage_pct > 0 else None,
"status": "placed",
"response": resp,
"timestamp": time.time(),
}
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
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
except Exception:
logger.debug("매수 주문 모니터링 중 예외 발생", exc_info=True)
return result
except Exception as e:
logger.exception("Upbit 매수 주문 실패: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") -> dict:
"""
Upbit API를 이용한 시장가 매도 주문
Args:
market: 거래 시장 (예: KRW-BTC)
amount: 매도할 코인 수량
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)
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(),
}
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(),
}
# ===== 매도 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(),
}
# 매도 전 파라미터 검증 로그 (안전장치)
logger.info(
"🔍 [매도 주문 전 검증] %s | 매도 수량=%.8f개 | 현재가=%.2f KRW | 예상 매도액=%.2f KRW",
market,
amount,
current_price,
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)
result = {
"market": market,
"side": "sell",
"amount": amount,
"status": "placed",
"response": resp,
"timestamp": time.time(),
}
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
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
except Exception:
logger.debug("매도 주문 모니터링 중 예외 발생", exc_info=True)
return result
except Exception 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") -> dict:
"""
매도 주문 확인 후 실행
"""
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_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 = f"<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 += 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,
)
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)
# 실전 거래이고, 일부/전부 체결됐다면 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") -> dict:
"""
매수 주문 확인 후 실행 (매도와 동일한 확인 메커니즘)
Args:
symbol: 거래 심볼
amount_krw: 매수할 KRW 금액
cfg: RuntimeConfig 객체
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 = f"<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 += 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)
# 실전 거래이고 타임아웃/부분체결 시 체결된 수량을 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:
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)
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
while True:
# 전체 타임아웃 체크 (무한 대기 방지)
if time.time() - start > timeout + 30: # 여유 시간 30초
logger.error("주문 모니터링 강제 종료: 전체 타임아웃 초과")
final_status = "timeout"
break
try:
order = upbit.get_order(current_uuid)
consecutive_errors = 0 # 성공 시 에러 카운터 리셋
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)
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 = upbit.cancel_order(current_uuid)
logger.info("[%s] 주문 취소 시도: %s", order.get("market"), cancel_resp)
# 취소가 완전히 처리될 때까지 잠시 대기 및 확인
time.sleep(3) # 거래소 처리 시간 대기
cancelled_order = 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 = 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
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))
return {
"final_status": final_status,
"attempts": attempts,
"filled_volume": filled,
"remaining_volume": remaining,
"last_order": last_order,
"last_checked": time.time(),
}