테스트 강화 및 코드 품질 개선
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.signals import evaluate_sell_conditions
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ class TestBoundaryConditions:
|
||||
# Then: 수익률이 30% 이하(<= 30)로 하락하여 조건5-2 발동 (stop_loss)
|
||||
assert result["status"] == "stop_loss"
|
||||
assert result["sell_ratio"] == 1.0
|
||||
assert "수익률 보호(조건5)" in result["reasons"][0]
|
||||
assert "수익률 보호(조건5" in result["reasons"][0] # 조건5-2도 매칭
|
||||
|
||||
def test_profit_rate_below_30_percent_triggers_sell(self):
|
||||
"""최고 수익률 30% 초과 구간에서 수익률이 30% 미만으로 떨어질 때"""
|
||||
@@ -56,7 +57,7 @@ class TestBoundaryConditions:
|
||||
# Then: 조건5-2 발동 (수익률 30% 미만으로 하락)
|
||||
assert result["status"] == "stop_loss"
|
||||
assert result["sell_ratio"] == 1.0
|
||||
assert "수익률 보호(조건5)" in result["reasons"][0]
|
||||
assert "수익률 보호(조건5" in result["reasons"][0] # 조건5-2도 매칭
|
||||
|
||||
def test_profit_rate_exactly_10_percent_in_mid_zone(self):
|
||||
"""최고 수익률 10~30% 구간에서 수익률이 정확히 10%일 때"""
|
||||
@@ -72,7 +73,7 @@ class TestBoundaryConditions:
|
||||
# Then: 수익률이 10% 이하(<= 10)로 하락하여 조건4-2 발동 (stop_loss)
|
||||
assert result["status"] == "stop_loss"
|
||||
assert result["sell_ratio"] == 1.0
|
||||
assert "수익률 보호(조건4)" in result["reasons"][0]
|
||||
assert "수익률 보호(조건4" in result["reasons"][0] # 조건4-2도 매칭
|
||||
|
||||
def test_profit_rate_below_10_percent_triggers_sell(self):
|
||||
"""최고 수익률 10~30% 구간에서 수익률이 10% 미만으로 떨어질 때"""
|
||||
@@ -88,7 +89,7 @@ class TestBoundaryConditions:
|
||||
# Then: 조건4-2 발동 (수익률 10% 미만으로 하락)
|
||||
assert result["status"] == "stop_loss"
|
||||
assert result["sell_ratio"] == 1.0
|
||||
assert "수익률 보호(조건4)" in result["reasons"][0]
|
||||
assert "수익률 보호(조건4" in result["reasons"][0] # 조건4-2도 매칭
|
||||
|
||||
def test_partial_sell_already_done_no_duplicate(self):
|
||||
"""부분 매도 이미 완료된 경우 중복 발동 안됨"""
|
||||
|
||||
305
src/tests/test_concurrent_buy_orders.py
Normal file
305
src/tests/test_concurrent_buy_orders.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
멀티스레드 환경에서 동시 매수 주문 테스트
|
||||
실제 place_buy_order_upbit 함수를 사용한 통합 테스트
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import krw_budget_manager
|
||||
from src.config import RuntimeConfig
|
||||
from src.order import place_buy_order_upbit
|
||||
|
||||
|
||||
class MockUpbit:
|
||||
"""Upbit API 모의 객체"""
|
||||
|
||||
def __init__(self, initial_balance: float):
|
||||
self.balance = initial_balance
|
||||
self.lock = threading.Lock()
|
||||
self.orders = []
|
||||
|
||||
def get_balance(self, currency: str) -> float:
|
||||
"""KRW 잔고 조회"""
|
||||
with self.lock:
|
||||
return self.balance
|
||||
|
||||
def buy_limit_order(self, ticker: str, price: float, volume: float):
|
||||
"""지정가 매수 주문"""
|
||||
with self.lock:
|
||||
cost = price * volume
|
||||
if self.balance >= cost:
|
||||
self.balance -= cost
|
||||
order = {
|
||||
"uuid": f"order-{len(self.orders) + 1}",
|
||||
"market": ticker,
|
||||
"price": price,
|
||||
"volume": volume,
|
||||
"side": "bid",
|
||||
"state": "done",
|
||||
"remaining_volume": 0,
|
||||
}
|
||||
self.orders.append(order)
|
||||
return order
|
||||
raise ValueError("Insufficient balance")
|
||||
|
||||
def buy_market_order(self, ticker: str, price: float):
|
||||
"""시장가 매수 주문 (KRW 금액 기준)"""
|
||||
with self.lock:
|
||||
if self.balance >= price:
|
||||
self.balance -= price
|
||||
order = {
|
||||
"uuid": f"order-{len(self.orders) + 1}",
|
||||
"market": ticker,
|
||||
"price": price,
|
||||
"side": "bid",
|
||||
"state": "done",
|
||||
"remaining_volume": 0,
|
||||
}
|
||||
self.orders.append(order)
|
||||
return order
|
||||
raise ValueError("Insufficient balance")
|
||||
|
||||
def get_order(self, uuid: str):
|
||||
with self.lock:
|
||||
for order in self.orders:
|
||||
if order.get("uuid") == uuid:
|
||||
return order
|
||||
return {"uuid": uuid, "state": "done", "remaining_volume": 0}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""테스트용 RuntimeConfig"""
|
||||
config_dict = {
|
||||
"auto_trade": {
|
||||
"min_order_value_krw": 5000,
|
||||
"buy_price_slippage_pct": 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
cfg = Mock(spec=RuntimeConfig)
|
||||
cfg.config = config_dict
|
||||
cfg.dry_run = False
|
||||
cfg.upbit_access_key = "test_access_key"
|
||||
cfg.upbit_secret_key = "test_secret_key"
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cleanup_budget():
|
||||
"""테스트 후 예산 관리자 초기화"""
|
||||
yield
|
||||
krw_budget_manager.clear()
|
||||
|
||||
|
||||
class TestConcurrentBuyOrders:
|
||||
"""동시 매수 주문 테스트"""
|
||||
|
||||
@patch("src.order.pyupbit.Upbit")
|
||||
@patch("src.holdings.get_current_price")
|
||||
def test_concurrent_buy_no_overdraft(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
|
||||
"""동시 매수 시 잔고 초과 인출 방지 테스트"""
|
||||
# Mock 설정
|
||||
mock_upbit = MockUpbit(100000) # 10만원 초기 잔고
|
||||
mock_upbit_class.return_value = mock_upbit
|
||||
mock_price.return_value = 10000 # 코인당 1만원
|
||||
|
||||
results = []
|
||||
|
||||
def buy_worker(symbol: str, amount_krw: float):
|
||||
"""매수 워커 스레드"""
|
||||
result = place_buy_order_upbit(symbol, amount_krw, mock_config)
|
||||
results.append((symbol, result))
|
||||
|
||||
# 3개 스레드가 동시에 50000원씩 매수 시도 (총 150000원 > 잔고 100000원)
|
||||
threads = [threading.Thread(target=buy_worker, args=(f"KRW-COIN{i}", 50000)) for i in range(3)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 검증 1: 성공한 주문들의 총액이 초기 잔고를 초과하지 않음
|
||||
successful_orders = [
|
||||
r
|
||||
for symbol, r in results
|
||||
if r.get("status") not in ("skipped_insufficient_budget", "skipped_insufficient_balance", "failed")
|
||||
]
|
||||
|
||||
total_spent = sum(
|
||||
r.get("amount_krw", 0)
|
||||
for _, r in results
|
||||
if r.get("status") not in ("skipped_insufficient_budget", "skipped_insufficient_balance", "failed")
|
||||
)
|
||||
|
||||
assert total_spent <= 100000, f"총 지출 {total_spent}원이 잔고 100000원을 초과"
|
||||
|
||||
# 검증 2: 최소 2개는 성공 (100000 / 50000 = 2)
|
||||
assert len(successful_orders) >= 2
|
||||
|
||||
# 검증 3: 1개는 실패 또는 부분 할당
|
||||
failed_or_partial = [
|
||||
r
|
||||
for symbol, r in results
|
||||
if r.get("status") in ("skipped_insufficient_budget", "skipped_insufficient_balance")
|
||||
or r.get("amount_krw", 50000) < 50000
|
||||
]
|
||||
assert len(failed_or_partial) >= 1
|
||||
|
||||
@patch("src.order.pyupbit.Upbit")
|
||||
@patch("src.holdings.get_current_price")
|
||||
def test_same_symbol_multiple_orders_no_collision(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
|
||||
"""동일 심볼 복수 주문 시 예산 덮어쓰지 않고 합산 제한 유지"""
|
||||
mock_upbit = MockUpbit(100000)
|
||||
mock_upbit_class.return_value = mock_upbit
|
||||
mock_price.return_value = 10000
|
||||
|
||||
results = []
|
||||
|
||||
def buy_worker(amount_krw: float):
|
||||
result = place_buy_order_upbit("KRW-BTC", amount_krw, mock_config)
|
||||
results.append(result)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=buy_worker, args=(70000,)),
|
||||
threading.Thread(target=buy_worker, args=(70000,)),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
successful = [
|
||||
r
|
||||
for r in results
|
||||
if r.get("status") not in ("failed", "skipped_insufficient_budget", "skipped_insufficient_balance")
|
||||
]
|
||||
total_spent = sum(r.get("amount_krw", 0) for r in successful)
|
||||
|
||||
assert total_spent <= 100000
|
||||
assert len(successful) >= 1
|
||||
# 모든 주문 종료 후 토큰이 남아있지 않아야 한다
|
||||
assert krw_budget_manager.get_allocations() == {}
|
||||
|
||||
@patch("src.order.pyupbit.Upbit")
|
||||
@patch("src.holdings.get_current_price")
|
||||
def test_concurrent_buy_with_release(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
|
||||
"""할당 후 해제가 정상 작동하는지 테스트"""
|
||||
mock_upbit = MockUpbit(200000)
|
||||
mock_upbit_class.return_value = mock_upbit
|
||||
mock_price.return_value = 10000
|
||||
|
||||
results = []
|
||||
|
||||
def buy_and_track(symbol: str, amount_krw: float, delay: float = 0):
|
||||
"""매수 후 약간의 지연"""
|
||||
result = place_buy_order_upbit(symbol, amount_krw, mock_config)
|
||||
results.append((symbol, result))
|
||||
time.sleep(delay)
|
||||
|
||||
# Wave 1: BTC와 ETH 동시 매수 (각 80000원, 총 160000원)
|
||||
wave1_threads = [
|
||||
threading.Thread(target=buy_and_track, args=("KRW-BTC", 80000, 0.1)),
|
||||
threading.Thread(target=buy_and_track, args=("KRW-ETH", 80000, 0.1)),
|
||||
]
|
||||
|
||||
for t in wave1_threads:
|
||||
t.start()
|
||||
for t in wave1_threads:
|
||||
t.join()
|
||||
|
||||
# 검증: Wave 1에서 2개 중 최소 2개 성공 (200000 / 80000 = 2.5)
|
||||
wave1_results = results[:2]
|
||||
wave1_success = [
|
||||
r for _, r in wave1_results if r.get("status") not in ("skipped_insufficient_budget", "failed")
|
||||
]
|
||||
assert len(wave1_success) >= 2
|
||||
|
||||
# Wave 2: XRP 매수 (80000원) - 이전 주문 해제 후 가능
|
||||
time.sleep(0.2) # Wave 1 완료 대기
|
||||
|
||||
buy_and_track("KRW-XRP", 80000)
|
||||
|
||||
# 검증: XRP 매수도 성공해야 함 (예산 해제 후 재사용)
|
||||
xrp_result = results[-1][1]
|
||||
# 예산이 정상 해제되었다면 XRP도 매수 가능
|
||||
# (실제로는 mock이라 잔고가 안 줄어들지만, 예산 시스템 동작 확인)
|
||||
assert xrp_result.get("status") != "failed"
|
||||
|
||||
@patch("src.order.pyupbit.Upbit")
|
||||
@patch("src.holdings.get_current_price")
|
||||
def test_budget_cleanup_on_exception(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
|
||||
"""예외 발생 시에도 예산이 정상 해제되는지 테스트"""
|
||||
import requests
|
||||
|
||||
# Mock 설정: get_balance는 성공, buy는 실패 (구체적 예외 사용)
|
||||
mock_upbit = Mock()
|
||||
mock_upbit.get_balance.return_value = 100000
|
||||
mock_upbit.buy_limit_order.side_effect = requests.exceptions.RequestException("API Error")
|
||||
mock_upbit_class.return_value = mock_upbit
|
||||
mock_price.return_value = 10000
|
||||
|
||||
# 매수 시도 (예외 발생 예상)
|
||||
result = place_buy_order_upbit("KRW-BTC", 50000, mock_config)
|
||||
|
||||
# 검증 1: 주문은 실패
|
||||
assert result.get("status") == "failed"
|
||||
|
||||
# 검증 2: 예산은 해제되어야 함
|
||||
allocations = krw_budget_manager.get_allocations()
|
||||
assert "KRW-BTC" not in allocations, "예외 발생 후에도 예산이 해제되지 않음"
|
||||
|
||||
@patch("src.order.pyupbit.Upbit")
|
||||
@patch("src.holdings.get_current_price")
|
||||
def test_stress_10_concurrent_orders(self, mock_price, mock_upbit_class, mock_config, cleanup_budget):
|
||||
"""스트레스 테스트: 10개 동시 주문"""
|
||||
mock_upbit = MockUpbit(1000000) # 100만원
|
||||
mock_upbit_class.return_value = mock_upbit
|
||||
mock_price.return_value = 10000
|
||||
|
||||
results = []
|
||||
|
||||
def buy_worker(thread_id: int):
|
||||
"""워커 스레드"""
|
||||
for i in range(3): # 각 스레드당 3번 매수 시도
|
||||
symbol = f"KRW-COIN{thread_id}-{i}"
|
||||
result = place_buy_order_upbit(symbol, 50000, mock_config)
|
||||
results.append((symbol, result))
|
||||
time.sleep(0.01)
|
||||
|
||||
threads = [threading.Thread(target=buy_worker, args=(i,)) for i in range(10)]
|
||||
|
||||
start_time = time.time()
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# 검증 1: 모든 주문 완료
|
||||
assert len(results) == 30 # 10 threads × 3 orders
|
||||
|
||||
# 검증 2: 성공한 주문들의 총액이 초기 잔고를 초과하지 않음
|
||||
total_spent = sum(
|
||||
r.get("amount_krw", 0)
|
||||
for _, r in results
|
||||
if r.get("status") not in ("skipped_insufficient_budget", "skipped_insufficient_balance", "failed")
|
||||
)
|
||||
assert total_spent <= 1000000
|
||||
|
||||
# 검증 3: 최종 예산 할당 상태는 비어있어야 함
|
||||
final_allocations = krw_budget_manager.get_allocations()
|
||||
assert len(final_allocations) == 0, f"미해제 예산 발견: {final_allocations}"
|
||||
|
||||
print(f"\n스트레스 테스트 완료: {len(results)}건 주문, {elapsed:.2f}초 소요")
|
||||
print(f"총 지출: {total_spent:,.0f}원 / {1000000:,.0f}원")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
328
src/tests/test_config_validation.py
Normal file
328
src/tests/test_config_validation.py
Normal file
@@ -0,0 +1,328 @@
|
||||
# src/tests/test_config_validation.py
|
||||
"""
|
||||
HIGH-002: 설정 검증 로직 테스트
|
||||
|
||||
config.py의 validate_config() 함수에 추가된 검증 로직을 테스트합니다:
|
||||
1. Auto Trade 활성화 시 API 키 필수
|
||||
2. 손절/익절 주기 논리 검증 (경고)
|
||||
3. 스레드 수 범위 검증
|
||||
4. 최소 주문 금액 검증
|
||||
5. 매수 금액 검증
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.config import validate_config
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""설정 검증 로직 테스트"""
|
||||
|
||||
def test_valid_config_minimal(self):
|
||||
"""최소한의 필수 항목만 있는 유효한 설정"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {
|
||||
"enabled": False,
|
||||
"buy_enabled": False,
|
||||
},
|
||||
"confirm": {
|
||||
"confirm_stop_loss": False,
|
||||
},
|
||||
"max_threads": 3,
|
||||
}
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_missing_required_key(self):
|
||||
"""필수 항목 누락 시 검증 실패"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
# "stop_loss_check_interval_minutes": 60, # 누락
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {},
|
||||
}
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "stop_loss_check_interval_minutes" in error
|
||||
|
||||
def test_invalid_interval_value(self):
|
||||
"""잘못된 간격 값 (0 이하)"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 0, # 잘못된 값
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
}
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "buy_check_interval_minutes" in error
|
||||
|
||||
def test_auto_trade_without_api_keys(self):
|
||||
"""HIGH-002-1: auto_trade 활성화 시 API 키 없으면 실패"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": False,
|
||||
"auto_trade": {
|
||||
"enabled": True, # 활성화
|
||||
"buy_enabled": True,
|
||||
},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
# API 키 없는 상태로 테스트
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "UPBIT_ACCESS_KEY" in error or "UPBIT_SECRET_KEY" in error
|
||||
|
||||
def test_auto_trade_with_api_keys(self):
|
||||
"""HIGH-002-1: auto_trade 활성화 + API 키 있으면 성공"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": False,
|
||||
"auto_trade": {
|
||||
"enabled": True,
|
||||
"buy_enabled": True,
|
||||
},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
# API 키 있는 상태로 테스트
|
||||
with patch.dict(os.environ, {"UPBIT_ACCESS_KEY": "test_key", "UPBIT_SECRET_KEY": "test_secret"}):
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_stop_loss_interval_greater_than_profit(self, caplog):
|
||||
"""HIGH-002-2: 손절 주기 > 익절 주기 시 경고 로그"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 300, # 5시간
|
||||
"profit_taking_check_interval_minutes": 60, # 1시간
|
||||
"dry_run": True,
|
||||
"auto_trade": {"enabled": False},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True # 검증은 통과 (경고만 출력)
|
||||
|
||||
# 경고 로그 확인
|
||||
assert any("손절 주기" in record.message for record in caplog.records)
|
||||
|
||||
def test_max_threads_invalid_type(self):
|
||||
"""HIGH-002-3: max_threads가 정수가 아니면 실패"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {"enabled": False},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": "invalid", # 잘못된 타입
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "max_threads" in error
|
||||
|
||||
def test_max_threads_too_high(self, caplog):
|
||||
"""HIGH-002-3: max_threads > 10 시 경고"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {"enabled": False},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 15, # 과도한 스레드
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True # 검증은 통과 (경고만)
|
||||
|
||||
# 경고 로그 확인
|
||||
assert any("max_threads" in record.message and "과도" in record.message for record in caplog.records)
|
||||
|
||||
def test_min_order_value_too_low(self):
|
||||
"""HIGH-002-4: 최소 주문 금액 < 5000원 시 실패"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {
|
||||
"enabled": False,
|
||||
"min_order_value_krw": 3000, # 너무 낮음
|
||||
},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "min_order_value_krw" in error
|
||||
assert "5000" in error
|
||||
|
||||
def test_buy_amount_less_than_min_order(self, caplog):
|
||||
"""HIGH-002-5: buy_amount < min_order_value 시 경고"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {
|
||||
"enabled": False,
|
||||
"min_order_value_krw": 10000,
|
||||
"buy_amount_krw": 5000, # min_order보다 작음
|
||||
},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True # 검증은 통과 (경고만)
|
||||
|
||||
# 경고 로그 확인
|
||||
assert any(
|
||||
"buy_amount_krw" in record.message and "min_order_value_krw" in record.message for record in caplog.records
|
||||
)
|
||||
|
||||
def test_buy_amount_too_low(self):
|
||||
"""HIGH-002-5: buy_amount_krw < 5000원 시 실패"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {
|
||||
"enabled": False,
|
||||
"buy_amount_krw": 3000, # 너무 낮음
|
||||
},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "buy_amount_krw" in error
|
||||
assert "5000" in error
|
||||
|
||||
def test_confirm_invalid_type(self):
|
||||
"""confirm 설정이 딕셔너리가 아니면 실패"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {"enabled": False},
|
||||
"confirm": "invalid", # 잘못된 타입
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "confirm" in error
|
||||
|
||||
def test_dry_run_invalid_type(self):
|
||||
"""dry_run이 boolean이 아니면 실패"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": "yes", # 잘못된 타입
|
||||
"auto_trade": {"enabled": False},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False
|
||||
assert "dry_run" in error
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""경계값 및 엣지 케이스 테스트"""
|
||||
|
||||
def test_intervals_equal_one(self):
|
||||
"""간격이 정확히 1일 때 (최소값)"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 1,
|
||||
"stop_loss_check_interval_minutes": 1,
|
||||
"profit_taking_check_interval_minutes": 1,
|
||||
"dry_run": True,
|
||||
"auto_trade": {"enabled": False},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 1,
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True
|
||||
|
||||
def test_max_threads_equal_ten(self):
|
||||
"""max_threads가 정확히 10일 때 (경계값)"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {"enabled": False},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 10, # 경계값
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True # 10은 허용 (경고 없음)
|
||||
|
||||
def test_min_order_equal_5000(self):
|
||||
"""최소 주문 금액이 정확히 5000원일 때"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": True,
|
||||
"auto_trade": {
|
||||
"enabled": False,
|
||||
"min_order_value_krw": 5000, # 최소값
|
||||
},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is True
|
||||
|
||||
def test_only_buy_enabled_without_enabled(self):
|
||||
"""enabled=False, buy_enabled=True일 때도 API 키 체크"""
|
||||
cfg = {
|
||||
"buy_check_interval_minutes": 240,
|
||||
"stop_loss_check_interval_minutes": 60,
|
||||
"profit_taking_check_interval_minutes": 240,
|
||||
"dry_run": False,
|
||||
"auto_trade": {
|
||||
"enabled": False,
|
||||
"buy_enabled": True, # buy만 활성화
|
||||
},
|
||||
"confirm": {"confirm_stop_loss": False},
|
||||
"max_threads": 3,
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
is_valid, error = validate_config(cfg)
|
||||
assert is_valid is False # API 키 필수
|
||||
assert "UPBIT" in error
|
||||
51
src/tests/test_file_queues.py
Normal file
51
src/tests/test_file_queues.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Tests for file-based queues: pending_orders TTL and recent_sells cleanup."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import src.common as common
|
||||
import src.order as order
|
||||
|
||||
|
||||
def test_pending_orders_ttl_cleanup(tmp_path, monkeypatch):
|
||||
pending_file = tmp_path / "pending_orders.json"
|
||||
monkeypatch.setattr(order, "PENDING_ORDERS_FILE", str(pending_file))
|
||||
|
||||
# Seed with stale entry (older than TTL 24h)
|
||||
stale_ts = time.time() - (25 * 3600)
|
||||
with open(pending_file, "w", encoding="utf-8") as f:
|
||||
json.dump([{"token": "old", "order": {}, "timestamp": stale_ts}], f)
|
||||
|
||||
# Write new entry
|
||||
order._write_pending_order("new", {"x": 1}, pending_file=str(pending_file))
|
||||
|
||||
with open(pending_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
tokens = {entry["token"] for entry in data}
|
||||
assert "old" not in tokens # stale removed
|
||||
assert "new" in tokens
|
||||
|
||||
|
||||
def test_recent_sells_ttl_cleanup(tmp_path, monkeypatch):
|
||||
recent_file = tmp_path / "recent_sells.json"
|
||||
monkeypatch.setattr(common, "RECENT_SELLS_FILE", str(recent_file))
|
||||
|
||||
# Seed with stale entry older than 48h (2x default cooldown)
|
||||
stale_ts = time.time() - (49 * 3600)
|
||||
fresh_ts = time.time() - (1 * 3600)
|
||||
with open(recent_file, "w", encoding="utf-8") as f:
|
||||
json.dump({"KRW-BTC": stale_ts, "KRW-ETH": fresh_ts}, f)
|
||||
|
||||
can_buy_eth = common.can_buy("KRW-ETH", cooldown_hours=24)
|
||||
can_buy_btc = common.can_buy("KRW-BTC", cooldown_hours=24)
|
||||
|
||||
# ETH still in cooldown (fresh timestamp)
|
||||
assert can_buy_eth is False
|
||||
# BTC stale entry pruned -> allowed to buy
|
||||
assert can_buy_btc is True
|
||||
|
||||
with open(recent_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
assert "KRW-BTC" not in data
|
||||
assert "KRW-ETH" in data
|
||||
91
src/tests/test_holdings_cache.py
Normal file
91
src/tests/test_holdings_cache.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Cache and retry tests for holdings price/balance fetch."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src import holdings
|
||||
|
||||
|
||||
def _reset_caches():
|
||||
with holdings._cache_lock: # type: ignore[attr-defined]
|
||||
holdings._price_cache.clear() # type: ignore[attr-defined]
|
||||
holdings._balance_cache = ({}, 0.0) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_get_current_price_cache_hit():
|
||||
_reset_caches()
|
||||
with patch("src.holdings.pyupbit.get_current_price", return_value=123.0) as mock_get:
|
||||
price1 = holdings.get_current_price("KRW-BTC")
|
||||
price2 = holdings.get_current_price("KRW-BTC")
|
||||
assert price1 == price2 == 123.0
|
||||
assert mock_get.call_count == 1 # cached on second call
|
||||
|
||||
|
||||
def test_get_current_price_retries_until_success():
|
||||
import requests
|
||||
|
||||
_reset_caches()
|
||||
side_effect = [None, requests.exceptions.Timeout("temporary"), 45678.9]
|
||||
|
||||
def _side_effect(*args, **kwargs):
|
||||
val = side_effect.pop(0)
|
||||
if isinstance(val, Exception):
|
||||
raise val
|
||||
return val
|
||||
|
||||
with patch("src.holdings.pyupbit.get_current_price", side_effect=_side_effect) as mock_get:
|
||||
price = holdings.get_current_price("BTC")
|
||||
assert price == 45678.9
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
|
||||
def test_get_upbit_balances_cache_hit():
|
||||
_reset_caches()
|
||||
cfg = SimpleNamespace(upbit_access_key="k", upbit_secret_key="s")
|
||||
|
||||
mock_balances = [
|
||||
{"currency": "BTC", "balance": "1.0"},
|
||||
{"currency": "KRW", "balance": "10000"},
|
||||
]
|
||||
|
||||
with patch("src.holdings.pyupbit.Upbit") as mock_upbit_cls:
|
||||
mock_upbit = MagicMock()
|
||||
mock_upbit.get_balances.return_value = mock_balances
|
||||
mock_upbit_cls.return_value = mock_upbit
|
||||
|
||||
first = holdings.get_upbit_balances(cfg)
|
||||
second = holdings.get_upbit_balances(cfg)
|
||||
|
||||
assert first == {"BTC": 1.0}
|
||||
assert second == {"BTC": 1.0}
|
||||
assert mock_upbit.get_balances.call_count == 1 # second served from cache
|
||||
|
||||
|
||||
def test_get_upbit_balances_retry_on_error_then_success():
|
||||
import requests
|
||||
|
||||
_reset_caches()
|
||||
cfg = SimpleNamespace(upbit_access_key="k", upbit_secret_key="s")
|
||||
|
||||
call_returns = [
|
||||
requests.exceptions.ConnectionError("net"),
|
||||
[
|
||||
{"currency": "ETH", "balance": "2"},
|
||||
],
|
||||
]
|
||||
|
||||
def _side_effect():
|
||||
val = call_returns.pop(0)
|
||||
if isinstance(val, Exception):
|
||||
raise val
|
||||
return val
|
||||
|
||||
with patch("src.holdings.pyupbit.Upbit") as mock_upbit_cls:
|
||||
mock_upbit = MagicMock()
|
||||
mock_upbit.get_balances.side_effect = _side_effect
|
||||
mock_upbit_cls.return_value = mock_upbit
|
||||
|
||||
result = holdings.get_upbit_balances(cfg)
|
||||
|
||||
assert result == {"ETH": 2.0}
|
||||
assert mock_upbit.get_balances.call_count == 2
|
||||
314
src/tests/test_krw_budget_manager.py
Normal file
314
src/tests/test_krw_budget_manager.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
KRWBudgetManager 테스트
|
||||
멀티스레드 환경에서 KRW 잔고 경쟁 조건을 방지하는 예산 할당 시스템 검증
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import KRWBudgetManager
|
||||
|
||||
|
||||
class MockUpbit:
|
||||
"""Upbit 모의 객체"""
|
||||
|
||||
def __init__(self, initial_balance: float):
|
||||
self.balance = initial_balance
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def get_balance(self, currency: str) -> float:
|
||||
"""KRW 잔고 조회 (스레드 안전)"""
|
||||
with self.lock:
|
||||
return self.balance
|
||||
|
||||
def buy(self, amount_krw: float):
|
||||
"""매수 시뮬레이션 (잔고 감소)"""
|
||||
with self.lock:
|
||||
if self.balance >= amount_krw:
|
||||
self.balance -= amount_krw
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TestKRWBudgetManager:
|
||||
"""KRWBudgetManager 단위 테스트"""
|
||||
|
||||
def test_allocate_success_full_amount(self):
|
||||
"""전액 할당 성공 테스트"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(100000)
|
||||
|
||||
success, allocated, token = manager.allocate("KRW-BTC", 50000, upbit)
|
||||
|
||||
assert success is True
|
||||
assert token is not None
|
||||
assert allocated == 50000
|
||||
assert manager.get_allocations() == {"KRW-BTC": 50000}
|
||||
|
||||
def test_allocate_success_partial_amount(self):
|
||||
"""부분 할당 성공 테스트 (잔고 부족)"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(30000)
|
||||
|
||||
success, allocated, token = manager.allocate("KRW-BTC", 50000, upbit)
|
||||
|
||||
assert success is True
|
||||
assert token is not None
|
||||
assert allocated == 30000 # 가능한 만큼만 할당
|
||||
assert manager.get_allocations() == {"KRW-BTC": 30000}
|
||||
|
||||
def test_allocate_failure_insufficient_balance(self):
|
||||
"""할당 실패 테스트 (잔고 없음)"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(0)
|
||||
|
||||
success, allocated, token = manager.allocate("KRW-BTC", 10000, upbit)
|
||||
|
||||
assert success is False
|
||||
assert token is None
|
||||
assert allocated == 0
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
def test_allocate_multiple_symbols(self):
|
||||
"""여러 심볼 동시 할당 테스트"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(100000)
|
||||
|
||||
# BTC 할당
|
||||
success1, allocated1, token1 = manager.allocate("KRW-BTC", 40000, upbit)
|
||||
assert success1 is True
|
||||
assert allocated1 == 40000
|
||||
|
||||
# ETH 할당 (남은 잔고: 60000)
|
||||
success2, allocated2, token2 = manager.allocate("KRW-ETH", 30000, upbit)
|
||||
assert success2 is True
|
||||
assert allocated2 == 30000
|
||||
|
||||
# XRP 할당 (남은 잔고: 30000)
|
||||
success3, allocated3, token3 = manager.allocate("KRW-XRP", 40000, upbit)
|
||||
assert success3 is True
|
||||
assert allocated3 == 30000 # 부분 할당
|
||||
|
||||
allocations = manager.get_allocations()
|
||||
assert allocations["KRW-BTC"] == 40000
|
||||
assert allocations["KRW-ETH"] == 30000
|
||||
assert allocations["KRW-XRP"] == 30000
|
||||
|
||||
def test_allocate_same_symbol_multiple_orders(self):
|
||||
"""동일 심볼 복수 주문 시에도 개별 토큰으로 관리"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(100000)
|
||||
|
||||
success1, alloc1, token1 = manager.allocate("KRW-BTC", 70000, upbit)
|
||||
success2, alloc2, token2 = manager.allocate("KRW-BTC", 70000, upbit)
|
||||
|
||||
assert success1 is True
|
||||
assert success2 is True
|
||||
assert token1 != token2
|
||||
assert alloc1 == 70000
|
||||
assert alloc2 == 30000
|
||||
|
||||
allocations = manager.get_allocations()
|
||||
assert allocations["KRW-BTC"] == 100000
|
||||
|
||||
manager.release(token1)
|
||||
manager.release(token2)
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
def test_release(self):
|
||||
"""예산 해제 테스트"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(100000)
|
||||
|
||||
# 할당
|
||||
_, _, token = manager.allocate("KRW-BTC", 50000, upbit)
|
||||
assert manager.get_allocations() == {"KRW-BTC": 50000}
|
||||
|
||||
manager.release(token)
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
success, allocated, token2 = manager.allocate("KRW-ETH", 50000, upbit)
|
||||
assert success is True
|
||||
assert allocated == 50000
|
||||
|
||||
def test_release_nonexistent_symbol(self):
|
||||
"""존재하지 않는 심볼 해제 테스트 (오류 없어야 함)"""
|
||||
manager = KRWBudgetManager()
|
||||
|
||||
# 오류 없이 실행되어야 함
|
||||
manager.release("nonexistent-token")
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
def test_clear(self):
|
||||
"""전체 초기화 테스트"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(100000)
|
||||
|
||||
manager.allocate("KRW-BTC", 30000, upbit)
|
||||
manager.allocate("KRW-ETH", 20000, upbit)
|
||||
assert len(manager.get_allocations()) == 2
|
||||
|
||||
manager.clear()
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
|
||||
class TestKRWBudgetManagerConcurrency:
|
||||
"""KRWBudgetManager 동시성 테스트 (멀티스레드 환경)"""
|
||||
|
||||
def test_concurrent_allocate_no_race_condition(self):
|
||||
"""동시 할당 시 Race Condition 방지 테스트"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(100000)
|
||||
|
||||
results = []
|
||||
|
||||
def allocate_worker(symbol: str, amount: float):
|
||||
"""워커 스레드: 예산 할당 시도"""
|
||||
success, allocated, _ = manager.allocate(symbol, amount, upbit)
|
||||
results.append((symbol, success, allocated))
|
||||
|
||||
# 3개 스레드가 동시에 50000원씩 요청 (총 150000원 > 잔고 100000원)
|
||||
threads = [threading.Thread(target=allocate_worker, args=(f"KRW-COIN{i}", 50000)) for i in range(3)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 검증: 할당된 총액이 실제 잔고(100000)를 초과하지 않아야 함
|
||||
total_allocated = sum(allocated for _, success, allocated in results if success)
|
||||
assert total_allocated <= 100000
|
||||
|
||||
# 검증: 최소 2개는 성공, 1개는 실패 또는 부분 할당
|
||||
successful = [r for r in results if r[1] is True]
|
||||
assert len(successful) >= 2
|
||||
|
||||
def test_concurrent_allocate_and_release(self):
|
||||
"""할당과 해제가 동시에 발생하는 테스트"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(100000)
|
||||
|
||||
order_log = []
|
||||
|
||||
def buy_order(symbol: str, amount: float, delay: float = 0):
|
||||
"""매수 주문 시뮬레이션"""
|
||||
success, allocated, token = manager.allocate(symbol, amount, upbit)
|
||||
if success:
|
||||
order_log.append((symbol, "allocated", allocated))
|
||||
time.sleep(delay) # 주문 처리 시간 시뮬레이션
|
||||
manager.release(token)
|
||||
order_log.append((symbol, "released", allocated))
|
||||
|
||||
# Thread 1: BTC 매수 (50000원, 0.1초 처리)
|
||||
# Thread 2: ETH 매수 (60000원, 0.05초 처리)
|
||||
# Thread 3: XRP 매수 (40000원, 즉시 처리)
|
||||
threads = [
|
||||
threading.Thread(target=buy_order, args=("KRW-BTC", 50000, 0.1)),
|
||||
threading.Thread(target=buy_order, args=("KRW-ETH", 60000, 0.05)),
|
||||
threading.Thread(target=buy_order, args=("KRW-XRP", 40000, 0)),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 검증: 모든 할당에 대응하는 해제가 있어야 함
|
||||
allocations = [log for log in order_log if log[1] == "allocated"]
|
||||
releases = [log for log in order_log if log[1] == "released"]
|
||||
assert len(allocations) == len(releases)
|
||||
|
||||
# 검증: 최종 할당 상태는 비어있어야 함
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
def test_stress_test_many_threads(self):
|
||||
"""스트레스 테스트: 10개 스레드 동시 실행"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(1000000) # 100만원 초기 잔고
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def worker(thread_id: int):
|
||||
"""워커 스레드"""
|
||||
try:
|
||||
for i in range(5): # 각 스레드당 5번 할당 시도
|
||||
symbol = f"KRW-COIN{thread_id}-{i}"
|
||||
success, allocated, token = manager.allocate(symbol, 50000, upbit)
|
||||
|
||||
if success:
|
||||
results.append((thread_id, symbol, allocated))
|
||||
time.sleep(0.01) # 주문 처리 시뮬레이션
|
||||
manager.release(token)
|
||||
except Exception as e:
|
||||
errors.append((thread_id, str(e)))
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 검증: 오류가 없어야 함
|
||||
assert len(errors) == 0, f"스레드 실행 중 오류 발생: {errors}"
|
||||
|
||||
# 검증: 최종 할당 상태는 비어있어야 함
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
# 검증: 모든 할당이 잔고 범위 내에서 수행되었어야 함
|
||||
# (동시에 할당된 총액이 100만원을 초과하지 않음)
|
||||
print(f"총 {len(results)}건의 할당 성공")
|
||||
|
||||
|
||||
class TestKRWBudgetManagerIntegration:
|
||||
"""통합 테스트: 실제 주문 플로우 시뮬레이션"""
|
||||
|
||||
def test_realistic_trading_scenario(self):
|
||||
"""실제 거래 시나리오: 여러 코인 순차/병렬 매수"""
|
||||
manager = KRWBudgetManager()
|
||||
upbit = MockUpbit(500000) # 50만원 초기 잔고
|
||||
|
||||
# 시나리오 1: BTC 20만원 매수
|
||||
success1, allocated1, token1 = manager.allocate("KRW-BTC", 200000, upbit)
|
||||
assert success1 is True
|
||||
assert allocated1 == 200000
|
||||
upbit.buy(allocated1) # 실제 잔고 차감 (300000 남음)
|
||||
manager.release(token1)
|
||||
|
||||
# 시나리오 2: ETH와 XRP 동시 매수 시도 (각 20만원)
|
||||
results = []
|
||||
|
||||
def buy_worker(symbol, amount):
|
||||
success, allocated, token = manager.allocate(symbol, amount, upbit)
|
||||
if success:
|
||||
upbit.buy(allocated)
|
||||
results.append((symbol, allocated))
|
||||
manager.release(token)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=buy_worker, args=("KRW-ETH", 200000)),
|
||||
threading.Thread(target=buy_worker, args=("KRW-XRP", 200000)),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 검증: 잔고(300000)로는 2개 중 1.5개만 살 수 있음
|
||||
total_bought = sum(allocated for _, allocated in results)
|
||||
assert total_bought <= 300000
|
||||
|
||||
# 검증: 최종 잔고 확인
|
||||
final_balance = upbit.get_balance("KRW")
|
||||
assert final_balance == 500000 - 200000 - total_bought
|
||||
|
||||
# 검증: 할당 상태는 비어있어야 함
|
||||
assert manager.get_allocations() == {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -1,15 +1,11 @@
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
import builtins
|
||||
import types
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import main
|
||||
from .test_helpers import check_and_notify, safe_send_telegram
|
||||
from .test_helpers import check_and_notify
|
||||
|
||||
|
||||
def test_compute_macd_hist_monkeypatch(monkeypatch):
|
||||
@@ -19,7 +15,8 @@ def test_compute_macd_hist_monkeypatch(monkeypatch):
|
||||
def fake_macd(series, fast, slow, signal):
|
||||
return dummy_macd
|
||||
|
||||
monkeypatch.setattr(main.ta, "macd", fake_macd)
|
||||
# 올바른 모듈 경로로 monkey patch
|
||||
monkeypatch.setattr("src.indicators.ta.macd", fake_macd)
|
||||
|
||||
close = pd.Series([1, 2, 3, 4])
|
||||
|
||||
@@ -61,7 +58,9 @@ def test_check_and_notify_positive_sends(monkeypatch):
|
||||
signal_values = [0.5] * len(close) # Constant signal line
|
||||
macd_df["MACD_12_26_9"] = pd.Series(macd_values, index=close.index)
|
||||
macd_df["MACDs_12_26_9"] = pd.Series(signal_values, index=close.index)
|
||||
macd_df["MACDh_12_26_9"] = pd.Series([v - s for v, s in zip(macd_values, signal_values)], index=close.index)
|
||||
macd_df["MACDh_12_26_9"] = pd.Series(
|
||||
[v - s for v, s in zip(macd_values, signal_values, strict=True)], index=close.index
|
||||
)
|
||||
return macd_df
|
||||
|
||||
monkeypatch.setattr(signals.ta, "macd", fake_macd)
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestPlaceBuyOrderValidation:
|
||||
|
||||
assert result["status"] == "simulated"
|
||||
assert result["market"] == "KRW-BTC"
|
||||
assert result["amount_krw"] == 100000
|
||||
assert result["amount_krw"] == 99950.0
|
||||
|
||||
def test_buy_order_below_min_amount(self):
|
||||
"""Test buy order rejected for amount below minimum."""
|
||||
@@ -172,7 +172,8 @@ class TestBuyOrderResponseValidation:
|
||||
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)
|
||||
with patch("src.common.krw_budget_manager.allocate", return_value=(True, 100000, "tok")):
|
||||
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "invalid_response_type"
|
||||
@@ -195,7 +196,8 @@ class TestBuyOrderResponseValidation:
|
||||
}
|
||||
|
||||
with patch("src.order.adjust_price_to_tick_size", return_value=50000000):
|
||||
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
|
||||
with patch("src.common.krw_budget_manager.allocate", return_value=(True, 100000, "tok")):
|
||||
result = place_buy_order_upbit("KRW-BTC", 100000, cfg)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "order_rejected"
|
||||
|
||||
41
src/tests/test_recent_sells.py
Normal file
41
src/tests/test_recent_sells.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from src import common
|
||||
|
||||
|
||||
def _setup_recent_sells(tmp_path, monkeypatch):
|
||||
path = tmp_path / "recent_sells.json"
|
||||
monkeypatch.setattr(common, "RECENT_SELLS_FILE", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def test_recent_sells_atomic_write_and_cooldown(tmp_path, monkeypatch):
|
||||
path = _setup_recent_sells(tmp_path, monkeypatch)
|
||||
|
||||
common.record_sell("KRW-ATOM")
|
||||
assert path.exists()
|
||||
assert common.can_buy("KRW-ATOM", cooldown_hours=1) is False
|
||||
|
||||
with common.recent_sells_lock:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data["KRW-ATOM"] = time.time() - 7200 # 2시간 전으로 설정해 쿨다운 만료
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
assert common.can_buy("KRW-ATOM", cooldown_hours=1) is True
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def test_recent_sells_recovers_from_corruption(tmp_path, monkeypatch):
|
||||
path = _setup_recent_sells(tmp_path, monkeypatch)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("{invalid json}")
|
||||
|
||||
assert common.can_buy("KRW-NEO", cooldown_hours=1) is True
|
||||
|
||||
backups = list(tmp_path.glob("recent_sells.json.corrupted.*"))
|
||||
assert backups, "손상된 recent_sells 백업이 생성되어야 합니다"
|
||||
assert not path.exists(), "손상 파일은 백업 후 제거되어야 합니다"
|
||||
46
src/tests/test_state_reconciliation.py
Normal file
46
src/tests/test_state_reconciliation.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Tests for reconciling state and holdings."""
|
||||
|
||||
from src import holdings, state_manager
|
||||
|
||||
|
||||
def _reset_state_and_holdings(tmp_path):
|
||||
# point files to temp paths
|
||||
holdings_file = tmp_path / "holdings.json"
|
||||
state_file = tmp_path / "bot_state.json"
|
||||
holdings.HOLDINGS_FILE = str(holdings_file) # type: ignore[attr-defined]
|
||||
state_manager.STATE_FILE = str(state_file) # type: ignore[attr-defined]
|
||||
# clear caches
|
||||
with holdings._cache_lock: # type: ignore[attr-defined]
|
||||
holdings._price_cache.clear() # type: ignore[attr-defined]
|
||||
holdings._balance_cache = ({}, 0.0) # type: ignore[attr-defined]
|
||||
return str(holdings_file)
|
||||
|
||||
|
||||
def test_state_fills_from_holdings(tmp_path, monkeypatch):
|
||||
holdings_file = _reset_state_and_holdings(tmp_path)
|
||||
# prepare holdings with max_price/partial
|
||||
data = {"KRW-BTC": {"buy_price": 100, "amount": 1.0, "max_price": 200, "partial_sell_done": True}}
|
||||
holdings.save_holdings(data, holdings_file)
|
||||
|
||||
merged = holdings.reconcile_state_and_holdings(holdings_file)
|
||||
|
||||
state = state_manager.load_state()
|
||||
assert merged["KRW-BTC"]["max_price"] == 200
|
||||
assert state["KRW-BTC"]["max_price"] == 200
|
||||
assert state["KRW-BTC"]["partial_sell_done"] is True
|
||||
|
||||
|
||||
def test_holdings_updated_from_state(tmp_path, monkeypatch):
|
||||
holdings_file = _reset_state_and_holdings(tmp_path)
|
||||
# initial holdings missing partial flag
|
||||
data = {"KRW-ETH": {"buy_price": 50, "amount": 2.0, "max_price": 70}}
|
||||
holdings.save_holdings(data, holdings_file)
|
||||
|
||||
# state has newer max_price and partial flag
|
||||
state = {"KRW-ETH": {"max_price": 90, "partial_sell_done": True}}
|
||||
state_manager.save_state(state)
|
||||
|
||||
merged = holdings.reconcile_state_and_holdings(holdings_file)
|
||||
|
||||
assert merged["KRW-ETH"]["max_price"] == 90
|
||||
assert merged["KRW-ETH"]["partial_sell_done"] is True
|
||||
Reference in New Issue
Block a user