"""스윙 청산 하드닝 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 유지(타 테스트와 동일 헬퍼)