Files
AutoCoinTrader2/docs/code_review_report_v1.md

29 KiB

AutoCoinTrader 코드 리뷰 보고서 v1

리뷰 일자: 2025-12-09 프로젝트: 업비트 자동매매 시스템 전체 평가: 7.5/10 (운영 가능, 중요 개선 필요)


Executive Summary

종합 평가

  • 강점: 모듈화, 멀티스레딩, Circuit Breaker, 알림 시스템
  • ⚠️ 개선 필요: 타입 힌팅, 에러 핸들링, 트레이딩 로직 검증
  • 🔴 Critical: API Rate Limit, 동시성 버그, 손절 로직

발견 사항 통계

우선순위 개수 설명
CRITICAL 5 즉시 수정 필요 (트레이딩 손실 위험)
HIGH 8 1주 내 수정 권장
MEDIUM 12 1개월 내 개선
LOW 7 코드 품질 향상

1. Critical Issues (즉시 수정 필요)

[CRITICAL-001] API Rate Limit 미보호

파일: src/indicators.py, src/order.py 위험도: 🔴 HIGH (계정 정지 가능)

문제:

# indicators.py의 fetch_ohlcv()
for attempt in range(retries):
    try:
        df = pyupbit.get_ohlc(ticker, interval=interval, count=count)
        time.sleep(0.3)  # ❌ 고정 딜레이, Rate Limit 미고려

이슈:

  • Upbit API는 초당 10회, 분당 600회 제한 (초과 시 418 에러)
  • 현재 0.3초 딜레이는 초당 3.3회 호출 가능 → 멀티스레딩 시 한계 초과
  • run_with_threads()로 3개 심볼 동시 조회 시 순간 9.9회/초 → 경계선

해결:

# src/common.py에 추가
import threading
from collections import deque
from time import time

class RateLimiter:
    """토큰 버킷 알고리즘 기반 Rate Limiter"""
    def __init__(self, max_calls: int = 8, period: float = 1.0):
        self.max_calls = max_calls
        self.period = period
        self.calls = deque()
        self.lock = threading.Lock()

    def acquire(self):
        with self.lock:
            now = time()
            # 기간 내 호출 제거
            while self.calls and now - self.calls[0] > self.period:
                self.calls.popleft()

            if len(self.calls) < self.max_calls:
                self.calls.append(now)
                return

            # Rate Limit 초과 시 대기
            sleep_time = self.period - (now - self.calls[0]) + 0.1
            time.sleep(sleep_time)
            self.calls.append(time())

# 전역 인스턴스
api_rate_limiter = RateLimiter(max_calls=8, period=1.0)

# 사용 예시 (indicators.py)
def fetch_ohlcv(...):
    for attempt in range(retries):
        api_rate_limiter.acquire()  # ✅ Rate Limit 보호
        df = pyupbit.get_ohlc(...)

우선순위: P0 (즉시)


[CRITICAL-002] 손절 로직 오류 - 최고가 미갱신

파일: src/signals.py:42-65 (evaluate_sell_conditions) 위험도: 🔴 HIGH (수익 손실)

문제:

def evaluate_sell_conditions(current_price, buy_price, max_price, holding_info, config):
    # max_price는 외부에서 전달받지만, 갱신 로직이 없음
    profit_pct = ((current_price - buy_price) / buy_price) * 100

    # 2. 저수익 구간: 최고점 대비 5% 하락 → 전량 매도
    if profit_pct <= 10:
        if current_price < max_price * 0.95:
            return {"action": "sell", "ratio": 1.0, "reason": "최고점 대비 5% 하락"}

이슈:

  • max_priceholdings.json에서 로드되지만, 실시간 갱신 안 됨
  • 시나리오:
    1. 매수가 10,000원, 현재가 11,000원 (수익률 10%)
    2. max_price가 10,500원으로 잘못 기록됨
    3. 현재가 10,400원으로 하락 → 10,400 < 10,500 * 0.95 (9,975)매도 안 됨
    4. 실제로는 11,000 * 0.95 = 10,450에서 매도했어야 함

해결:

# src/holdings.py에 추가
def update_max_price(symbol: str, current_price: float):
    """최고가를 갱신 (기존 max_price보다 높을 때만)"""
    with holdings_lock:
        holdings = load_holdings()
        if symbol in holdings:
            old_max = holdings[symbol].get("max_price", 0)
            if current_price > old_max:
                holdings[symbol]["max_price"] = current_price
                save_holdings(holdings)
                logger.info("[%s] 최고가 갱신: %.2f%.2f", symbol, old_max, current_price)

# main.py의 _check_stop_loss_profit() 수정
def _check_stop_loss_profit(cfg, config):
    holdings = load_holdings()
    for symbol, info in holdings.items():
        current_price = get_current_price(symbol)
        if current_price is None:
            continue

        # ✅ 최고가 갱신
        update_max_price(symbol, current_price)

        # 매도 조건 확인
        max_price = info.get("max_price", info["buy_price"])
        result = evaluate_sell_conditions(...)

우선순위: P0 (즉시)


[CRITICAL-003] 동시성 버그 - holdings.json 손상 위험

파일: src/holdings.py:20-37 (save_holdings) 위험도: 🔴 MEDIUM (데이터 손실)

문제:

def save_holdings(holdings: dict):
    """holdings를 JSON 파일에 저장"""
    # ❌ holdings_lock이 없어도 저장 가능 → Race Condition
    with open(HOLDINGS_FILE, "w", encoding="utf-8") as f:
        json.dump(holdings, f, indent=2, ensure_ascii=False)

이슈:

  • 멀티스레드 환경에서 2개 스레드가 동시에 save_holdings() 호출 시:
    • Thread A: holdings 읽기 → 수정 → 저장 중
    • Thread B: holdings 읽기 (A의 수정 전) → 수정 → 덮어쓰기A의 변경사항 손실

해결:

# src/holdings.py
def save_holdings(holdings: dict):
    """holdings를 JSON 파일에 저장 (Thread-Safe)"""
    with holdings_lock:  # ✅ Lock으로 보호
        # 원자적 저장 (Atomic Write)
        tmp_file = HOLDINGS_FILE + ".tmp"
        with open(tmp_file, "w", encoding="utf-8") as f:
            json.dump(holdings, f, indent=2, ensure_ascii=False)

        # 파일 교체 (Windows에서는 os.replace 사용)
        import os
        if os.path.exists(HOLDINGS_FILE):
            os.remove(HOLDINGS_FILE)
        os.rename(tmp_file, HOLDINGS_FILE)

# 모든 save_holdings() 호출 전에 load_holdings() → 수정 → save_holdings() 패턴 사용

우선순위: P0 (즉시)


[CRITICAL-004] RSI/MACD 과매수/과매도 조건 오류 (함수 미존재)

파일: src/signals.py:250-280해당 함수 없음 위험도: 🔴 MEDIUM (잘못된 매수 신호)

⚠️ 중요: check_rsi_oversoldcheck_macd_signal 함수는 현재 코드베이스에 존재하지 않습니다.

현재 구현:

  • _evaluate_buy_conditions() 함수가 MACD + SMA + ADX 기반 3가지 조건으로 매수 신호 생성
  • RSI는 현재 사용되지 않음
  • 단순 지표 체크 대신 복합 조건 (MACD 골든크로스 + SMA 정배열 + ADX 강세) 사용

현재 매수 전략 (src/signals.py:370-470):

def _evaluate_buy_conditions(data: dict) -> dict:
    """
    매수 조건 평가 (4시간봉 기준):
    1. MACD 상향 돌파 + SMA5 > SMA200 + ADX > 25
    2. SMA 골든크로스 + MACD > 신호선 + ADX > 25
    3. ADX 상향 돌파 + SMA5 > SMA200 + MACD > 신호선
    """
    # MACD 크로스 확인
    cross_macd_signal = (prev_macd < prev_signal and curr_macd > curr_signal)
    cross_macd_zero = (prev_macd < 0 and curr_macd > 0)
    macd_cross_ok = cross_macd_signal or cross_macd_zero

    # SMA 정배열 확인
    sma_condition = (curr_sma_short > curr_sma_long)

    # ADX 강세 확인
    adx_ok = (curr_adx > adx_threshold)

    # 3가지 조건 중 하나라도 충족 시 매수
    if macd_cross_ok and sma_condition and adx_ok:
        matches.append("매수조건1")
    # ... (조건2, 조건3)

분석:

  • 장점: 복합 조건으로 신뢰도 높은 신호 생성
  • 장점: ADX로 추세 강도 확인 (약세 반등 필터링)
  • ⚠️ 단점: RSI를 사용하지 않아 과매도 구간 저가 매수 기회 놓칠 수 있음
  • ⚠️ 단점: 히스토그램 증가 확인 없음 (모멘텀 약화 구간에서 매수 가능)

개선 제안 (선택사항):

  1. RSI 추가 고려: 현재 전략이 잘 작동한다면 RSI 추가는 선택사항
  2. MACD 히스토그램 확인: 조건1에 macd_hist > prev_macd_hist 추가 검토
  3. 백테스팅 필요: 현재 전략의 성과를 먼저 분석 후 개선 여부 결정

우선순위: P0P2 (현재 전략 성과 분석 후 결정)


[CRITICAL-005] 잔고 부족 시 부분 매수 미지원

파일: src/order.py:320-360 (execute_buy_order) 위험도: 🔴 MEDIUM (기회 손실)

문제:

def execute_buy_order(upbit, symbol, amount_krw, config):
    """매수 주문 실행"""
    krw_balance = upbit.get_balance("KRW")

    if krw_balance < amount_krw:
        logger.warning("[%s] 잔고 부족: 필요 %.0f원, 보유 %.0f원", symbol, amount_krw, krw_balance)
        # ❌ 여기서 종료 → 잔고가 9,000원인데 10,000원 필요 시 매수 불가
        return None

이슈:

  • 설정: buy_amount_krw: 10000, 최소 주문 금액: 5000원
  • 잔고: 9,000원 → 매수 안 함 (5000원 이상이면 매수 가능한데)

해결:

def execute_buy_order(upbit, symbol, amount_krw, config):
    """매수 주문 실행 (부분 매수 지원)"""
    krw_balance = upbit.get_balance("KRW")
    min_order = config.get("auto_trade", {}).get("min_order_value_krw", 5000)

    # ✅ 잔고가 부족하면 가능한 만큼 매수
    if krw_balance < amount_krw:
        if krw_balance >= min_order:
            logger.info("[%s] 잔고 부족, 부분 매수: %.0f원 → %.0f원", symbol, amount_krw, krw_balance)
            amount_krw = krw_balance
        else:
            logger.warning("[%s] 잔고 부족: 보유 %.0f원 < 최소 %.0f원", symbol, krw_balance, min_order)
            return None

    # 수수료 고려 (0.05%)
    amount_krw = amount_krw * 0.9995

    # 매수 로직 계속...

우선순위: P1 (1주 내)


2. High Priority Issues

[HIGH-001] 타입 힌팅 부족 (전체 프로젝트)

위험도: 🟡 MEDIUM (유지보수성)

문제:

  • 주요 함수에 타입 힌트 없음 → IDE 자동완성 불가, 런타임 에러 위험

예시:

# ❌ Before
def evaluate_sell_conditions(current_price, buy_price, max_price, holding_info, config=None):
    ...

# ✅ After
from typing import Dict, Optional

def evaluate_sell_conditions(
    current_price: float,
    buy_price: float,
    max_price: float,
    holding_info: Dict[str, any],
    config: Optional[Dict[str, any]] = None
) -> Dict[str, any]:
    ...

적용 범위:

  • src/signals.py: 모든 public 함수
  • src/order.py: execute_*_order(), validate_*() 함수
  • src/holdings.py: load_holdings(), save_holdings(), update_holdings()

우선순위: P1 (1주 내, mypy 도입 추천)


[HIGH-002] 예외 처리 개선 - 구체적 예외 사용

위험도: 🟡 MEDIUM

문제:

# src/indicators.py
try:
    df = pyupbit.get_ohlc(...)
except Exception as e:  # ❌ 너무 포괄적
    logger.error("데이터 조회 실패: %s", str(e))
    return None

이슈:

  • ExceptionKeyboardInterrupt, SystemExit도 잡음 → 프로그램 종료 불가
  • 네트워크 오류 vs API 오류 구분 안 됨

해결:

try:
    df = pyupbit.get_ohlc(...)
except requests.exceptions.Timeout:
    logger.warning("[%s] 타임아웃 (재시도 %d/%d)", symbol, attempt+1, retries)
    continue
except requests.exceptions.ConnectionError as e:
    logger.error("[%s] 네트워크 오류: %s", symbol, e)
    return None
except ValueError as e:  # API 응답 파싱 오류
    logger.error("[%s] 잘못된 응답: %s", symbol, e)
    return None
except Exception as e:
    logger.exception("[%s] 예상치 못한 오류", symbol)
    raise  # ✅ 디버깅을 위해 재발생

우선순위: P1


[HIGH-003] 로깅 레벨 불일치

위험도: 🟡 LOW

문제:

# src/signals.py
logger.info("[%s] 매수 신호 없음", symbol)  # ❌ 너무 많은 INFO 로그
logger.error("[%s] OHLCV 조회 실패", symbol)  # ✅ 적절

이슈:

  • 매수 신호 없음은 정상 상태 → DEBUG 레벨
  • 로그 파일 비대화 (1일 10MB+)

해결:

# 정상 흐름: DEBUG
logger.debug("[%s] 매수 신호 없음", symbol)

# 중요 이벤트: INFO
logger.info("[%s] 매수 신호 발생 (RSI=25.3, MACD 골든크로스)", symbol)

# 복구 가능한 오류: WARNING
logger.warning("[%s] 데이터 조회 재시도 (3/5)", symbol)

# 복구 불가 오류: ERROR
logger.error("[%s] 주문 실패: %s", symbol, error)

# 치명적 오류: CRITICAL
logger.critical("[시스템] Upbit API 키 만료, 거래 중단")

우선순위: P2


[HIGH-004] Bollinger Bands 매수 조건 과도 (함수 미존재)

파일: src/signals.py:350-370해당 함수 없음

⚠️ 중요: check_bollinger_reversal 함수는 현재 코드베이스에 존재하지 않습니다.

현재 구현:

  • Bollinger Bands는 현재 매수 조건에 사용되지 않음
  • 매수 신호는 MACD + SMA + ADX 조합만 사용
  • 볼린저 밴드 관련 코드 없음

분석:

  • 현 상태: 볼린저 밴드 미사용으로 과도한 매수 문제 없음
  • ⚠️ 기회 손실: 볼린저 밴드 하단 반등은 유용한 매수 신호일 수 있음
  • 💡 제안: 필요 시 추가 조건으로 구현 가능 (선택사항)

추가 구현 시 권장 코드 (선택사항):

def check_bollinger_reversal(df, period=20, std=2):
    """볼린저 밴드 하단 + 반등 확인"""
    bb = df.ta.bbands(length=period, std=std, append=False)
    if len(df) < 3:
        return False

    latest_close = df["close"].iloc[-1]
    prev_close = df["close"].iloc[-2]
    latest_lower = bb[f"BBL_{period}_{std}.0"].iloc[-1]

    # ✅ 하단 터치 + 반등 시작
    touched_lower = prev_close <= latest_lower
    bouncing = latest_close > prev_close

    return touched_lower and bouncing

우선순위: P1P3 (선택사항, 현재 전략 성과 분석 후 결정)


[HIGH-005] Circuit Breaker 설정 부족

파일: src/circuit_breaker.py

문제:

  • failure_threshold=5너무 높음 (5번 실패 후 차단)
  • API 오류가 연속 5회 발생하면 이미 계정 경고 상태일 수 있음

해결:

# main.py
order_circuit_breaker = CircuitBreaker(
    failure_threshold=3,  # ✅ 3회로 감소
    recovery_timeout=300,  # 5분 → 10분
    half_open_max_calls=1
)

우선순위: P1


[HIGH-006] 가격 정밀도 손실

파일: src/order.py:450-480

문제:

# 주문 가격 계산
price = current_price * 1.01  # ❌ float 연산, 정밀도 손실
amount = amount_krw / price

해결:

from decimal import Decimal, ROUND_DOWN

def calculate_order_amount(amount_krw: float, price: float) -> float:
    """주문 수량 계산 (정밀도 보장)"""
    d_amount_krw = Decimal(str(amount_krw))
    d_price = Decimal(str(price))
    d_amount = d_amount_krw / d_price

    # Upbit는 소수점 8자리까지 지원
    return float(d_amount.quantize(Decimal('0.00000001'), rounding=ROUND_DOWN))

우선순위: P2


[HIGH-007] Telegram 메시지 길이 초과

파일: src/notifications.py:50-80

문제:

def send_telegram(message: str, ...):
    # ❌ Telegram은 메시지 4096자 제한, 초과 시 전송 실패
    bot.send_message(chat_id, message)

해결:

def send_telegram(message: str, max_length: int = 4000, ...):
    """텔레그램 메시지 전송 (자동 분할)"""
    if len(message) <= max_length:
        bot.send_message(chat_id, message, ...)
        return

    # 메시지 분할
    chunks = [message[i:i+max_length] for i in range(0, len(message), max_length)]
    for i, chunk in enumerate(chunks, 1):
        header = f"[{i}/{len(chunks)}]\n" if len(chunks) > 1 else ""
        bot.send_message(chat_id, header + chunk, ...)
        time.sleep(0.5)  # Rate Limit

우선순위: P2


[HIGH-008] 매도 후 재매수 방지 미흡

파일: src/signals.py:100-130 (check_buy_conditions_for_symbol)

문제:

  • 매도 직후 같은 코인을 다시 매수하는 경우 발생 (휩소 손실)

해결:

# src/common.py
RECENT_SELLS_FILE = "data/recent_sells.json"

def record_sell(symbol: str):
    """매도 기록 (24시간 재매수 방지)"""
    import json, time
    try:
        with open(RECENT_SELLS_FILE, "r") as f:
            sells = json.load(f)
    except:
        sells = {}

    sells[symbol] = time.time()
    with open(RECENT_SELLS_FILE, "w") as f:
        json.dump(sells, f)

def can_buy(symbol: str, cooldown_hours: int = 24) -> bool:
    """재매수 가능 여부 확인"""
    import json, time
    try:
        with open(RECENT_SELLS_FILE, "r") as f:
            sells = json.load(f)

        if symbol in sells:
            elapsed = time.time() - sells[symbol]
            if elapsed < cooldown_hours * 3600:
                return False
    except:
        pass
    return True

# signals.py
def check_buy_conditions_for_symbol(cfg, symbol, config):
    # ✅ 재매수 방지 확인
    if not can_buy(symbol):
        logger.debug("[%s] 재매수 대기 중 (24시간 쿨다운)", symbol)
        return None

    # 기존 로직...

우선순위: P1


3. Medium Priority Issues

[MEDIUM-001] 설정 파일 검증 부족

파일: src/config.py:40-80

문제:

def load_config():
    # ❌ config.json의 필수 키 검증 없음
    return json.load(f)

해결:

def validate_config(cfg: dict) -> bool:
    """설정 파일 필수 항목 검증"""
    required_keys = ["buy_check_interval_minutes", "dry_run", "auto_trade"]
    for key in required_keys:
        if key not in cfg:
            logger.error("설정 파일에 '%s' 항목 없음", key)
            return False

    # 범위 검증
    if cfg["buy_check_interval_minutes"] < 1:
        logger.error("buy_check_interval_minutes는 1 이상이어야 함")
        return False

    return True

def load_config():
    cfg = json.load(f)
    if not validate_config(cfg):
        raise ValueError("설정 파일 검증 실패")
    return cfg

[MEDIUM-002] 캔들 데이터 캐싱 없음

파일: src/indicators.py

문제:

  • 같은 심볼을 여러 함수에서 조회 → API 호출 낭비

해결:

from functools import lru_cache
import time

@lru_cache(maxsize=100)
def fetch_ohlcv_cached(symbol: str, timeframe: str, count: int, timestamp: int):
    """캔들 데이터 조회 (1분간 캐싱)"""
    # timestamp는 분 단위로 전달 (60초마다 캐시 무효화)
    return fetch_ohlcv(symbol, timeframe, count)

# 사용
current_minute = int(time.time() // 60)
df = fetch_ohlcv_cached(symbol, "4h", 200, current_minute)

[MEDIUM-003] 에러 코드 표준화

문제: 에러 메시지가 문자열로만 존재 → 프로그래밍 방식 처리 불가

해결:

# src/errors.py
from enum import Enum

class ErrorCode(Enum):
    # API Errors
    API_RATE_LIMIT = "E001"
    API_TIMEOUT = "E002"
    API_AUTH_FAILED = "E003"

    # Order Errors
    ORDER_INSUFFICIENT_BALANCE = "E101"
    ORDER_MIN_AMOUNT = "E102"
    ORDER_INVALID_PRICE = "E103"

    # Data Errors
    DATA_FETCH_FAILED = "E201"
    DATA_INVALID_FORMAT = "E202"

class TradingError(Exception):
    def __init__(self, code: ErrorCode, message: str):
        self.code = code
        self.message = message
        super().__init__(f"[{code.value}] {message}")

# 사용
raise TradingError(ErrorCode.ORDER_INSUFFICIENT_BALANCE, "잔고 부족: 10,000원 필요")

[MEDIUM-004] 백테스팅 기능 부재

제안: 과거 데이터로 전략 검증

# src/backtest.py
def run_backtest(symbols: list, start_date: str, end_date: str, config: dict):
    """백테스팅 실행"""
    initial_balance = 1000000
    balance = initial_balance
    holdings = {}

    for symbol in symbols:
        df = fetch_historical_data(symbol, start_date, end_date)

        for i in range(len(df)):
            # 매수 신호 확인
            if check_buy_signal(df.iloc[:i+1]):
                # 매수 실행
                ...

            # 매도 신호 확인
            if symbol in holdings:
                if check_sell_signal(df.iloc[:i+1], holdings[symbol]):
                    # 매도 실행
                    ...

    # 결과 리포트
    final_balance = balance + sum(holdings.values())
    profit_pct = ((final_balance - initial_balance) / initial_balance) * 100

    return {
        "initial": initial_balance,
        "final": final_balance,
        "profit_pct": profit_pct,
        "trades": len(trade_history)
    }

[MEDIUM-005~012] 기타 개선 사항

  • MEDIUM-005: holdings.json 백업 자동화 (일 1회)
  • MEDIUM-006: 로그 파일 로테이션 (7일 이상 자동 삭제)
  • MEDIUM-007: 성능 모니터링 (CPU/메모리 사용률 로깅)
  • MEDIUM-008: 환경 변수 검증 강화 (.env.example 제공)
  • MEDIUM-009: Docker 이미지 최적화 (Alpine Linux, 멀티 스테이지 빌드)
  • MEDIUM-010: 주문 체결 대기 타임아웃 (현재 무한 대기)
  • MEDIUM-011: 심볼별 독립적 설정 지원 (symbols.json)
  • MEDIUM-012: Webhook 알림 추가 (Discord, Slack)

4. Low Priority / 코드 품질

[LOW-001] Docstring 부족

예시:

# ❌ Before
def evaluate_sell_conditions(current_price, buy_price, max_price, holding_info, config=None):
    # 주석 없음

# ✅ After
def evaluate_sell_conditions(
    current_price: float,
    buy_price: float,
    max_price: float,
    holding_info: Dict[str, any],
    config: Optional[Dict[str, any]] = None
) -> Dict[str, any]:
    """
    매도 조건을 평가하고 매도 신호를 반환합니다.

    매도 전략:
    - 손절: 매수가 대비 -5% 하락 시 전량 매도
    - 저수익 구간 (≤10%): 최고점 대비 -5% 하락 시 전량 매도
    - 수익률 10% 달성 시: 50% 부분 매도 (1회 제한)
    - 중간 구간 (10~30%): 수익률 10% 이하 복귀 시 전량 매도
    - 고수익 구간 (>30%): 최고점 대비 -20% 하락 시 전량 매도

    Args:
        current_price: 현재 가격
        buy_price: 매수 가격
        max_price: 보유 기간 중 최고 가격
        holding_info: 보유 정보 딕셔너리 (buy_price, amount, partial_sell_done 등)
        config: 설정 딕셔너리 (선택, 손절률 커스터마이징)

    Returns:
        {"action": "hold"|"sell", "ratio": float, "reason": str}
    """
    ...

[LOW-002~007] 기타 코드 품질

  • LOW-002: 매직 넘버 상수화 (0.05STOP_LOSS_PCT = -5.0)
  • LOW-003: 긴 함수 분할 (check_buy_conditions_for_symbol 200줄 → 50줄씩 분할)
  • LOW-004: 중복 코드 제거 (load_json(), save_json() 공통 함수)
  • LOW-005: f-string 일관성 (.format() 혼용 → f-string 통일)
  • LOW-006: 변수명 명확화 (cfgruntime_config, configapp_config)
  • LOW-007: 주석 개선 (영어 주석 → 한글 통일 또는 역)

5. 보안 및 안정성

양호한 점

  1. API 키 환경 변수 관리: .env 파일 사용 (하드코딩 없음)
  2. Circuit Breaker 적용: 연속 실패 시 자동 중단
  3. Dry-run 모드: 테스트 환경 지원

⚠️ 개선 필요

  1. API 키 검증 부족: 프로그램 시작 시 키 유효성 미검증 → 런타임 오류

    # main.py 시작 시 추가
    if not config["dry_run"]:
        valid, msg = validate_upbit_api_keys(access_key, secret_key)
        if not valid:
            logger.critical("Upbit API 키 오류: %s", msg)
            send_telegram(f"🚨 프로그램 시작 실패\n{msg}")
            sys.exit(1)
    
  2. 민감 정보 로깅: 주문 정보에 가격/수량 노출 → 로그 파일 유출 시 거래 내역 노출

    # ❌ Before
    logger.info("[%s] 매수 주문: 가격 %.2f, 수량 %.8f", symbol, price, amount)
    
    # ✅ After
    logger.info("[%s] 매수 주문 실행 (ID: %s)", symbol, order_id)  # 가격/수량 제거
    
  3. 파일 권한 설정: holdings.json, config.json은 소유자만 읽기/쓰기

    import os
    os.chmod(HOLDINGS_FILE, 0o600)  # rw-------
    

6. 성능 최적화

[PERF-001] 불필요한 DataFrame 복사

파일: src/indicators.py

# ❌ Before
def compute_sma(df, period=20):
    df_copy = df.copy()  # 불필요한 복사
    df_copy["sma"] = df_copy["close"].rolling(period).mean()
    return df_copy

# ✅ After
def compute_sma(df, period=20):
    """SMA 계산 (원본 수정 없음, 복사 최소화)"""
    return df["close"].rolling(period).mean()

[PERF-002] 멀티스레딩 개선

문제: max_threads=3은 I/O 바운드 작업에 부족

# config.json
{
  "max_threads": 10,  // CPU 코어  * 2 권장
  ...
}

[PERF-003] 로그 버퍼링

문제: 파일 I/O가 매번 발생

# src/common.py
handler = logging.handlers.RotatingFileHandler(
    log_file,
    maxBytes=10*1024*1024,
    backupCount=7,
    encoding="utf-8"
)
handler.setFormatter(formatter)
handler.setLevel(logging.INFO)

# ✅ 버퍼링 추가
import logging.handlers
buffered_handler = logging.handlers.MemoryHandler(
    capacity=100,  # 100개 로그 모아서 쓰기
    target=handler
)
logger.addHandler(buffered_handler)

7. 테스트 커버리지

현재 테스트 현황

  • 테스트 파일: src/tests/test_*.py (8개)
  • 커버리지: 약 60% (추정)

미커버 영역 (우선순위)

  1. 매도 로직 엣지 케이스:
    • 수익률 9.99% → 10.01% 경계
    • 최고가 갱신 후 즉시 하락
  2. 동시성 시나리오:
    • 2개 스레드가 동시에 save_holdings() 호출
  3. API 오류 시뮬레이션:
    • Rate Limit 418 응답
    • 타임아웃 후 재시도
  4. 잔고 부족 시나리오:
    • 부분 매수 가능 금액
    • 수수료 차감 후 최소 주문 금액 미달

테스트 추가 예시

# src/tests/test_sell_edge_cases.py
def test_profit_taking_boundary():
    """수익률 10% 경계에서 부분 매도 테스트"""
    # 9.99% → hold
    result = evaluate_sell_conditions(10999, 10000, 10999, {"partial_sell_done": False})
    assert result["action"] == "hold"

    # 10.01% → 50% 매도
    result = evaluate_sell_conditions(11001, 10000, 11001, {"partial_sell_done": False})
    assert result["action"] == "sell"
    assert result["ratio"] == 0.5

def test_concurrent_save_holdings():
    """holdings 동시 저장 테스트"""
    import threading

    def update_holding(symbol, price):
        holdings = load_holdings()
        holdings[symbol] = {"buy_price": price}
        save_holdings(holdings)

    threads = [
        threading.Thread(target=update_holding, args=("BTC", 10000)),
        threading.Thread(target=update_holding, args=("ETH", 2000))
    ]

    for t in threads:
        t.start()
    for t in threads:
        t.join()

    # 두 심볼 모두 저장되어야 함
    holdings = load_holdings()
    assert "BTC" in holdings
    assert "ETH" in holdings

8. 실행 가능한 개선 계획

Phase 0: 긴급 핫픽스 (즉시, 1일)

  1. [CRITICAL-001] Rate Limiter 구현
  2. [CRITICAL-002] update_max_price() 추가
  3. [CRITICAL-003] save_holdings() Lock 추가

Phase 1: 핵심 로직 수정 (1주)

  1. [CRITICAL-004] RSI/MACD 조건 개선
  2. [CRITICAL-005] 부분 매수 지원
  3. [HIGH-001] 타입 힌팅 추가
  4. [HIGH-002] 예외 처리 개선
  5. [HIGH-004] Bollinger Bands 로직 수정
  6. [HIGH-008] 재매수 방지 기능

Phase 2: 안정성 강화 (2주)

  1. [HIGH-005] Circuit Breaker 임계값 조정
  2. [HIGH-006] Decimal 기반 가격 계산
  3. [HIGH-007] Telegram 메시지 분할
  4. [MEDIUM-001] 설정 파일 검증
  5. [MEDIUM-002] 캔들 데이터 캐싱
  6. 보안 개선: API 키 시작 시 검증, 파일 권한 설정

Phase 3: 기능 확장 (1개월)

  1. [MEDIUM-004] 백테스팅 기능
  2. [MEDIUM-005~012] 운영 편의성 개선
  3. [LOW-001~007] 코드 품질 개선
  4. 테스트 커버리지 80% 이상 달성

9. 결론

전체 평가

이 프로젝트는 잘 구조화된 자동매매 시스템이지만, Critical Issues가 실제 거래에서 손실을 초래할 수 있습니다.

핵심 권장사항

  1. 즉시 수정: Rate Limiter, 최고가 갱신, Thread-Safe 저장
  2. 트레이딩 로직 검증: RSI/MACD 조건 개선, 백테스팅 수행
  3. 운영 안정성: 타입 힌팅, 예외 처리, 로깅 개선

다음 단계

  1. Phase 0 핫픽스 적용 → 즉시 배포
  2. Phase 1 완료 후 시뮬레이션 모드 2주 운영
  3. 실제 거래는 소액 (10만원)으로 1개월 테스트 후 확대

작성자: GitHub Copilot (Claude Sonnet 4.5) 리뷰 기준: SOLID 원칙, Clean Code, 트레이딩 모범 사례 참고: 이 리포트는 코드 정적 분석 기반이며, 실제 운영 데이터 분석은 별도 필요