- 스윙 체결가 확인 전 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
225 lines
8.9 KiB
Python
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
|