테스트 강화 및 코드 품질 개선

This commit is contained in:
2025-12-17 00:01:46 +09:00
parent 37a150bd0d
commit 00c57ddd32
51 changed files with 10670 additions and 217 deletions

View File

@@ -5,6 +5,8 @@ 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
@@ -12,19 +14,25 @@ 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
def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str]:
# 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 키의 유효성을 검증합니다.
Upbit API 키의 유효성을 검증합니다 (LOW-005: 강화된 검증).
Args:
access_key: Upbit 액세스 키
secret_key: Upbit 시크릿 키
check_trade_permission: 주문 권한 검증 여부 (기본값: True)
Returns:
(유효성 여부, 메시지)
@@ -36,7 +44,8 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
try:
upbit = pyupbit.Upbit(access_key, secret_key)
# 간단한 테스트: 잔고 조회
# 1단계: 잔고 조회 (읽기 권한)
balances = upbit.get_balances()
if balances is None:
@@ -46,10 +55,41 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
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)
# 성공: 유효한 키
logger.info(
"[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d", len(balances) if isinstance(balances, list) else 0
)
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:
@@ -63,17 +103,60 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
def adjust_price_to_tick_size(price: float) -> float:
"""
Upbit 호가 단위에 맞춰 가격을 조정합니다.
pyupbit.get_tick_size를 사용하여 실시간 호가 단위를 가져옵니다.
- Decimal 기반으로 계산하여 부동소수점 오차를 최소화합니다.
- pyupbit.get_tick_size 실패 시 원본 가격을 그대로 사용합니다.
"""
try:
tick_size = pyupbit.get_tick_size(price)
adjusted_price = round(price / tick_size) * tick_size
return adjusted_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)
@@ -84,18 +167,32 @@ _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 Exception:
except json.JSONDecodeError:
pending = []
pending.append({"token": token, "order": order, "timestamp": time.time()})
with open(pending_file, "w", encoding="utf-8") as f:
# 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)
except Exception as e:
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()
@@ -232,10 +329,24 @@ def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60)
logger.info("📋 진행 중인 주문 발견: %s (side=%s, volume=%.8f)", order.get("uuid"), side, volume)
return order
# 2. Check done orders (filled) - 최근 주문부터 확인
# 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:
@@ -251,7 +362,7 @@ def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60)
return None
def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
def _has_duplicate_pending_order(upbit, market, side, volume, price=None, lookback_sec=120):
"""
Retry 전에 중복된 미체결/완료된 주문이 있는지 확인합니다.
@@ -287,6 +398,17 @@ def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
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))
@@ -313,6 +435,8 @@ def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) -> dict:
"""
Upbit API를 이용한 매수 주문 (시장가 또는 지정가)
부분 매수 지원: 잔고가 부족하면 가능한 만큼 매수합니다.
"""
from .holdings import get_current_price
@@ -325,9 +449,14 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
logger.error(msg)
return {"error": msg, "status": "failed", "timestamp": time.time()}
allocation_token: str | None = None
try:
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
price = get_current_price(market)
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:
@@ -344,6 +473,57 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
"[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사유: 최소 주문 금액 미만"
@@ -359,7 +539,13 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
"timestamp": time.time(),
}
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
# 슬리피지 적용 - 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(
@@ -377,16 +563,12 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
resp = None
# Retry loop for robust order placement
max_retries = 3
max_retries = ORDER_MAX_RETRIES
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:
raise ValueError(f"Invalid params: price={adjusted_limit_price}, volume={volume}")
# 지정가 매수 (Decimal 기반 계산)
adjusted_limit_price, volume = compute_limit_order_params(amount_krw, limit_price)
if attempt == 1:
logger.info(
@@ -426,7 +608,7 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
logger.warning("[매수 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except requests.exceptions.ReadTimeout:
@@ -455,11 +637,11 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
logger.warning("주문 확인 실패. 재시도합니다.")
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except Exception as e:
# Other exceptions (e.g. ValueError from pyupbit) - do not retry
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()}
@@ -515,11 +697,20 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
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:
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:
"""
@@ -611,7 +802,7 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
)
resp = None
max_retries = 3
max_retries = ORDER_MAX_RETRIES
for attempt in range(1, max_retries + 1):
try:
resp = upbit.sell_market_order(market, amount)
@@ -626,7 +817,7 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
logger.warning("[매도 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except requests.exceptions.ReadTimeout:
logger.warning("[매도 확인] ReadTimeout 발생 (%d/%d). 주문 확인 시도...", attempt, max_retries)
@@ -650,9 +841,9 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
logger.warning("매도 주문 확인 실패. 재시도합니다.")
if attempt == max_retries:
raise
time.sleep(1)
time.sleep(ORDER_RETRY_DELAY)
continue
except Exception as e:
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
logger.error("[매도 실패] 예외 발생: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
@@ -707,22 +898,49 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
except Exception:
logger.debug("매도 주문 모니터링 중 예외 발생", exc_info=True)
return result
except Exception as e:
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) -> dict:
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
if not confirm_via_file:
logger.info("파일 확인 비활성화: 즉시 매도 주문 실행")
# 즉시 매도 조건:
# 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()
@@ -754,6 +972,11 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
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)
@@ -777,7 +1000,7 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
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", {})
@@ -798,7 +1021,11 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
record_trade(trade_record)
# 실전 거래이고, 일부/전부 체결됐다면 holdings에서 수량 차감
# ✅ 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")
@@ -811,7 +1038,9 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
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, indicators: dict = None
) -> dict:
"""
매수 주문 확인 후 실행 (매도와 동일한 확인 메커니즘)
@@ -819,6 +1048,7 @@ def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: Run
symbol: 거래 심볼
amount_krw: 매수할 KRW 금액
cfg: RuntimeConfig 객체
indicators: (Optional) 지표 데이터 (백테스팅용)
Returns:
주문 결과 딕셔너리
@@ -904,7 +1134,7 @@ def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: Run
}
from .signals import record_trade
record_trade(trade_record)
record_trade(trade_record, indicators=indicators)
# 실전 거래이고 타임아웃/부분체결 시 체결된 수량을 holdings에 반영
if not cfg.dry_run and monitor_result: