Files
auto-stock/backend/tests/test_swing_exit_hardening_r2_20261009.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

257 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 유지(타 테스트와 동일 헬퍼)
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)