Files
auto-stock/backend/tests/test_risk_management_endpoints_r4.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

225 lines
8.9 KiB
Python

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