"""risk_management 하드닝 R4: GET/check 는 DB 쓰기 금지, 호출자 잔고 신뢰 제거.""" import asyncio from decimal import Decimal from unittest.mock import MagicMock import pytest from app.api.endpoints import risk_management as rm from app.services.mdd_manager import mdd_manager _REAL_SWING_CALC = rm._swing_realized_today_v2 class _NoWriteDB: """add/commit/refresh 호출 시 즉시 예외(그럴듯한 값 반환 금지).""" def __init__(self, session=None): self._session = session async def execute(self, *_a, **_k): r = MagicMock() r.scalar_one_or_none.return_value = self._session return r def add(self, *_a, **_k): raise AssertionError("db.add 금지") async def commit(self): raise AssertionError("db.commit 금지") async def refresh(self, *_a, **_k): raise AssertionError("db.refresh 금지") def _sess(**kw): s = rm.TradingSession(session_date=rm._today_kst(), consecutive_losses=0, consecutive_wins=0, daily_profit_loss=Decimal("0"), daily_trades_count=0, is_paused=False) for k, v in kw.items(): setattr(s, k, v) return s @pytest.fixture(autouse=True) def _server_balance(monkeypatch): async def _none(_db, stock_code=None): return 0.0, 0 monkeypatch.setattr(rm, "_swing_realized_today_v2", _none) monkeypatch.setattr(mdd_manager, "_last_balance", 10_000_000.0, raising=False) def test_get_today_session_no_write_when_missing(): r = asyncio.run(rm.get_today_session(db=_NoWriteDB(None))) assert r["is_paused"] is False and r["daily_trades_count"] == 0 def test_check_limits_no_write_when_session_missing(): r = asyncio.run(rm.check_risk_limits(order_value=100_000, account_balance=10_000_000, strategy="swing", db=_NoWriteDB(None))) assert r["allowed"] is True def test_inflated_caller_balance_cannot_dilute_daily_loss(): s = _sess(daily_profit_loss=Decimal("-400000")) # 서버잔고 대비 -4% r = asyncio.run(rm.check_risk_limits(order_value=100_000, account_balance=1_000_000_000, strategy="swing", db=_NoWriteDB(s))) assert r["allowed"] is False and r["risk_level"] == "CRITICAL" assert s.is_paused is False # 영속/변경 없음 def test_inflated_balance_cannot_dilute_position_cap(): cap = rm.MAX_POSITION_SIZE_RATIO order = 10_000_000 * (cap + 0.02) r = asyncio.run(rm.check_risk_limits(order_value=order, account_balance=1_000_000_000, strategy="swing", db=_NoWriteDB(_sess()))) assert r["allowed"] is False and r["risk_level"] == "HIGH" def test_unverified_when_server_balance_unknown(monkeypatch): monkeypatch.setattr(mdd_manager, "_last_balance", 0.0, raising=False) bal, note = rm._resolve_account_balance(5_000_000) assert bal == 5_000_000 and note == "server_balance_unverified" # ---- R1: update-session 제거 + 서버측 가드 ---- def test_update_session_endpoint_removed(): paths = {getattr(r, "path", "") for r in rm.router.routes} assert "/update-session" not in paths assert not hasattr(rm, "update_trading_session") def test_mdd_paused_blocks(monkeypatch): monkeypatch.setattr(mdd_manager, "is_paused", True, raising=False) r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, strategy="swing", db=_NoWriteDB(None))) monkeypatch.setattr(mdd_manager, "is_paused", False, raising=False) assert r["allowed"] is False and r["risk_level"] == "BLOCKED" def test_dt_daily_loss_blocks_dt_only(monkeypatch): from app.services.daytrading.position_manager import day_trading_state as st from app.core.config import settings monkeypatch.setattr(mdd_manager, "is_paused", False, raising=False) monkeypatch.setattr(st, "last_total_assets", 10_000_000.0, raising=False) monkeypatch.setattr(st, "daily_pnl", 10_000_000.0 * (float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) - 0.001), raising=False) dt = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, strategy="daytrading", db=_NoWriteDB(None))) sw = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, strategy="swing", db=_NoWriteDB(None))) assert dt["allowed"] is False and sw["allowed"] is True def test_state_lookup_failure_fails_closed(monkeypatch): async def boom(): raise RuntimeError("x") monkeypatch.setattr(mdd_manager, "can_open_new_position", boom) r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, strategy="swing", db=_NoWriteDB(None))) assert r["allowed"] is False # ---- R3: 스윙 일일손실·연패를 TradeHistory 실계산으로 가드 ---- def test_swing_daily_loss_from_trade_history_blocks(monkeypatch): async def f(_db, stock_code=None): return -10_000_000.0 * (rm.MAX_DAILY_LOSS_RATIO + 0.01), 0 monkeypatch.setattr(rm, "_swing_realized_today_v2", f) r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, strategy="swing", db=_NoWriteDB(None))) assert r["allowed"] is False and r["risk_level"] == "CRITICAL" def test_swing_consecutive_losses_from_trade_history_blocks(monkeypatch): async def f(_db, stock_code=None): return 0.0, rm.MAX_CONSECUTIVE_LOSSES monkeypatch.setattr(rm, "_swing_realized_today_v2", f) r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, strategy="swing", db=_NoWriteDB(None))) assert r["allowed"] is False and r["risk_level"] == "CRITICAL" def test_swing_history_lookup_failure_fails_closed(monkeypatch): async def boom(_db, stock_code=None): raise RuntimeError("db down") monkeypatch.setattr(rm, "_swing_realized_today_v2", boom) r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, strategy="swing", db=_NoWriteDB(None))) assert r["allowed"] is False and r["risk_level"] == "BLOCKED" def _rows_db(rows): class _Res: def all(self): return rows class _DB: async def execute(self, *_a, **_k): return _Res() return _DB() _ROWS = [(Decimal("-100"), "A", "swing"), (Decimal("-50"), "B", "swing"), (Decimal("-7"), "A", "mdd_reduction"), (Decimal("-9"), "A", "manual"), (Decimal("-1"), "A", "swing"), (Decimal("30"), "A", "swing"), (Decimal("-9"), "A", "swing")] def test_swing_realized_calc_streak_is_per_stock_and_excludes_forced(): pnl, streak = asyncio.run(_REAL_SWING_CALC(_rows_db(_ROWS), stock_code="A")) assert pnl == -146.0 and streak == 2 # A swing: -100, -1 후 +30 에서 중단 def test_swing_realized_calc_no_global_streak_without_stock(): pnl, streak = asyncio.run(_REAL_SWING_CALC(_rows_db(_ROWS))) assert streak == 0 and pnl == -146.0 # ── R5: 리스크 상태는 mdd_manager 실상태를 반영한다 ───────────────────────── def test_mdd_status_v2_reflects_mdd_manager(monkeypatch): monkeypatch.setattr(mdd_manager, "peak_balance", 10_000_000.0) monkeypatch.setattr(mdd_manager, "_last_balance", 8_900_000.0, raising=False) monkeypatch.setattr(mdd_manager, "is_paused", True) monkeypatch.setattr(mdd_manager, "reduction_applied", "10pct") r = asyncio.run(rm.get_mdd_status_v2()) assert r["is_paused"] is True and r["reduction_applied"] == "10pct" assert r["drawdown_pct"] == 11.0 assert "update_health" in r def test_mdd_status_unknown_balance_is_none_not_zero(monkeypatch): monkeypatch.setattr(mdd_manager, "peak_balance", 0.0) monkeypatch.setattr(mdd_manager, "_last_balance", 0.0, raising=False) assert rm._mdd_state_v2()["drawdown_pct"] is None def test_session_today_reports_mdd_pause_when_no_session_row(monkeypatch): monkeypatch.setattr(mdd_manager, "is_paused", True) r = asyncio.run(rm.get_today_session(db=_NoWriteDB(None))) assert r["is_paused"] is True and r["pause_reason"] def test_risk_metrics_no_bogus_pct_level(): from datetime import datetime class _T: def __init__(self, pl, i): self.net_profit_loss = pl self.executed_at = datetime(2026, 10, 1, 9, i) self.created_at = self.executed_at class _R: def __init__(self, rows): self._rows = rows def scalars(self): return self def all(self): return self._rows class _DB: async def execute(self, *_a, **_k): return _R([_T(100, 1), _T(-300, 2), _T(-50, 3)]) r = asyncio.run(rm.get_risk_metrics(days=30, db=_DB())) assert r["max_drawdown_pct"] is None and r["risk_level"] is None assert "mdd_status" in r