"""하드닝 라운드 6 — 패턴메모리 증액 뒤 자본 가드 재적용 + DT 집계 예외 로깅. R6-1: 현금 유보(1-MAX_TOTAL_CAPITAL_USAGE)·스윙 상한(SWING_MAX_CAPITAL_RATIO)이 패턴메모리(최대 1.5배) 이전에만 적용돼 증액분이 두 상한을 넘을 수 있었다. R6-2: 스윙 상한 계산의 DT 투자액 집계 예외가 로그 없이 삼켜졌다(§4). 기대값은 settings 참조(§4.1). """ import ast import inspect import logging import textwrap import pytest from app.core.config import settings from app.services import trade_executor from app.services.sizing_core import clamp_by_ratios TOTAL = 10_000_000.0 def _swing_room(available_cash: float, dt_amount: float = 0.0) -> float: invested = max(0.0, TOTAL - available_cash - dt_amount) return TOTAL * settings.SWING_MAX_CAPITAL_RATIO - invested def _pipeline(available_cash: float, base: float, uplift: float, dt_amount: float = 0.0) -> float: """_execute_buy 의 가드 순서를 재현: 사전 가드 → 패턴메모리 → 4% → 재적용.""" reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) amt = base if available_cash - amt < reserve: amt = max(0, available_cash - reserve) room = _swing_room(available_cash, dt_amount) amt = min(amt, max(0.0, room)) amt = amt * uplift # 패턴메모리 증액 amt = clamp_by_ratios(amt, TOTAL, [settings.MAX_POSITION_SIZE], available_cash) return trade_executor._reapply_capital_guards_v2(amt, available_cash, reserve, room) def test_cash_reserve_holds_after_pattern_uplift(): reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) # 유보선 바로 위 현금 — 사전 가드가 금액을 유보 한도까지 줄인 상태 available = reserve + TOTAL * settings.MAX_POSITION_SIZE * 0.5 final = _pipeline(available, TOTAL * settings.MAX_POSITION_SIZE, 1.5) assert available - final >= reserve - 1e-6 def test_swing_cap_holds_after_pattern_uplift(): # 스윙 투자액이 상한 바로 아래 — 잔여 한도가 4% 의 절반 room_target = TOTAL * settings.MAX_POSITION_SIZE * 0.5 available = TOTAL - (TOTAL * settings.SWING_MAX_CAPITAL_RATIO - room_target) final = _pipeline(available, TOTAL * settings.MAX_POSITION_SIZE, 1.5) assert final <= _swing_room(available) + 1e-6 def test_reapply_none_swing_room_only_cash_reserve(): out = trade_executor._reapply_capital_guards_v2(500_000.0, 1_000_000.0, 600_000.0, None) assert out == pytest.approx(400_000.0) def test_reapply_negative_bounds_return_zero(): assert trade_executor._reapply_capital_guards_v2(100.0, 10.0, 50.0, -5.0) == 0.0 def test_reapply_is_outside_try_after_pattern_memory(): fn = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_buy))).body[0] body = fn.body pm_idx = next( i for i, s in enumerate(body) if isinstance(s, ast.Try) and "get_pattern_adjusted_invest_amount" in ast.unparse(s) ) assert "_reapply_capital_guards_v2" not in ast.unparse(body[pm_idx]) after = [s for s in body[pm_idx + 1:] if not isinstance(s, ast.Try)] assert any("_reapply_capital_guards_v2" in ast.unparse(s) for s in after) class _ExplodingPositions: def values(self): raise RuntimeError("dt state broken") def test_dt_amount_exception_logs_warning(monkeypatch, caplog): from app.services.daytrading import position_manager monkeypatch.setattr(position_manager.day_trading_state, "positions", _ExplodingPositions()) with caplog.at_level(logging.WARNING): assert trade_executor._dt_invested_amount_v2("005930") == 0.0 assert any("DT 투자액 집계 실패" in r.getMessage() for r in caplog.records) assert all(r.levelno >= logging.WARNING for r in caplog.records if "DT 투자액" in r.getMessage()) def test_dt_amount_sums_positions(monkeypatch): from app.services.daytrading import position_manager monkeypatch.setattr( position_manager.day_trading_state, "positions", {"A": {"entry_price": 10_000, "quantity": 3}, "B": {"entry_price": "5000", "quantity": 2}}, ) assert trade_executor._dt_invested_amount_v2("X") == pytest.approx(40_000.0) def test_execute_buy_has_no_inline_dt_silent_except(): src = inspect.getsource(trade_executor._execute_buy) assert "_dt_invested_amount_v2(" in src assert "_dt_amount = 0.0" not in src