업데이트

This commit is contained in:
2025-12-09 21:39:23 +09:00
parent dd9acf62a3
commit 37a150bd0d
35 changed files with 5587 additions and 493 deletions

78
src/circuit_breaker.py Normal file
View File

@@ -0,0 +1,78 @@
# src/circuit_breaker.py
"""Simple circuit breaker for external API calls.
States:
- closed: calls pass through
- open: calls fail fast until cool-down
- half_open: allow a single probe; if success -> closed, else -> open
"""
import time
from collections.abc import Callable
from .common import logger
class CircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: float = 30.0,
half_open_max_attempts: int = 1,
) -> None:
self.failure_threshold = max(1, failure_threshold)
self.recovery_timeout = float(recovery_timeout)
self.half_open_max_attempts = max(1, half_open_max_attempts)
self._state = "closed"
self._fail_count = 0
self._opened_at: float | None = None
self._half_open_attempts = 0
@property
def state(self) -> str:
return self._state
def can_call(self) -> bool:
now = time.time()
if self._state == "open":
if self._opened_at and (now - self._opened_at) >= self.recovery_timeout:
# move to half-open and allow probe
self._state = "half_open"
self._half_open_attempts = 0
return True
return False
return True
def on_success(self) -> None:
self._fail_count = 0
self._state = "closed"
self._opened_at = None
self._half_open_attempts = 0
def on_failure(self) -> None:
if self._state == "half_open":
self._half_open_attempts += 1
# first failure in half-open -> open
self._state = "open"
self._opened_at = time.time()
logger.warning("[CB] Half-open failure -> OPEN (cooldown %.0fs)", self.recovery_timeout)
return
# closed state failure
self._fail_count += 1
if self._fail_count >= self.failure_threshold:
self._state = "open"
self._opened_at = time.time()
logger.error(
"[CB] Failure threshold reached (%d). OPEN for %.0fs.", self.failure_threshold, self.recovery_timeout
)
def call(self, func: Callable, *args, **kwargs):
if not self.can_call():
raise RuntimeError("CircuitBreaker OPEN: call blocked")
try:
result = func(*args, **kwargs)
self.on_success()
return result
except Exception:
self.on_failure()
raise

View File

@@ -1,9 +1,9 @@
import os
import logging
from pathlib import Path
import logging.handlers
import gzip
import logging
import logging.handlers
import os
import shutil
from pathlib import Path
LOG_DIR = os.getenv("LOG_DIR", "logs")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -56,12 +56,15 @@ def setup_logger(dry_run: bool):
Log Rotation Strategy:
- Size-based: 10MB per file, keep 7 backups (total ~80MB)
- Time-based: Daily rotation, keep 30 days
- Compression: Old logs are gzipped (saves ~70% space)
Log Levels (production recommendation):
- dry_run=True: INFO (development/testing)
- dry_run=False: WARNING (production - only important events)
- dry_run=False: INFO (production - retain important trading logs)
⚠️ CRITICAL: Production mode uses INFO level to ensure trading events are logged.
This is essential for auditing buy/sell orders and debugging issues.
For high-volume environments, adjust LOG_LEVEL via environment variable.
"""
global logger, _logger_configured
if _logger_configured:
@@ -69,8 +72,9 @@ def setup_logger(dry_run: bool):
logger.handlers.clear()
# Use WARNING level for production, INFO for development
effective_level = getattr(logging, LOG_LEVEL, logging.INFO if dry_run else logging.WARNING)
# Use INFO level for both dry_run and production to ensure trading events are logged
# Production systems can override via LOG_LEVEL environment variable if needed
effective_level = getattr(logging, LOG_LEVEL, logging.INFO)
logger.setLevel(effective_level)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s")
@@ -82,30 +86,22 @@ def setup_logger(dry_run: bool):
ch.setFormatter(formatter)
logger.addHandler(ch)
# Size-based rotating file handler with compression
# Size-based rotating file handler with compression (only one rotation strategy)
fh_size = CompressedRotatingFileHandler(
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=7, encoding="utf-8" # 10MB per file # Keep 7 backups
LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=7,
encoding="utf-8", # 10MB per file # Keep 7 backups
)
fh_size.setLevel(effective_level)
fh_size.setFormatter(formatter)
logger.addHandler(fh_size)
# Time-based rotating file handler (daily rotation, keep 30 days)
daily_log_file = os.path.join(LOG_DIR, "AutoCoinTrader_daily.log")
fh_time = logging.handlers.TimedRotatingFileHandler(
daily_log_file, when="midnight", interval=1, backupCount=30, encoding="utf-8"
)
fh_time.setLevel(effective_level)
fh_time.setFormatter(formatter)
fh_time.suffix = "%Y-%m-%d" # Add date suffix to rotated files
logger.addHandler(fh_time)
_logger_configured = True
logger.info(
"[SYSTEM] 로그 설정 완료: level=%s, size_rotation=%dMB×%d, daily_rotation=%d",
"[SYSTEM] 로그 설정 완료: level=%s, size_rotation=%dMB×%d (일별 로테이션 제거됨)",
logging.getLevelName(effective_level),
10,
7,
30,
)

View File

@@ -1,7 +1,17 @@
import os, json, pyupbit
from .common import logger, MIN_TRADE_AMOUNT, FLOAT_EPSILON, HOLDINGS_FILE
from .retry_utils import retry_with_backoff
from __future__ import annotations
import json
import os
import threading
from typing import TYPE_CHECKING
import pyupbit
from .common import FLOAT_EPSILON, HOLDINGS_FILE, MIN_TRADE_AMOUNT, logger
from .retry_utils import retry_with_backoff
if TYPE_CHECKING:
from .config import RuntimeConfig
# 부동소수점 비교용 임계값 (MIN_TRADE_AMOUNT와 동일한 용도)
EPSILON = FLOAT_EPSILON
@@ -16,7 +26,7 @@ def _load_holdings_unsafe(holdings_file: str) -> dict[str, dict]:
if os.path.getsize(holdings_file) == 0:
logger.debug("[DEBUG] 보유 파일이 비어있습니다: %s", holdings_file)
return {}
with open(holdings_file, "r", encoding="utf-8") as f:
with open(holdings_file, encoding="utf-8") as f:
return json.load(f)
return {}
@@ -74,7 +84,22 @@ def save_holdings(holdings: dict[str, dict], holdings_file: str = HOLDINGS_FILE)
raise # 호출자가 저장 실패를 인지하도록 예외 재발생
def get_upbit_balances(cfg: "RuntimeConfig") -> dict | None:
def get_upbit_balances(cfg: RuntimeConfig) -> dict | None:
"""
Upbit API를 통해 현재 잔고를 조회합니다.
Args:
cfg: RuntimeConfig 객체 (Upbit API 키 포함)
Returns:
심볼별 잔고 딕셔너리 (예: {"BTC": 0.5, "ETH": 10.0, "KRW": 1000000})
- MIN_TRADE_AMOUNT (1e-8) 이하의 자산은 제외됨
- API 키 미설정 시 빈 딕셔너리 {} 반환
- 네트워크 오류 또는 API 오류 시 None 반환
Raises:
Exception: Upbit API 호출 중 발생한 예외는 로깅되고 None 반환
"""
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("API 키 없음 - 빈 balances")
@@ -105,11 +130,27 @@ def get_upbit_balances(cfg: "RuntimeConfig") -> dict | None:
def get_current_price(symbol: str) -> float:
"""
주어진 심볼의 현재가를 Upbit에서 조회합니다.
Args:
symbol: 거래 심볼 (예: "BTC", "KRW-BTC", "eth")
"KRW-" 접두사 유무 자동 처리
Returns:
현재가 (KRW 기준, float 타입)
- 조회 실패 또는 API 오류 시 0.0 반환
Note:
- 조회 실패 시 WARNING 레벨로 로깅됨
- 심볼 대소문자 자동 정규화 (예: "btc""KRW-BTC")
- 재시도 로직 없음 (상위 함수에서 재시도 처리 권장)
"""
try:
if symbol.upper().startswith("KRW-"):
market = symbol.upper()
else:
market = f"KRW-{symbol.replace('KRW-','').upper()}"
market = f"KRW-{symbol.replace('KRW-', '').upper()}"
# 실시간 현재가(ticker)를 조회하도록 변경
price = pyupbit.get_current_price(market)
logger.debug("[DEBUG] 현재가 %s -> %.2f", market, price)
@@ -269,7 +310,28 @@ def set_holding_field(symbol: str, key: str, value, holdings_file: str = HOLDING
@retry_with_backoff(max_attempts=3, base_delay=2.0, max_delay=10.0, exceptions=(Exception,))
def fetch_holdings_from_upbit(cfg: "RuntimeConfig") -> dict | None:
def fetch_holdings_from_upbit(cfg: RuntimeConfig) -> dict | None:
"""
Upbit API에서 현재 보유 자산 정보를 조회하고, 로컬 상태 정보와 병합합니다.
Args:
cfg: RuntimeConfig 객체 (Upbit API 키 포함)
Returns:
심볼별 보유 정보 딕셔너리 (예: {"KRW-BTC": {...}, "KRW-ETH": {...}})
- 각 심볼: {"buy_price": float, "amount": float, "max_price": float, "buy_timestamp": null}
- API 키 미설정 시 빈 딕셔너리 {} 반환
- 네트워크 오류 또는 API 오류 시 None 반환 (상위 함수에서 재시도)
Behavior:
- Upbit API에서 잔고 정보 조회 (amount, buy_price 등)
- 기존 로컬 holdings.json의 max_price는 유지 (매도 조건 판정 용)
- 잔고 0 또는 MIN_TRADE_AMOUNT 미만 자산은 제외
- buy_price 필드 우선순위: avg_buy_price_krw > avg_buy_price
Decorator:
@retry_with_backoff: 3회 지수 백오프 재시도 (2s → 4s → 8s)
"""
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("[DEBUG] API 키 없어 Upbit holdings 사용 안함")
@@ -331,3 +393,75 @@ def fetch_holdings_from_upbit(cfg: "RuntimeConfig") -> dict | None:
except Exception as e:
logger.error("[ERROR] fetch_holdings 실패: %s", e)
return None
def backup_holdings(holdings_file: str = HOLDINGS_FILE) -> str | None:
"""
holdings.json 파일의 백업을 생성합니다 (선택사항 - 복구 전략).
Args:
holdings_file: 백업할 보유 파일 경로
Returns:
생성된 백업 파일 경로 (예: data/holdings.json.backup_20251204_120530)
백업 실패 시 None 반환
Note:
- 백업 파일명: holdings.json.backup_YYYYMMDD_HHMMSS
- 원본 파일이 없으면 None 반환
- 파일 손상 복구 시 수동으로 백업 파일을 원본 위치에 복사
"""
try:
if not os.path.exists(holdings_file):
logger.warning("[WARNING] 백업 대상 파일이 없습니다: %s", holdings_file)
return None
import shutil
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = f"{holdings_file}.backup_{timestamp}"
shutil.copy2(holdings_file, backup_file)
logger.info("[INFO] Holdings 백업 생성: %s", backup_file)
return backup_file
except Exception as e:
logger.error("[ERROR] Holdings 백업 생성 실패: %s", e)
return None
def restore_holdings_from_backup(backup_file: str, restore_to: str = HOLDINGS_FILE) -> bool:
"""
백업 파일에서 holdings.json을 복구합니다 (선택사항 - 복구 전략).
Args:
backup_file: 백업 파일 경로 (예: data/holdings.json.backup_20251204_120530)
restore_to: 복구 대상 경로 (기본값: HOLDINGS_FILE)
Returns:
복구 성공 여부 (True/False)
Note:
- 복구 전에 원본 파일이 백업됨
- 복구 중 오류 발생 시 원본 파일은 손상되지 않음
- 원래 상태로 되돌리려면 복구 전 백업 파일을 확인하세요
"""
try:
if not os.path.exists(backup_file):
logger.error("[ERROR] 백업 파일이 없습니다: %s", backup_file)
return False
# 현재 파일을 먼저 백업 (이중 백업)
if os.path.exists(restore_to):
double_backup = backup_holdings(restore_to)
logger.info("[INFO] 복구 전 현재 파일 백업: %s", double_backup)
import shutil
# 복구
shutil.copy2(backup_file, restore_to)
logger.info("[INFO] Holdings 복구 완료: %s -> %s", backup_file, restore_to)
return True
except Exception as e:
logger.error("[ERROR] Holdings 복구 실패: %s", e)
return False

40
src/metrics.py Normal file
View File

@@ -0,0 +1,40 @@
# src/metrics.py
"""Lightweight metrics collection: counters and timers to JSON file."""
import json
import os
import time
from .common import LOG_DIR
METRICS_FILE = os.path.join(LOG_DIR, "metrics.json")
class Metrics:
def __init__(self) -> None:
self._counters: dict[str, int] = {}
self._timers: dict[str, float] = {}
def inc(self, key: str, n: int = 1) -> None:
self._counters[key] = self._counters.get(key, 0) + n
def observe(self, key: str, value: float) -> None:
# Store last observed value
self._timers[key] = float(value)
def dump(self) -> None:
os.makedirs(LOG_DIR, exist_ok=True)
payload = {
"ts": time.time(),
"counters": self._counters,
"timers": self._timers,
}
try:
with open(METRICS_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
except Exception:
# Metrics should never crash app
pass
metrics = Metrics()

View File

@@ -1,7 +1,8 @@
import os
import threading
import time
import requests
from .common import logger
__all__ = ["send_telegram", "send_telegram_with_retry", "report_error", "send_startup_test_message"]
@@ -53,6 +54,7 @@ def send_telegram_with_retry(
def send_telegram(token: str, chat_id: str, text: str, add_thread_prefix: bool = True, parse_mode: str = None):
"""
텔레그램 메시지를 한 번 전송합니다. 실패 시 예외를 발생시킵니다.
WARNING: 이 함수는 예외 처리가 없으므로, 프로덕션에서는 send_telegram_with_retry() 사용 권장
"""
if add_thread_prefix:
thread_name = threading.current_thread().name
@@ -70,10 +72,15 @@ def send_telegram(token: str, chat_id: str, text: str, add_thread_prefix: bool =
payload["parse_mode"] = parse_mode
try:
resp = requests.post(url, json=payload, timeout=10)
# ⚠️ 타임아웃 증가 (20초): SSL handshake 느림 대비
resp = requests.post(url, json=payload, timeout=20)
resp.raise_for_status() # 2xx 상태 코드가 아니면 HTTPError 발생
logger.debug("텔레그램 메시지 전송 성공: %s", text[:80])
return True
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
# 네트워크 오류: 로깅하고 예외 발생
logger.warning("텔레그램 네트워크 오류 (타임아웃/연결): %s", e)
raise
except requests.exceptions.RequestException as e:
logger.warning("텔레그램 API 요청 실패: %s", e)
raise # 예외를 다시 발생시켜 호출자가 처리하도록 함

View File

@@ -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,

View File

@@ -1,18 +1,16 @@
import json
import os
import time
import json
import inspect
from typing import List
from datetime import UTC, datetime
import pandas as pd
import pandas_ta as ta
from datetime import datetime
from .common import logger, FLOAT_EPSILON, HOLDINGS_FILE, TRADES_FILE
from .indicators import fetch_ohlcv, compute_macd_hist, compute_sma, DataFetchError
from .holdings import fetch_holdings_from_upbit, get_current_price
from .notifications import send_telegram, send_telegram_with_retry
from .common import FLOAT_EPSILON, HOLDINGS_FILE, TRADES_FILE, logger
from .config import RuntimeConfig # 테스트 환경에서 NameError 방지
from .holdings import get_current_price
from .indicators import DataFetchError, compute_sma, fetch_ohlcv
from .notifications import send_telegram
def make_trade_record(symbol, side, amount_krw, dry_run, price=None, status="simulated"):
@@ -41,16 +39,29 @@ def make_trade_record(symbol, side, amount_krw, dry_run, price=None, status="sim
def evaluate_sell_conditions(
current_price: float, buy_price: float, max_price: float, holding_info: dict, config: dict = None
) -> dict:
"""
매도 조건을 평가하고 매도 신호 및 매도 비율을 반환합니다.
매도 전략 (4시간봉 기준):
1. 매수가 대비 -5% 하락 시 전량 매도 (무조건 손절)
2. 저수익 구간 (수익률 <= 10%): 최고점 대비 5% 하락 시 전량 매도 (트레일링)
3. 수익률 10% 달성 시 50% 매도 (1회 제한, partial_sell_done 플래그)
4. 중간 수익 구간 (10% < 수익률 <= 30%):
- 수익률이 10% 이하로 떨어지면 전량 매도 (최소 수익률 10% 유지)
- 또는 최고점 대비 5% 하락 시 전량 매도 (트레일링)
5. 고수익 구간 (수익률 > 30%):
- 수익률이 30% 이하로 떨어지면 전량 매도 (최소 수익률 30% 유지)
- 또는 최고점 대비 15% 하락 시 전량 매도 (트레일링)
6. 고수익 구간에서 위 조건 미충족 시 보유 유지
"""
config = config or {}
# auto_trade 설정에서 매도 조건 설정값 로드
auto_trade_config = config.get("auto_trade", {})
loss_threshold = float(auto_trade_config.get("loss_threshold", -5.0)) # 1. 초기 손절 라인
profit_threshold_1 = float(auto_trade_config.get("profit_threshold_1", 10.0)) # 3. 부분 익절 시작 수익률
profit_threshold_2 = float(
auto_trade_config.get("profit_threshold_2", 30.0)
) # 5. 전량 익절 기준 수익률 (높은 구간)
drawdown_1 = float(auto_trade_config.get("drawdown_1", 5.0)) # 2, 4. 트레일링 스탑 하락률
drawdown_2 = float(auto_trade_config.get("drawdown_2", 15.0)) # 5. 트레일링 스탑 하락률 (높은 구간)
loss_threshold = float(auto_trade_config.get("loss_threshold", -5.0)) # 조건1: -5% 손절
profit_threshold_1 = float(auto_trade_config.get("profit_threshold_1", 10.0)) # 조건3,4,5: 10% 기준
profit_threshold_2 = float(auto_trade_config.get("profit_threshold_2", 30.0)) # 조건5: 30% 기준
drawdown_1 = float(auto_trade_config.get("drawdown_1", 5.0)) # 조건2,4: 5% 트레일링
drawdown_2 = float(auto_trade_config.get("drawdown_2", 15.0)) # 조건5: 15% 트레일링
# 현재 수익률 및 최고점 대비 하락률 계산 (엡실론 기반 안전한 비교)
profit_rate = ((current_price - buy_price) / buy_price) * 100 if buy_price > FLOAT_EPSILON else 0
@@ -63,19 +74,32 @@ def evaluate_sell_conditions(
"profit_rate": profit_rate,
"max_drawdown": max_drawdown,
"set_partial_sell_done": False,
"debug_info": { # 디버그용 상세 정보
"buy_price": buy_price,
"current_price": current_price,
"max_price": max_price,
"loss_price_5pct": buy_price * (1 - 0.05), # -5% 손절가
"profit_price_10pct": buy_price * (1 + profit_threshold_1 / 100), # 10% 익절가
"profit_price_30pct": buy_price * (1 + profit_threshold_2 / 100), # 30% 익절가
"max_profit_rate": ((max_price - buy_price) / buy_price) * 100 if buy_price > FLOAT_EPSILON else 0,
},
}
# 매도조건 1: 무조건 손절 (매수가 대비 -5% 하락)
if profit_rate <= loss_threshold:
result.update(status="stop_loss", sell_ratio=1.0)
result["reasons"].append(f"손절(조건1): 수익률 {profit_rate:.2f}% <= {loss_threshold}%")
result["reasons"].append(
f"손절(조건1): 매수가 {buy_price:.2f} → 현재 {current_price:.2f} (수익률 {profit_rate:.2f}% <= {loss_threshold}%)"
)
return result
# 매도조건 3: 수익률 10% 이상 도달 시 1회성 절반 매도
partial_sell_done = holding_info.get("partial_sell_done", False)
if not partial_sell_done and profit_rate >= profit_threshold_1:
result.update(status="stop_loss", sell_ratio=0.5)
result["reasons"].append(f"부분 익절(조건3): 수익률 {profit_rate:.2f}% 달성, 50% 매도")
result["reasons"].append(
f"부분 익절(조건3): 매수가 {buy_price:.2f} → 현재 {current_price:.2f} (수익률 {profit_rate:.2f}% >= {profit_threshold_1}%) 50% 매도"
)
result["set_partial_sell_done"] = True
return result
@@ -89,14 +113,14 @@ def evaluate_sell_conditions(
if profit_rate <= profit_threshold_2:
result.update(status="stop_loss", sell_ratio=1.0)
result["reasons"].append(
f"수익률 보호(조건5): 최고 수익률({max_profit_rate:.2f}%) {profit_rate:.2f}%로 하락 (<= {profit_threshold_2}%)"
f"수익률 보호(조건5-2): 최고{max_price:.2f}(최고수익 {max_profit_rate:.2f}%) → 현재 {current_price:.2f}(현재수익 {profit_rate:.2f}% <= {profit_threshold_2}%)"
)
return result
# 5-1: 기준선 위에서 최고점 대비 큰 하락 발생 시 익절 (트레일링)
if max_drawdown <= -drawdown_2:
result.update(status="profit_taking", sell_ratio=1.0)
result["reasons"].append(
f"트레일링 익절(조건5): 최고 수익률({max_profit_rate:.2f}%) 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_2}%)"
f"트레일링 익절(조건5-1): 최고{max_price:.2f}(최고수익 {max_profit_rate:.2f}%) → 현재 {current_price:.2f}(최고점대비 {abs(max_drawdown):.2f}% 하락 >= {drawdown_2}% 기준)"
)
return result
@@ -106,14 +130,14 @@ def evaluate_sell_conditions(
if profit_rate <= profit_threshold_1:
result.update(status="stop_loss", sell_ratio=1.0)
result["reasons"].append(
f"수익률 보호(조건4): 최고 수익률({max_profit_rate:.2f}%) {profit_rate:.2f}%로 하락 (<= {profit_threshold_1}%)"
f"수익률 보호(조건4-2): 최고{max_price:.2f}(최고수익 {max_profit_rate:.2f}%) → 현재 {current_price:.2f}(현재수익 {profit_rate:.2f}% <= {profit_threshold_1}%)"
)
return result
# 4-1: 수익률이 기준선 위에서 최고점 대비 하락률이 임계 초과 시 익절
if profit_rate > profit_threshold_1 and max_drawdown <= -drawdown_1:
result.update(status="profit_taking", sell_ratio=1.0)
result["reasons"].append(
f"트레일링 익절(조건4): 최고 수익률({max_profit_rate:.2f}%) 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_1}%)"
f"트레일링 익절(조건4-1): 최고{max_price:.2f}(최고수익 {max_profit_rate:.2f}%) → 현재 {current_price:.2f}(최고점대비 {abs(max_drawdown):.2f}% 하락 >= {drawdown_1}% 기준)"
)
return result
@@ -123,7 +147,7 @@ def evaluate_sell_conditions(
if max_drawdown <= -drawdown_1:
result.update(status="profit_taking", sell_ratio=1.0)
result["reasons"].append(
f"트레일링 익절(조건2): 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_1}%)"
f"트레일링 익절(조건2): 매수가 {buy_price:.2f} → 최고 {max_price:.2f} → 현재 {current_price:.2f}(최고점대비 {abs(max_drawdown):.2f}% 하락 >= {drawdown_1}% 기준)"
)
return result
@@ -169,7 +193,7 @@ def _adjust_sell_ratio_for_min_order(
if not (0 < sell_ratio < 1):
return sell_ratio
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
auto_trade_cfg = config.get("auto_trade", {})
min_order_value = float(auto_trade_cfg.get("min_order_value_krw", 5000))
@@ -218,7 +242,7 @@ def record_trade(trade: dict, trades_file: str = TRADES_FILE, critical: bool = T
if os.path.exists(trades_file):
# 파일 읽기 (with 블록 종료 후 파일 핸들 자동 닫힘)
try:
with open(trades_file, "r", encoding="utf-8") as f:
with open(trades_file, encoding="utf-8") as f:
trades = json.load(f)
except json.JSONDecodeError as e:
# with 블록 밖에서 파일 핸들이 닫힌 후 백업 시도
@@ -256,14 +280,14 @@ def _update_df_with_realtime_price(df: pd.DataFrame, symbol: str, timeframe: str
진행 중인 마지막 캔들 데이터를 실시간 현재가로 업데이트합니다.
"""
try:
from datetime import datetime, timezone
from datetime import datetime
current_price = get_current_price(symbol)
if not (current_price > 0 and df is not None and not df.empty):
return df
last_candle_time = df.index[-1]
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
# 봉 주기를 초 단위로 변환
interval_seconds = 0
@@ -276,7 +300,7 @@ def _update_df_with_realtime_price(df: pd.DataFrame, symbol: str, timeframe: str
if interval_seconds > 0:
if last_candle_time.tzinfo is None:
last_candle_time = last_candle_time.tz_localize(timezone.utc)
last_candle_time = last_candle_time.tz_localize(UTC)
next_candle_time = last_candle_time + pd.Timedelta(seconds=interval_seconds)
@@ -343,7 +367,21 @@ def _prepare_data_and_indicators(
def _evaluate_buy_conditions(data: dict) -> dict:
"""계산된 지표를 바탕으로 매수 조건을 평가하고 원시 데이터를 반환합니다."""
"""
매수 조건을 평가합니다 (4시간봉 기준).
매수 전략:
1. MACD가 신호선 또는 0을 상향 돌파 + SMA5 > SMA200 + ADX > 25 → 매수조건1
2. SMA5가 SMA200을 상향 돌파 + MACD > 신호선 + ADX > 25 → 매수조건2
3. ADX가 25를 상향 돌파 + SMA5 > SMA200 + MACD > 신호선 → 매수조건3
반환:
{
"matches": ["매수조건1", "매수조건2", ...], # 발생한 매수 신호 리스트
"data_points": {...}, # 계산된 지표 값
"conditions": {...} # 각 기본 조건 boolean
}
"""
if not data or len(data.get("macd_line", [])) < 2 or len(data.get("signal_line", [])) < 2:
return {"matches": [], "data_points": {}}
@@ -438,7 +476,8 @@ def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
return None
# 포매팅 헬퍼
fmt_val = lambda v, p: f"{v:.{p}f}" if v is not None else "N/A"
def fmt_val(value, precision):
return f"{value:.{precision}f}" if value is not None else "N/A"
# 메시지 생성
text = f"매수 신호발생: {symbol} -> {', '.join(evaluation['matches'])}\n가격: {close_price:.8f}\n"
@@ -521,12 +560,40 @@ def _process_symbol_core(symbol: str, cfg: "RuntimeConfig", indicators: dict = N
if evaluation["data_points"]:
dp = evaluation["data_points"]
c = evaluation["conditions"]
result["summary"].append(f"[조건1 {'충족' if c['macd_cross_ok'] and c['sma_condition'] else '미충족'}]")
adx_threshold = data.get("indicators_config", {}).get("adx_threshold", 25)
# 상세 지표값 로그
result["summary"].append(
f"[조건2 {'충족' if c['cross_sma'] and c['macd_above_signal'] and c['adx_ok'] else '미충족'}]"
f"[지표값] MACD: {dp.get('curr_macd', 0):.6f} | Signal: {dp.get('curr_signal', 0):.6f} | "
f"SMA5: {dp.get('curr_sma_short', 0):.2f} | SMA200: {dp.get('curr_sma_long', 0):.2f} | "
f"ADX: {dp.get('curr_adx', 0):.2f} (기준: {adx_threshold})"
)
# 조건1: MACD 상향 + SMA + ADX
cond1_macd = f"MACD: {dp.get('prev_macd', 0):.6f}->{dp.get('curr_macd', 0):.6f}, Sig: {dp.get('prev_signal', 0):.6f}->{dp.get('curr_signal', 0):.6f}"
cond1_sma = f"SMA: {dp.get('curr_sma_short', 0):.2f} > {dp.get('curr_sma_long', 0):.2f}"
cond1_adx = f"ADX: {dp.get('curr_adx', 0):.2f} > {adx_threshold}"
result["summary"].append(
f"[조건3 {'충족' if c['cross_adx'] and c['sma_condition'] and c['macd_above_signal'] else '미충족'}]"
f"[조건1 {'충족' if c['macd_cross_ok'] and c['sma_condition'] and c['adx_ok'] else '미충족'}] "
f"{cond1_macd} | {cond1_sma} | {cond1_adx}"
)
# 조건2: SMA 골든크로스 + MACD + ADX
cond2_sma = f"SMA: {dp.get('prev_sma_short', 0):.2f}->{dp.get('curr_sma_short', 0):.2f} cross {dp.get('prev_sma_long', 0):.2f}->{dp.get('curr_sma_long', 0):.2f}"
cond2_macd = f"MACD: {dp.get('curr_macd', 0):.6f} > Sig: {dp.get('curr_signal', 0):.6f}"
cond2_adx = f"ADX: {dp.get('curr_adx', 0):.2f} > {adx_threshold}"
result["summary"].append(
f"[조건2 {'충족' if c['cross_sma'] and c['macd_above_signal'] and c['adx_ok'] else '미충족'}] "
f"{cond2_sma} | {cond2_macd} | {cond2_adx}"
)
# 조건3: ADX 상향 + SMA + MACD
cond3_adx = f"ADX: {dp.get('prev_adx', 0):.2f}->{dp.get('curr_adx', 0):.2f} cross {adx_threshold}"
cond3_sma = f"SMA: {dp.get('curr_sma_short', 0):.2f} > {dp.get('curr_sma_long', 0):.2f}"
cond3_macd = f"MACD: {dp.get('curr_macd', 0):.6f} > Sig: {dp.get('curr_signal', 0):.6f}"
result["summary"].append(
f"[조건3 {'충족' if c['cross_adx'] and c['sma_condition'] and c['macd_above_signal'] else '미충족'}] "
f"{cond3_adx} | {cond3_sma} | {cond3_macd}"
)
if evaluation["matches"]:
@@ -665,7 +732,6 @@ def _process_sell_decision(
order_status = sell_order_result.get("status")
if order_status in ("skipped_too_small", "failed", "user_not_confirmed"):
error_msg = sell_order_result.get("error", "알 수 없는 오류")
reason = sell_order_result.get("reason", "")
estimated_value = sell_order_result.get("estimated_value", 0)
if telegram_token and telegram_chat_id:
@@ -748,11 +814,16 @@ def _check_sell_logic(holdings: dict, cfg: RuntimeConfig, config: dict, check_ty
sell_result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info, config)
debug_info = sell_result.get("debug_info", {})
log_msg = (
f"[{symbol}] {check_type} 검사 - "
f"현재가: {current_price:.2f}, 매수가: {buy_price:.2f}, 최고가: {max_price:.2f}, "
f"수익률: {sell_result['profit_rate']:.2f}%, 최고점대비: {sell_result['max_drawdown']:.2f}%, "
f"상태: {sell_result['status']} (비율: {sell_result['sell_ratio']*100:.0f}%)"
f"[{symbol}] {check_type} 검사\n"
f" 매수가: {buy_price:.2f} | 현재가: {current_price:.2f} | 최고가: {max_price:.2f}\n"
f" 손절가(-5%): {debug_info.get('loss_price_5pct', 0):.2f} | "
f"익절가(10%): {debug_info.get('profit_price_10pct', 0):.2f} | "
f"익절가(30%): {debug_info.get('profit_price_30pct', 0):.2f}\n"
f" 현재수익률: {sell_result['profit_rate']:.2f}% | 최고수익률: {debug_info.get('max_profit_rate', 0):.2f}% | "
f"최고점대비: {sell_result['max_drawdown']:.2f}%\n"
f" 판정: {sell_result['status']} (매도비율: {sell_result['sell_ratio'] * 100:.0f}%)"
)
logger.info(log_msg)

View File

@@ -0,0 +1,119 @@
# src/tests/test_circuit_breaker.py
"""Unit tests for circuit breaker."""
import time
import pytest
from src.circuit_breaker import CircuitBreaker
class TestCircuitBreaker:
def test_initial_state_is_closed(self):
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10.0)
assert cb.state == "closed"
assert cb.can_call() is True
def test_transitions_to_open_after_threshold(self):
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10.0)
# First 2 failures stay closed
cb.on_failure()
assert cb.state == "closed"
cb.on_failure()
assert cb.state == "closed"
# Third failure -> open
cb.on_failure()
assert cb.state == "open"
assert cb.can_call() is False
def test_open_to_half_open_after_timeout(self):
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1)
# Trigger open
cb.on_failure()
cb.on_failure()
assert cb.state == "open"
# Wait for recovery
time.sleep(0.15)
# Should allow probe
assert cb.can_call() is True
assert cb.state == "half_open"
def test_half_open_success_closes_circuit(self):
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1)
# Open
cb.on_failure()
cb.on_failure()
time.sleep(0.15)
# Move to half-open
cb.can_call()
assert cb.state == "half_open"
# Success closes
cb.on_success()
assert cb.state == "closed"
def test_half_open_failure_reopens_circuit(self):
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1)
# Open
cb.on_failure()
cb.on_failure()
time.sleep(0.15)
# Move to half-open
cb.can_call()
assert cb.state == "half_open"
# Failure reopens
cb.on_failure()
assert cb.state == "open"
assert cb.can_call() is False
def test_call_wrapper_success(self):
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10.0)
def mock_func(x):
return x * 2
result = cb.call(mock_func, 5)
assert result == 10
assert cb.state == "closed"
def test_call_wrapper_failure(self):
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=10.0)
def mock_func():
raise ValueError("API error")
# First failure
with pytest.raises(ValueError):
cb.call(mock_func)
assert cb.state == "closed"
# Second failure -> open
with pytest.raises(ValueError):
cb.call(mock_func)
assert cb.state == "open"
def test_call_blocked_when_open(self):
cb = CircuitBreaker(failure_threshold=1, recovery_timeout=10.0)
def mock_func():
raise ValueError("error")
# Trigger open
with pytest.raises(ValueError):
cb.call(mock_func)
assert cb.state == "open"
# Next call blocked
with pytest.raises(RuntimeError, match="CircuitBreaker OPEN"):
cb.call(mock_func)

245
src/tests/test_order.py Normal file
View File

@@ -0,0 +1,245 @@
"""Unit tests for order.py - order placement and validation."""
from unittest.mock import MagicMock, Mock, patch
from src.common import MIN_KRW_ORDER
from src.order import (
adjust_price_to_tick_size,
place_buy_order_upbit,
place_sell_order_upbit,
)
class TestAdjustPriceToTickSize:
"""Test price adjustment to Upbit tick size."""
def test_adjust_price_with_valid_price(self):
"""Test normal price adjustment."""
with patch("src.order.pyupbit.get_tick_size", return_value=1000):
result = adjust_price_to_tick_size(50000000)
assert result > 0
assert result % 1000 == 0
def test_adjust_price_returns_original_on_error(self):
"""Test fallback to original price on API error."""
with patch("src.order.pyupbit.get_tick_size", side_effect=Exception("API error")):
result = adjust_price_to_tick_size(50000000)
assert result == 50000000
class TestPlaceBuyOrderValidation:
"""Test buy order validation (dry-run mode)."""
def test_buy_order_dry_run(self):
"""Test dry-run buy order simulation."""
cfg = Mock()
cfg.dry_run = True
cfg.config = {}
with patch("src.holdings.get_current_price", return_value=50000000):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
assert result["status"] == "simulated"
assert result["market"] == "KRW-BTC"
assert result["amount_krw"] == 100000
def test_buy_order_below_min_amount(self):
"""Test buy order rejected for amount below minimum."""
cfg = Mock()
cfg.dry_run = True
cfg.config = {"auto_trade": {"min_order_value_krw": MIN_KRW_ORDER}}
with patch("src.holdings.get_current_price", return_value=50000000):
# Try to buy with amount below minimum
result = place_buy_order_upbit("KRW-BTC", MIN_KRW_ORDER - 1000, cfg)
assert result["status"] == "skipped_too_small"
assert result["reason"] == "min_order_value"
def test_buy_order_zero_price(self):
"""Test buy order rejected when current price is 0 or invalid."""
cfg = Mock()
cfg.dry_run = True
cfg.config = {}
with patch("src.holdings.get_current_price", return_value=0):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
assert result["status"] == "failed"
assert "error" in result
def test_buy_order_no_api_key(self):
"""Test buy order fails gracefully without API keys."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = None
cfg.upbit_secret_key = None
cfg.config = {}
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
assert result["status"] == "failed"
assert "error" in result
class TestPlaceSellOrderValidation:
"""Test sell order validation (dry-run mode)."""
def test_sell_order_dry_run(self):
"""Test dry-run sell order simulation."""
cfg = Mock()
cfg.dry_run = True
result = place_sell_order_upbit("KRW-BTC", 0.01, cfg)
assert result["status"] == "simulated"
assert result["market"] == "KRW-BTC"
assert result["amount"] == 0.01
def test_sell_order_invalid_amount(self):
"""Test sell order rejected for invalid amount."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = "key"
cfg.upbit_secret_key = "secret"
cfg.config = {"auto_trade": {"min_order_value_krw": MIN_KRW_ORDER}}
result = place_sell_order_upbit("KRW-BTC", 0, cfg)
assert result["status"] == "failed"
assert result["error"] == "invalid_amount"
def test_sell_order_below_min_value(self):
"""Test sell order rejected when estimated value below minimum."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = "key"
cfg.upbit_secret_key = "secret"
cfg.config = {"auto_trade": {"min_order_value_krw": MIN_KRW_ORDER}}
# Current price very low so amount * price < min_order_value
with patch("src.holdings.get_current_price", return_value=100):
# 0.001 BTC * 100 KRW = 0.1 KRW < 5000 KRW minimum
result = place_sell_order_upbit("KRW-BTC", 0.001, cfg)
assert result["status"] == "skipped_too_small"
assert result["reason"] == "min_order_value"
def test_sell_order_price_unavailable(self):
"""Test sell order fails when current price unavailable."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = "key"
cfg.upbit_secret_key = "secret"
cfg.config = {}
with patch("src.holdings.get_current_price", return_value=0):
result = place_sell_order_upbit("KRW-BTC", 0.01, cfg)
assert result["status"] == "failed"
assert result["error"] == "price_unavailable"
def test_sell_order_no_api_key(self):
"""Test sell order fails gracefully without API keys."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = None
cfg.upbit_secret_key = None
cfg.config = {}
result = place_sell_order_upbit("KRW-BTC", 0.01, cfg)
assert result["status"] == "failed"
assert "error" in result
class TestBuyOrderResponseValidation:
"""Test buy order API response validation."""
def test_response_type_validation(self):
"""Test validation of response type (must be dict)."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = "key"
cfg.upbit_secret_key = "secret"
cfg.config = {"auto_trade": {"min_order_value_krw": MIN_KRW_ORDER}}
with patch("src.holdings.get_current_price", return_value=50000000):
with patch("src.order.pyupbit.Upbit") as mock_upbit_class:
mock_upbit = MagicMock()
mock_upbit_class.return_value = mock_upbit
# Invalid response: string instead of dict
mock_upbit.buy_limit_order.return_value = "invalid_response"
with patch("src.order.adjust_price_to_tick_size", return_value=50000000):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
assert result["status"] == "failed"
assert result["error"] == "invalid_response_type"
def test_response_uuid_validation(self):
"""Test validation of uuid in response."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = "key"
cfg.upbit_secret_key = "secret"
cfg.config = {"auto_trade": {"min_order_value_krw": MIN_KRW_ORDER, "buy_price_slippage_pct": 1.0}}
with patch("src.holdings.get_current_price", return_value=50000000):
with patch("src.order.pyupbit.Upbit") as mock_upbit_class:
mock_upbit = MagicMock()
mock_upbit_class.return_value = mock_upbit
# Response without uuid (Upbit error format)
mock_upbit.buy_limit_order.return_value = {
"error": {"name": "insufficient_funds", "message": "잔액 부족"}
}
with patch("src.order.adjust_price_to_tick_size", return_value=50000000):
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
assert result["status"] == "failed"
assert result["error"] == "order_rejected"
class TestSellOrderResponseValidation:
"""Test sell order API response validation."""
def test_sell_response_uuid_missing(self):
"""Test sell order fails when uuid missing from response."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = "key"
cfg.upbit_secret_key = "secret"
cfg.config = {"auto_trade": {"min_order_value_krw": MIN_KRW_ORDER}}
with patch("src.holdings.get_current_price", return_value=50000000):
with patch("src.order.pyupbit.Upbit") as mock_upbit_class:
mock_upbit = MagicMock()
mock_upbit_class.return_value = mock_upbit
# Missing uuid
mock_upbit.sell_market_order.return_value = {"market": "KRW-BTC"}
result = place_sell_order_upbit("KRW-BTC", 0.01, cfg)
assert result["status"] == "failed"
assert result["error"] == "order_rejected"
def test_sell_response_type_invalid(self):
"""Test sell order fails with invalid response type."""
cfg = Mock()
cfg.dry_run = False
cfg.upbit_access_key = "key"
cfg.upbit_secret_key = "secret"
cfg.config = {}
with patch("src.holdings.get_current_price", return_value=50000000):
with patch("src.order.pyupbit.Upbit") as mock_upbit_class:
mock_upbit = MagicMock()
mock_upbit_class.return_value = mock_upbit
# Invalid response
mock_upbit.sell_market_order.return_value = "not_a_dict"
result = place_sell_order_upbit("KRW-BTC", 0.01, cfg)
assert result["status"] == "failed"
assert result["error"] == "invalid_response_type"

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
주문 실패 방지 개선 사항 테스트
- validate_upbit_api_keys() 함수
- _has_duplicate_pending_order() 함수
"""
import os
import sys
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, os.path.dirname(__file__))
from src.order import _has_duplicate_pending_order, validate_upbit_api_keys
class TestValidateUpbitAPIKeys(unittest.TestCase):
"""API 키 검증 함수 테스트"""
@patch("src.order.pyupbit.Upbit")
def test_valid_api_keys(self, mock_upbit_class):
"""유효한 API 키 테스트"""
# Mock: get_balances() 반환
mock_instance = Mock()
mock_instance.get_balances.return_value = [
{"currency": "BTC", "balance": 1.0},
{"currency": "KRW", "balance": 1000000},
]
mock_upbit_class.return_value = mock_instance
is_valid, msg = validate_upbit_api_keys("test_access_key", "test_secret_key")
self.assertTrue(is_valid)
self.assertIn("OK", msg)
mock_upbit_class.assert_called_once_with("test_access_key", "test_secret_key")
print("✅ [PASS] 유효한 API 키 검증")
@patch("src.order.pyupbit.Upbit")
def test_invalid_api_keys_timeout(self, mock_upbit_class):
"""Timeout 예외 처리 테스트"""
import requests
mock_instance = Mock()
mock_instance.get_balances.side_effect = requests.exceptions.Timeout("Connection timeout")
mock_upbit_class.return_value = mock_instance
is_valid, msg = validate_upbit_api_keys("invalid_key", "invalid_secret")
self.assertFalse(is_valid)
self.assertIn("타임아웃", msg)
print("✅ [PASS] Timeout 예외 처리")
@patch("src.order.pyupbit.Upbit")
def test_missing_api_keys(self, mock_upbit_class):
"""API 키 누락 테스트"""
is_valid, msg = validate_upbit_api_keys("", "")
self.assertFalse(is_valid)
self.assertIn("설정되지 않았습니다", msg)
mock_upbit_class.assert_not_called()
print("✅ [PASS] API 키 누락 처리")
class TestDuplicateOrderPrevention(unittest.TestCase):
"""중복 주문 방지 함수 테스트"""
def test_no_duplicate_orders(self):
"""중복 주문 없을 때"""
mock_upbit = Mock()
mock_upbit.get_orders.return_value = []
is_dup, order = _has_duplicate_pending_order(mock_upbit, "KRW-BTC", "bid", 0.001, 50000.0)
self.assertFalse(is_dup)
self.assertIsNone(order)
print("✅ [PASS] 중복 주문 없음 - 통과")
def test_duplicate_order_found_in_pending(self):
"""미체결 중인 중복 주문 발견"""
mock_upbit = Mock()
duplicate_order = {"uuid": "test-uuid-123", "side": "bid", "volume": 0.001, "price": 50000.0}
mock_upbit.get_orders.return_value = [duplicate_order]
is_dup, order = _has_duplicate_pending_order(mock_upbit, "KRW-BTC", "bid", 0.001, 50000.0)
self.assertTrue(is_dup)
self.assertIsNotNone(order)
self.assertEqual(order["uuid"], "test-uuid-123")
print("✅ [PASS] 미체결 중복 주문 감지")
def test_duplicate_order_volume_mismatch(self):
"""수량 불일치 - 중복 아님"""
mock_upbit = Mock()
different_order = {"uuid": "different-uuid", "side": "bid", "volume": 0.002, "price": 50000.0} # 다른 수량
mock_upbit.get_orders.return_value = [different_order]
is_dup, order = _has_duplicate_pending_order(mock_upbit, "KRW-BTC", "bid", 0.001, 50000.0)
self.assertFalse(is_dup)
self.assertIsNone(order)
print("✅ [PASS] 수량 불일치 - 중복 아님 판정")
class TestIntegrationLogMessages(unittest.TestCase):
"""통합 로그 메시지 검증"""
def test_duplicate_prevention_log_format(self):
"""중복 방지 로그 형식 검증"""
# 로그 메시지는 다음 형식이어야 함:
# [⛔ 중복 방지] 이미 동일한 주문이 존재함: uuid=...
expected_log_patterns = [
"⛔ 중복 방지", # 중복 방지 표시
"이미 동일한", # 중복 인식
"uuid=", # 주문 UUID 표시
]
for pattern in expected_log_patterns:
self.assertIsNotNone(pattern)
print("✅ [PASS] 로그 메시지 형식 검증")
if __name__ == "__main__":
print("=" * 70)
print("주문 실패 방지 개선 사항 테스트")
print("=" * 70)
unittest.main(verbosity=2)

View File

@@ -1,160 +1,170 @@
import time
import threading
from typing import List
from .config import RuntimeConfig
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from .common import logger
from .config import RuntimeConfig
from .notifications import send_telegram_with_retry
from .signals import process_symbol
from .notifications import send_telegram
def run_sequential(symbols: List[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
logger.info("순차 처리 시작 (심볼 수=%d)", len(symbols))
alerts = []
buy_signal_count = 0
for i, sym in enumerate(symbols):
try:
res = process_symbol(sym, cfg=cfg)
for line in res.get("summary", []):
logger.info(line)
if res.get("telegram"):
buy_signal_count += 1
if cfg.dry_run:
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
else:
# dry_run이 아닐 때는 콘솔에 메시지 출력하지 않음
pass
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
res["telegram"],
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 메시지 전송 불가")
alerts.append({"symbol": sym, "text": res["telegram"]})
except Exception as e:
logger.exception("심볼 처리 오류: %s -> %s", sym, e)
if i < len(symbols) - 1 and cfg.symbol_delay is not None:
logger.debug("다음 심볼까지 %.2f초 대기", cfg.symbol_delay)
time.sleep(cfg.symbol_delay)
if aggregate_enabled and len(alerts) > 1:
def _process_result_and_notify(
symbol: str, res: dict[str, Any], cfg: RuntimeConfig, alerts: list[dict[str, str]]
) -> bool:
"""
Process the result of process_symbol and send notifications if needed.
Returns True if a buy signal was triggered (telegram message sent), False otherwise.
"""
if not res:
logger.warning("심볼 결과 없음: %s", symbol)
return False
for line in res.get("summary", []):
logger.info(line)
buy_signal_triggered = False
if res.get("telegram"):
buy_signal_triggered = True
if cfg.dry_run:
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
if cfg.telegram_bot_token and cfg.telegram_chat_id:
# ✅ 재시도 로직 포함
if not send_telegram_with_retry(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
res["telegram"],
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
):
logger.error("심볼 %s 알림 전송 최종 실패", symbol)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 메시지 전송 불가")
alerts.append({"symbol": symbol, "text": res["telegram"]})
return buy_signal_triggered
def _send_aggregated_summary(alerts: list[dict[str, str]], cfg: RuntimeConfig):
"""Send aggregated summary if multiple alerts occurred."""
if len(alerts) > 1:
summary_lines = [f"알림 발생 심볼 수: {len(alerts)}", "\n"]
summary_lines += [f"- {a['symbol']}" for a in alerts]
summary_text = "\n".join(summary_lines)
if cfg.dry_run:
logger.info("[dry-run] 알림 요약:\n%s", summary_text)
else:
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
# ✅ 재시도 로직 포함
if not send_telegram_with_retry(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
summary_text,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
):
logger.error("알림 요약 전송 최종 실패")
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가")
# 매수 조건이 하나도 충족되지 않은 경우 알림 전송
def _notify_no_signals(alerts: list[dict[str, str]], cfg: RuntimeConfig):
"""Notify if no buy signals were triggered."""
if cfg.telegram_bot_token and cfg.telegram_chat_id and not any(a.get("text") for a in alerts):
send_telegram(
# ✅ 재시도 로직 포함
if not send_telegram_with_retry(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
):
logger.error("정상 작동 알림 전송 최종 실패")
def run_sequential(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
logger.info("순차 처리 시작 (심볼 수=%d)", len(symbols))
alerts = []
buy_signal_count = 0
for i, sym in enumerate(symbols):
try:
res = process_symbol(sym, cfg=cfg)
if _process_result_and_notify(sym, res, cfg, alerts):
buy_signal_count += 1
except Exception as e:
logger.exception("심볼 처리 오류: %s -> %s", sym, e)
if i < len(symbols) - 1 and cfg.symbol_delay is not None:
logger.debug("다음 심볼까지 %.2f초 대기", cfg.symbol_delay)
time.sleep(cfg.symbol_delay)
if aggregate_enabled:
_send_aggregated_summary(alerts, cfg)
_notify_no_signals(alerts, cfg)
return buy_signal_count
def run_with_threads(symbols: List[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
def run_with_threads(symbols: list[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
logger.info(
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d, 심볼 간 지연=%.2f초)",
len(symbols),
cfg.max_threads or 0,
cfg.symbol_delay or 0.0,
)
semaphore = threading.Semaphore(cfg.max_threads)
threads = []
last_request_time = [0]
alerts = []
buy_signal_count = 0
max_workers = cfg.max_threads or 4
# Throttle control
last_request_time = [0.0]
throttle_lock = threading.Lock()
results = {}
results_lock = threading.Lock()
def worker(symbol: str):
try:
with semaphore:
with throttle_lock:
elapsed = time.time() - last_request_time[0]
if cfg.symbol_delay is not None and elapsed < cfg.symbol_delay:
sleep_time = cfg.symbol_delay - elapsed
logger.debug("[%s] 스로틀 대기: %.2f", symbol, sleep_time)
time.sleep(sleep_time)
last_request_time[0] = time.time()
res = process_symbol(symbol, cfg=cfg)
with results_lock:
results[symbol] = res
with throttle_lock:
elapsed = time.time() - last_request_time[0]
if cfg.symbol_delay is not None and elapsed < cfg.symbol_delay:
sleep_time = cfg.symbol_delay - elapsed
logger.debug("[%s] 스로틀 대기: %.2f", symbol, sleep_time)
time.sleep(sleep_time)
last_request_time[0] = time.time()
return symbol, process_symbol(symbol, cfg=cfg)
except Exception as e:
logger.exception("[%s] 워커 스레드 오류: %s", symbol, e)
return symbol, None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_symbol = {executor.submit(worker, sym): sym for sym in symbols}
# Collect results as they complete
results = {}
for future in as_completed(future_to_symbol):
sym = future_to_symbol[future]
try:
symbol, res = future.result()
results[symbol] = res
except Exception as e:
logger.exception("[%s] Future 결과 조회 오류: %s", sym, e)
# Process results in original order to maintain consistent log/alert order if desired,
# or just process as is. Here we process in original symbol order.
for sym in symbols:
t = threading.Thread(target=worker, args=(sym,), name=f"Worker-{sym}")
threads.append(t)
t.start()
for t in threads:
t.join()
alerts = []
buy_signal_count = 0
for sym in symbols:
with results_lock:
res = results.get(sym)
if not res:
logger.warning("심볼 결과 없음: %s", sym)
continue
for line in res.get("summary", []):
logger.info(line)
if res.get("telegram"):
buy_signal_count += 1
if cfg.dry_run:
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
res["telegram"],
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 메시지 전송 불가")
alerts.append({"symbol": sym, "text": res["telegram"]})
if aggregate_enabled and len(alerts) > 1:
summary_lines = [f"알림 발생 심볼 수: {len(alerts)}", "\n"]
summary_lines += [f"- {a['symbol']}" for a in alerts]
summary_text = "\n".join(summary_lines)
if cfg.dry_run:
logger.info("[dry-run] 알림 요약:\n%s", summary_text)
else:
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
summary_text,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가")
# 매수 조건이 하나도 충족되지 않은 경우 알림 전송
if cfg.telegram_bot_token and cfg.telegram_chat_id and not any(a.get("text") for a in alerts):
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
res = results.get(sym)
if res:
if _process_result_and_notify(sym, res, cfg, alerts):
buy_signal_count += 1
if aggregate_enabled:
_send_aggregated_summary(alerts, cfg)
_notify_no_signals(alerts, cfg)
logger.info("병렬 처리 완료")
return buy_signal_count