업데이트

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

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)