테스트 강화 및 코드 품질 개선

This commit is contained in:
2025-12-17 00:01:46 +09:00
parent 37a150bd0d
commit 00c57ddd32
51 changed files with 10670 additions and 217 deletions

View File

@@ -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):
"""부분 매도 이미 완료된 경우 중복 발동 안됨"""

View 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"])

View 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

View 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

View 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

View 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"])

View File

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

View File

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

View 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(), "손상 파일은 백업 후 제거되어야 합니다"

View 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