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

254 lines
11 KiB
Python

"""스윙 청산 하드닝 R2 (2026-10-09) 회귀 테스트.
1. 결과 불명 매도 대기(_pending_unknown_sells)의 Redis 영속화·재기동 복원
- 재기동 후 복원 → KIS 잔고 대조로 장부 반영, 같은 분할 단계 재발동 없음
- 전량 체결 후 보유 목록에서 빠진 종목도 대조(유령 ActiveTrade 방지)
- Redis 일시 장애로 복원 실패 → 분할 익절만 1주기 보류, 손절은 진행
2/3. 체결 확인·Portfolio sync 백그라운드 태스크가 trade_executor 추적 레지스트리
(spawn_tracked_task_v2 → drain_tracked_tasks_v2) 를 탄다
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order) — r5 테스트의 _Sim 재사용.
"""
import asyncio
import logging
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.core.config import settings # noqa: F401 (기대값은 settings/모듈 상수 참조)
from app.services.mean_reversion_strategy import ExitSignal
from test_swing_r5_pending_sell_20261002 import ( # noqa: F401 (_reset: autouse 픽스처)
UNKNOWN, _Sim, _partial_signal, _reset,
)
class _FakeRedis:
"""hset/hdel/hgetall/expire 만 흉내. fail_reads 회만큼 hgetall 이 예외."""
def __init__(self, fail_reads=0):
self.h: dict[str, dict[str, str]] = {}
self.fail_reads = fail_reads
self.reads = 0
async def hset(self, key, field, value):
self.h.setdefault(key, {})[field] = value
async def hdel(self, key, field):
self.h.get(key, {}).pop(field, None)
async def expire(self, key, ttl):
return True
async def hgetall(self, key):
self.reads += 1
if self.reads <= self.fail_reads:
raise ConnectionError("redis down")
return dict(self.h.get(key, {}))
@pytest.fixture(autouse=True)
def _r2_reset(monkeypatch):
import app.services.swing.exit_service as es
monkeypatch.setattr(es, "_unknown_sells_restore_state", "pending")
monkeypatch.setattr(es, "_unknown_sells_restore_failures", 0)
# 체결 확인 백그라운드(delay=3s 후 KIS 조회)가 테스트 종료 뒤 남지 않게 — 기록형 목
import app.services.trade_executor as te
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", AsyncMock())
def _use_redis(monkeypatch, fake):
import app.core.redis as r
monkeypatch.setattr(r, "get_redis", lambda: fake)
def _restart(es):
"""프로세스 재기동 흉내 — 인메모리 상태만 사라진다(Redis·KIS·DB 장부는 유지)."""
es._pending_unknown_sells.clear()
es._recent_exits.clear()
es._unknown_sells_restore_state = "pending"
es._unknown_sells_restore_failures = 0
# ── 1. 영속화·복원 ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_unknown_partial_survives_restart_no_stage_refire(monkeypatch):
"""[재현 반전] 50% 익절 10주 결과 불명(실제 체결) 직후 재기동. 이전: 대기 소실 →
remaining_pct 1.0 그대로 → 같은 단계 재발동으로 10주 추가 매도. 이제: Redis 복원 →
대조로 SELL 기록·remaining_pct=0.5 → 재발동 없음."""
fake = _FakeRedis()
_use_redis(monkeypatch, fake)
sim = _Sim(monkeypatch, swing=20, dt=5, price=11500, signal=_partial_signal, responses=[UNKNOWN])
await sim.cycles(1)
es = sim.es
assert "005930" in es._pending_unknown_sells
key = es._unknown_sells_redis_key_v2()
assert "005930" in fake.h[key] # 등록 시 미러링
_restart(es)
await sim.cycles(3)
assert sum(sim.sent) == 10
assert sim.at.remaining_pct == 0.5
assert any("결과 불명" in (p.get("exit_reason") or "") for p in sim.history)
assert "005930" not in fake.h.get(key, {}) # 대조 후 Redis 에서도 해제
assert es._pending_unknown_sells == {}
@pytest.mark.asyncio
async def test_unknown_full_fill_restart_row_gone_closes_active_trade(monkeypatch):
"""전량 손절 불명(체결) → 재기동 → sync 가 Portfolio 행을 지워 보유 목록에 없음.
이전: 루프에 안 들어와 대조 불가 → ActiveTrade 유령. 이제: 보유 목록 밖 대조로 close+loss."""
fake = _FakeRedis()
_use_redis(monkeypatch, fake)
sim = _Sim(monkeypatch, swing=10, dt=0, responses=[UNKNOWN])
await sim.cycles(1)
es = sim.es
assert sim.kis["hldg"] == 0 and sim.closed == 0
_restart(es)
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[]))
await sim.cycles(1)
assert sim.closed == 1
sim.loss.assert_awaited()
assert es._pending_unknown_sells == {}
assert fake.h.get(es._unknown_sells_redis_key_v2(), {}) == {}
@pytest.mark.asyncio
async def test_memory_entry_wins_over_redis_on_restore(monkeypatch):
import app.services.swing.exit_service as es
fake = _FakeRedis()
_use_redis(monkeypatch, fake)
stale = {"qty": 99, "pre_hldg": 99, "price": 1, "avg_price": 1, "swing_qty": 99, "sell_pct": 1.0,
"remaining_pct": 1.0, "original_qty": 99, "filter_not_dt": False,
"had_active_trade": True, "stock_name": "T", "reason": "x"}
await es._persist_unknown_sell_v2("005930", stale)
es._pending_unknown_sells["005930"] = dict(stale, qty=5)
assert await es._restore_unknown_sells_v2() is False
assert es._pending_unknown_sells["005930"]["qty"] == 5
@pytest.mark.asyncio
async def test_malformed_redis_entry_skipped_with_error(monkeypatch, caplog):
import app.services.swing.exit_service as es
fake = _FakeRedis()
fake.h[es._unknown_sells_redis_key_v2()] = {"005930": "{not json", "000660": '{"qty": 3}'}
_use_redis(monkeypatch, fake)
with caplog.at_level(logging.ERROR):
assert await es._restore_unknown_sells_v2() is False
assert es._pending_unknown_sells == {}
assert es._unknown_sells_restore_state == "ok"
assert sum("형식 이상" in r.getMessage() for r in caplog.records) == 2
# ── 1. 복원 실패 시 분할 익절 1주기 보류 ─────────────────────────────────────
@pytest.mark.asyncio
async def test_restore_failure_holds_partial_one_cycle_then_restores(monkeypatch):
import app.services.swing.exit_service as es
_use_redis(monkeypatch, _FakeRedis(fail_reads=es._UNKNOWN_SELL_RESTORE_HOLD_CYCLES))
sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal)
await sim.cycles(es._UNKNOWN_SELL_RESTORE_HOLD_CYCLES)
assert sim.sent == [] # 분할 익절 보류
await sim.cycles(1) # 재시도 성공 → 정상 진행
assert sim.sent == [10]
assert es._unknown_sells_restore_state == "ok"
@pytest.mark.asyncio
async def test_restore_failure_does_not_hold_stop_loss(monkeypatch):
"""복원 실패 중에도 손절(전량)은 진행 — 청산 우선(§8.1-11)."""
import app.services.swing.exit_service as es
_use_redis(monkeypatch, _FakeRedis(fail_reads=10))
sim = _Sim(monkeypatch, swing=10, dt=0, price=8000, avg=10000)
await sim.cycles(1)
assert sim.sent == [10]
assert es._unknown_sells_restore_failures == 1
@pytest.mark.asyncio
async def test_restore_gives_up_after_hold_cycles(monkeypatch, caplog):
import app.services.swing.exit_service as es
_use_redis(monkeypatch, _FakeRedis(fail_reads=100))
sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal)
with caplog.at_level(logging.WARNING):
await sim.cycles(es._UNKNOWN_SELL_RESTORE_HOLD_CYCLES + 1)
assert sim.sent == [10] # 보류는 HOLD_CYCLES 주기만
assert es._unknown_sells_restore_state == "gave_up"
assert any(r.levelno >= logging.ERROR and "복원 포기" in r.getMessage() for r in caplog.records)
@pytest.mark.asyncio
async def test_redis_unconnected_no_hold(monkeypatch):
import app.services.swing.exit_service as es
_use_redis(monkeypatch, None)
sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal)
await sim.cycles(1)
assert sim.sent == [10]
assert es._unknown_sells_restore_state == "ok"
# ── 2/3. 백그라운드 태스크 추적 ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_fill_check_task_is_tracked_and_drained(monkeypatch):
import app.services.swing.exit_service as es
import app.services.trade_executor as te
gate = asyncio.Event()
calls = []
async def _verify(*a, **kw):
calls.append((a, kw))
await gate.wait()
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _verify)
monkeypatch.setattr(te, "_tracked_bg_tasks", set())
es._schedule_swing_sell_fill_check_v2("0001", "005930", "T", 10, 11500.0, 10000.0, "KRX")
tracked = [t for t in te._tracked_bg_tasks if not t.done()]
assert len(tracked) == 1
assert tracked[0].get_name() == "swing_sell_fill:005930"
assert te.pending_tracked_tasks_count_v2() == 1
await asyncio.sleep(0)
gate.set()
assert await te.drain_tracked_tasks_v2(1.0) == 0
assert calls and calls[0][1]["sell_pnl_basis"]["strategy_type"] == "swing"
assert te._tracked_bg_tasks == set()
@pytest.mark.asyncio
async def test_unknown_sell_portfolio_sync_task_is_tracked(monkeypatch):
import app.services.swing.exit_service as es
import app.services.trade_executor as te
monkeypatch.setattr(te, "_tracked_bg_tasks", set())
sync = AsyncMock()
monkeypatch.setattr(te, "_delayed_sync_portfolio", sync)
es._trigger_portfolio_sync_v2()
assert len(te._tracked_bg_tasks) == 1
assert next(iter(te._tracked_bg_tasks)).get_name() == "swing_unknown_sell_sync"
await te.drain_tracked_tasks_v2(1.0)
sync.assert_awaited_once()
def test_fill_check_without_loop_warns_not_raises(monkeypatch, caplog):
"""루프 밖 호출 — 예외 전파 없이 warning, 코루틴 미생성."""
import app.services.swing.exit_service as es
import app.services.trade_executor as te
def _boom(*a, **kw):
raise AssertionError("루프 없으면 코루틴을 만들면 안 된다")
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _boom)
with caplog.at_level(logging.WARNING):
es._schedule_swing_sell_fill_check_v2("0001", "005930", "T", 10, 1.0, 1.0, "KRX")
assert any("체결 확인 예약 실패" in r.getMessage() for r in caplog.records)
def test_no_bare_create_task_in_swing_exit_service():
import inspect
import app.services.swing.exit_service as es
src = inspect.getsource(es)
assert "create_task(" not in src
_ = ExitSignal, MagicMock # import 유지(타 테스트와 동일 헬퍼)