- 스윙 체결가 확인 전 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
181 lines
9.7 KiB
Python
181 lines
9.7 KiB
Python
"""감사 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
|