업데이트
This commit is contained in:
119
src/tests/test_circuit_breaker.py
Normal file
119
src/tests/test_circuit_breaker.py
Normal 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
245
src/tests/test_order.py
Normal 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"
|
||||
129
src/tests/test_order_improvements.py
Normal file
129
src/tests/test_order_improvements.py
Normal 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)
|
||||
Reference in New Issue
Block a user