Files
cointrader/tests/test_risk_manager.py
21in7 181f82d3c0 fix: address critical code review issues (PnL double recording, sync HTTP, race conditions)
- fix(bot): prevent PnL double recording in _close_and_reenter using asyncio.Event
- fix(bot): prevent SYNC detection PnL duplication with _close_handled_by_sync flag
- fix(notifier): move sync HTTP call to background thread via run_in_executor
- fix(risk_manager): make is_trading_allowed async with lock for thread safety
- fix(exchange): cache exchange info at class level (1 API call for all symbols)
- fix(exchange): use `is not None` instead of truthy check for price/stop_price
- refactor(backtester): extract _calc_trade_stats to eliminate code duplication
- fix(ml_features): apply rolling z-score to OI/funding rate in serving (train-serve skew)
- fix(bot): use config.correlation_symbols instead of hardcoded BTCUSDT/ETHUSDT
- fix(bot): expand OI/funding history deque to 96 for z-score window
- cleanup(config): remove unused stop_loss_pct, take_profit_pct, trailing_stop_pct fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:03:52 +09:00

140 lines
4.1 KiB
Python

import pytest
import os
from src.risk_manager import RiskManager
from src.config import Config
@pytest.fixture
def config():
os.environ.update({
"BINANCE_API_KEY": "k",
"BINANCE_API_SECRET": "s",
"SYMBOL": "XRPUSDT",
"LEVERAGE": "10",
})
return Config()
@pytest.mark.asyncio
async def test_max_drawdown_check(config):
rm = RiskManager(config, max_daily_loss_pct=0.05)
rm.daily_pnl = -60.0
rm.initial_balance = 1000.0
assert await rm.is_trading_allowed() is False
@pytest.mark.asyncio
async def test_trading_allowed_normal(config):
rm = RiskManager(config, max_daily_loss_pct=0.05)
rm.daily_pnl = -10.0
rm.initial_balance = 1000.0
assert await rm.is_trading_allowed() is True
@pytest.mark.asyncio
async def test_position_size_capped(config):
rm = RiskManager(config, max_daily_loss_pct=0.05)
await rm.register_position("XRPUSDT", "LONG")
await rm.register_position("TRXUSDT", "SHORT")
await rm.register_position("DOGEUSDT", "LONG")
assert await rm.can_open_new_position("SOLUSDT", "SHORT") is False
# --- 동적 증거금 비율 테스트 ---
@pytest.fixture
def dynamic_config():
c = Config()
c.margin_max_ratio = 0.50
c.margin_min_ratio = 0.20
c.margin_decay_rate = 0.0006
return c
@pytest.fixture
def risk(dynamic_config):
r = RiskManager(dynamic_config)
r.set_base_balance(22.0)
return r
def test_set_base_balance(risk):
assert risk.initial_balance == 22.0
def test_ratio_at_base_balance(risk):
"""기준 잔고에서 최대 비율(50%) 반환"""
ratio = risk.get_dynamic_margin_ratio(22.0)
assert ratio == pytest.approx(0.50, abs=1e-6)
def test_ratio_decreases_as_balance_grows(risk):
"""잔고가 늘수록 비율 감소"""
ratio_100 = risk.get_dynamic_margin_ratio(100.0)
ratio_300 = risk.get_dynamic_margin_ratio(300.0)
assert ratio_100 < 0.50
assert ratio_300 < ratio_100
def test_ratio_clamped_at_min(risk):
"""잔고가 매우 커도 최소 비율(20%) 이하로 내려가지 않음"""
ratio = risk.get_dynamic_margin_ratio(10000.0)
assert ratio == pytest.approx(0.20, abs=1e-6)
def test_ratio_clamped_at_max(risk):
"""잔고가 기준보다 작아도 최대 비율(50%) 초과하지 않음"""
ratio = risk.get_dynamic_margin_ratio(5.0)
assert ratio == pytest.approx(0.50, abs=1e-6)
# --- 멀티심볼 공유 RiskManager 테스트 ---
@pytest.fixture
def shared_risk(config):
config.max_same_direction = 2
return RiskManager(config)
@pytest.mark.asyncio
async def test_can_open_new_position_async(shared_risk):
"""비동기 포지션 오픈 허용 체크."""
assert await shared_risk.can_open_new_position("XRPUSDT", "LONG") is True
@pytest.mark.asyncio
async def test_register_and_close_position(shared_risk):
"""포지션 등록 후 닫기."""
await shared_risk.register_position("XRPUSDT", "LONG")
assert "XRPUSDT" in shared_risk.open_positions
await shared_risk.close_position("XRPUSDT", pnl=1.5)
assert "XRPUSDT" not in shared_risk.open_positions
assert shared_risk.daily_pnl == 1.5
@pytest.mark.asyncio
async def test_same_symbol_blocked(shared_risk):
"""같은 심볼 중복 진입 차단."""
await shared_risk.register_position("XRPUSDT", "LONG")
assert await shared_risk.can_open_new_position("XRPUSDT", "SHORT") is False
@pytest.mark.asyncio
async def test_max_same_direction_limit(shared_risk):
"""같은 방향 2개 초과 차단."""
await shared_risk.register_position("XRPUSDT", "LONG")
await shared_risk.register_position("TRXUSDT", "LONG")
# 3번째 LONG 차단
assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False
# SHORT은 허용
assert await shared_risk.can_open_new_position("DOGEUSDT", "SHORT") is True
@pytest.mark.asyncio
async def test_max_positions_global_limit(shared_risk):
"""전체 포지션 수 한도 초과 차단."""
shared_risk.config.max_positions = 2
await shared_risk.register_position("XRPUSDT", "LONG")
await shared_risk.register_position("TRXUSDT", "SHORT")
assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False