"""감사 P1 수정 검증: MDD 수량 반영 / 전략명 명시 / 국내 DT 태깅.""" import inspect import re from unittest.mock import patch import pytest def _active_trade_payload_scopes(src: str) -> dict: """`enqueue_active_trade_upsert(<변수>)` 로 넘기는 payload 변수별 '전략명 한정 키' 집합. 불변식: ActiveTrade upsert 는 반드시 대상 행을 전략명으로 한정해야 한다. - `strategy_name` : 전략명이 있는 행을 동등 매칭 - `strategy_name_not` : 전략명이 NULL 인 legacy/동기화 유입 행을 비-DT 조건으로 겨냥 둘 중 아무것도 없으면 db_worker 가 '가장 최근 active 행'을 잡아 같은 종목의 데이트레이딩 행을 덮어쓴다(= 이 가드가 막으려는 과거 버그). [2026-09-27] 구현이 dict 리터럴(`{"strategy_name": x, ...}`) → 변수 조립 (`payload["strategy_name"] = x`) 로 바뀌면서 리터럴 문자열 매칭이 깨졌다. 변수명·조립 순서에 의존하지 않도록 payload 변수 단위로 키를 수집한다. """ scopes: dict = {} for var in re.findall(r"enqueue_active_trade_upsert\(\s*(_\w+)\s*\)", src): scopes[var] = set( re.findall( rf'{re.escape(var)}\[\s*"(strategy_name|strategy_name_not)"\s*\]\s*=', src, ) ) | set( # dict 리터럴/update() 로 넣는 경우도 함께 인정 re.findall( rf'{re.escape(var)}(?:\s*=|\.update\()\s*\{{[^}}]*?"(strategy_name|strategy_name_not)"\s*:', src, re.S, ) ) return scopes @pytest.mark.asyncio async def test_mdd_reduction_reflects_quantities(): """MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다. 과거 버그(P1 감사): 축소 매도가 체결됐는데 ActiveTrade.quantity 가 축소 전 값으로 남아, 다음 청산이 실제 보유량보다 많이 매도했다(과다매도 → 주문 거부 또는 미보유 매도). 그래서 '잔량이 payload 에 실리는지' 를 가드한다. [2026-09-27] 구현이 dict 리터럴 `{"quantity": remaining_qty}` → `_sync_active_trade_after_reduction` 내부 헬퍼(`_upsert`/`_upsert_non_dt`)의 payload 조립(`"quantity": qty`)으로 바뀌어 문자열 매칭이 깨졌다. 동작은 동일. 문자열 대신 **실제로 큐에 실리는 payload** 를 캡처해 검증하도록 갱신 — 리팩터링에는 견고하고, 잔량 반영이 빠지면 여전히 실패한다. """ from app.services import mdd_manager as m captured: list = [] async def _cap(payload): captured.append(payload) # (1) DT 병존 없음 → 최신 active 행 1건에 잔량·배율 반영 with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \ patch.object(m, "_dt_position_qty", lambda _code: 0): await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7) assert len(captured) == 1, "축소 반영 payload 가 큐잉되지 않음" assert captured[0].get("quantity") == 7, "ActiveTrade 잔량 미반영 → 다음 청산이 과다매도" assert captured[0]["remaining_pct_multiply"] == pytest.approx(0.7) # (2) DT 포지션 병존 → DT 행은 건드리지 않고(비-DT 필터) 스윙 잔량만 반영 captured.clear() with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \ patch.object(m, "_dt_position_qty", lambda _code: 3): await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7) assert len(captured) == 1 assert captured[0].get("quantity") == 7, "DT 병존 경로에서 잔량 미반영" assert captured[0].get("strategy_name_not"), "DT ActiveTrade 행 오염 방지 필터 누락" # 두 축소 경로(REDUCE_AND_PAUSE / FORCE_REDUCE) 모두 인메모리 반영을 호출해야 한다 src = inspect.getsource(m) assert src.count("_reflect_reduction(stock_code, sell_qty") == 2, "두 축소 경로 모두 반영 필요" helper = inspect.getsource(m._reflect_reduction) assert "reflect_sell" in helper and "day_trading_state" in helper assert "remove_position" in helper and 'pos["quantity"] = _left' in helper def test_swing_exit_always_passes_strategy_name(): """전략명을 비우면 db_worker 가 최신 active 행(=DT)을 닫거나 덮어쓴다. 과거 버그(P1 감사): 스윙 청산이 전략명 없이 close/upsert 를 큐잉해, 같은 종목에 데이트레이딩 행이 있으면 DT 행이 닫히거나 잔량이 덮어써지고 스윙 행은 영구 잔존. [2026-09-27] 전략명이 NULL 인 legacy/동기화 유입 행을 'swing' 으로 폴백하면 매칭 0건이 되는 문제 때문에 `_at_filter_not_dt` 분기가 추가됐다: - 전략명 있음 → `payload["strategy_name"] = _at_strategy` - 전략명 NULL → `payload["strategy_name_not"] = DT_STRATEGY_NAMES` 두 경로 모두 'DT 행을 건드리지 않는다' 는 불변식을 지킨다. 따라서 특정 리터럴 대신 **모든 upsert payload 가 전략명으로 한정되는지** 를 검사한다. """ from app.services.swing import exit_service as se src = inspect.getsource(se) assert "strategy_name=None" not in src, "close 호출에 전략명 누락" assert 'strategy_name=_strategy or "swing"' in src # 전량청산 close 경로 scopes = _active_trade_payload_scopes(src) # 트레일링 고점 upsert + 분할매도 upsert = 최소 2곳 assert len(scopes) >= 2, f"ActiveTrade upsert payload 검출 실패: {scopes}" for var, keys in scopes.items(): assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염" # NULL 전략명 경로는 DT 제외 필터로 비-DT 행을 겨냥해야 한다 assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실" def test_executor_sell_excludes_daytrading_shares(): """Portfolio 행은 스윙+DT 합계 → 스윙 청산이 DT 보유분까지 팔면 안 된다. 과거 버그(P1 감사): Portfolio 한 행에 스윙+DT 수량이 합산돼 있어 스윙 청산이 DT 보유분까지 매도했고, ActiveTrade 갱신도 DT 행을 집어 닫아버렸다. [2026-09-27] 분할매도 upsert 가 dict 리터럴 → `_at_payload[...] = ...` 조립으로 바뀌고, 전략명 NULL 행을 위한 `strategy_name_not` 분기가 추가됐다(스윙 청산과 동일 패턴). 또 캐시 반영용 '행 합산 수량'(_row_total_qty)과 이 전략 보유분(_own)이 분리됐다. 검사 방식만 갱신 — 불변식(DT 수량 제외 + DT 행 비오염)은 그대로 본다. """ from app.services import trade_executor as te src = inspect.getsource(te._execute_sell) assert "day_trading_state" in src and "_dt_qty" in src assert "owned_by_daytrading" in src assert 'total_qty = _own' in src, "DT 수량 차감 후 매도 대상 축소 누락" # 캐시/Portfolio 행 잔량은 행 합산 기준이어야 DT 보유분이 사라지지 않는다 assert "_row_total_qty" in src # 분할매도 upsert 는 전략명(또는 비-DT 필터)으로 대상 행을 한정해야 한다 scopes = _active_trade_payload_scopes(src) assert scopes, "ActiveTrade upsert payload 검출 실패 — 검사 대상 확인 필요" for var, keys in scopes.items(): assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염" assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실" def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1(): from app.api.endpoints import portfolio as pf src = inspect.getsource(pf.sync_portfolio) # 국내 신규/기존 행 + 해외 = 태깅 지점 4곳 assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 4 # 정의가 output1 블록보다 앞이어야 NameError 가 없다 assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data')