"""종목당 4% 절대 상한(MAX_POSITION_SIZE) 회귀 테스트 — CLAUDE.md §5.1 / §4. 배경(2026-09-27): 패턴메모리 조정 뒤의 상한 재클램프가 `try` 블록 **안**에 있어 `get_pattern_adjusted_invest_amount()` 가 예외를 던지면 클램프를 건너뛰고 적응형 포지션(6~7%)이 그대로 주문 금액이 됐다. §4 는 "리스크 가드·한도 클램프를 `try` 안에 두지 말 것" 으로 이것을 규칙화했다. 여기서는 두 층을 검증한다. 1) 구조: `_execute_buy` 의 절대 상한 클램프가 패턴메모리 `try` **밖**에 있다 (예외 발생 케이스를 실제 주문 경로 없이 확정적으로 잡는다). 2) 수치: 적응형 Normal(6%)·Aggressive(7%) 산출액이 클램프 후 총자산 대비 MAX_POSITION_SIZE 를 넘지 않는다. 기대값은 전부 `settings.*` 참조다(§4.1 — config 값 하드코딩 금지). """ import ast import inspect import pytest from app.core.config import settings from app.services import trade_executor from app.services.sizing_core import clamp_by_ratios # ── 1) 구조 검증 ──────────────────────────────────────────────────────────── def _execute_buy_ast() -> ast.AST: src = inspect.getsource(trade_executor._execute_buy) return ast.parse(ast.unparse(ast.parse(_dedent(src)))) def _dedent(src: str) -> str: import textwrap return textwrap.dedent(src) def _contains_call(node: ast.AST, name: str) -> bool: return any( isinstance(n, ast.Call) and ( (isinstance(n.func, ast.Name) and n.func.id == name) or (isinstance(n.func, ast.Attribute) and n.func.attr == name) ) for n in ast.walk(node) ) def _mentions_max_position_size(node: ast.AST) -> bool: return any( isinstance(n, ast.Attribute) and n.attr == "MAX_POSITION_SIZE" for n in ast.walk(node) ) def test_pattern_memory_clamp_is_outside_try(): """패턴메모리 호출은 try 안, 절대 상한 클램프는 그 try 뒤의 문장이어야 한다.""" tree = _execute_buy_ast() fn = tree.body[0] assert isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)) body = fn.body pm_try_idx = None for idx, stmt in enumerate(body): if isinstance(stmt, ast.Try) and _contains_call(stmt, "get_pattern_adjusted_invest_amount"): pm_try_idx = idx break assert pm_try_idx is not None, "패턴메모리 조정 try 블록을 찾지 못했다" # try 블록 안에는 절대 상한 클램프가 없어야 한다 assert not ( _contains_call(body[pm_try_idx], "clamp_by_ratios") and _mentions_max_position_size(body[pm_try_idx]) ), "절대 상한 클램프가 패턴메모리 try 안에 있다 (예외 시 가드가 통과된다)" # try 이후 문장 중에 MAX_POSITION_SIZE 클램프가 있어야 한다 after = body[pm_try_idx + 1:] assert any( _contains_call(s, "clamp_by_ratios") and _mentions_max_position_size(s) for s in after ), "패턴메모리 try 뒤에 MAX_POSITION_SIZE 재클램프가 없다" def test_adaptive_position_cap_is_clamped_at_source(): """적응형 position_size 를 소비할 때 min(..., MAX_POSITION_SIZE) 가 적용된다. [2026-10-02 §4] 클램프가 try 안에 있으면 예외 시 건너뛰므로 try 밖에서 무조건 적용되는 형태로 바뀌었다(동작 검증은 test_swing_safety_20261002.py). """ src = inspect.getsource(trade_executor._execute_buy) assert "_adaptive_position_cap = max(0.0, min(_adaptive_position_cap, settings.MAX_POSITION_SIZE))" in src # ── 2) 수치 검증 ──────────────────────────────────────────────────────────── TOTAL_ASSETS = 10_000_000.0 AVAILABLE_CASH = 10_000_000.0 @pytest.mark.parametrize("mode_position_size", [0.06, 0.07]) # Normal / Aggressive def test_adaptive_modes_never_exceed_hard_cap(mode_position_size): """적응형 6~7% 예산도 최종 클램프 후에는 MAX_POSITION_SIZE 이하.""" adaptive_cap = min(mode_position_size, settings.MAX_POSITION_SIZE) raw = TOTAL_ASSETS * adaptive_cap final = clamp_by_ratios(raw, TOTAL_ASSETS, [settings.MAX_POSITION_SIZE], AVAILABLE_CASH) assert final / TOTAL_ASSETS <= settings.MAX_POSITION_SIZE + 1e-9 def test_pattern_memory_uplift_is_clamped(): """패턴메모리가 +30% 상향해도 최종 클램프가 상한을 지킨다.""" raw = TOTAL_ASSETS * settings.MAX_POSITION_SIZE * 1.3 final = clamp_by_ratios(raw, TOTAL_ASSETS, [settings.MAX_POSITION_SIZE], AVAILABLE_CASH) assert final / TOTAL_ASSETS <= settings.MAX_POSITION_SIZE + 1e-9