Files
auto-stock/backend/tests/test_p1_audit_fixes.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

181 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""감사 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 "remaining_pct_multiply" not in captured[0], "익절 단계(remaining_pct) 오염 금지"
# (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():
"""DT 태깅이 국내(신규·기존)·해외 4경로 모두에 적용되는지.
[2026-09-28] 유입(external) 격리 도입으로 **신규 행** 2곳의 판정이 인라인 비교에서
순수 함수 `_classify_holding_strategy_type`(= DT 전량 → daytrading, 시스템 매수
근거 없음 → external) 위임으로 바뀌었다. 인라인 비교는 기존 행 갱신부(국내·해외)
2곳에만 남는다. 불변식(4경로 전부 태깅 + `_dt_qty` 가 output1 블록보다 앞)은 동일.
"""
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sync_portfolio)
# 기존 행 갱신부(국내·해외) = 인라인 DT 비교 2곳
assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 2
# 신규 행(국내·해외) = 분류 함수 위임 2곳
assert src.count("_classify_strategy_type(stock_code, quantity)") == 2
# 분류 함수가 DT 전량 판정을 보유
pure_src = inspect.getsource(pf._classify_holding_strategy_type)
assert 'return "daytrading"' in pure_src and "quantity <= _dt_held" in pure_src
# 정의가 output1 블록보다 앞이어야 NameError 가 없다
assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data')
@pytest.mark.asyncio
async def test_mdd_reduction_never_touches_remaining_pct_stage():
"""[하드닝 R3] MDD 축소 payload 는 remaining_pct(익절 단계)를 건드리지 않는다.
×0.7 이 곱해지면 모멘텀 1차 익절 게이트(remaining>0.75)가 영구 폐쇄된다.
"""
from app.services import mdd_manager as m
captured: list = []
async def _cap(payload):
captured.append(payload)
for dt in (0, 3):
captured.clear()
with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \
patch.object(m, "_dt_position_qty", lambda _c, _d=dt: _d):
await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7)
assert len(captured) == 1
assert "remaining_pct_multiply" not in captured[0]
assert "remaining_pct" not in captured[0]
assert captured[0]["quantity"] == 7