- 스윙 체결가 확인 전 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
254 lines
11 KiB
Python
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 유지(타 테스트와 동일 헬퍼)
|