"""9회차 B 그룹(스윙 청산·MDD) 회귀 테스트 (2026-10-12). B-1 잔고 연속조회(get_balance_all_v2)로 2페이지 종목 손절 / _partial 보류 / 완전 응답 미발견 = 미보유 / 예외 보류, MDD 축소의 불완전 판정. B-2 결과 불명 매도 대기 중 분할 익절 재발화 금지 · 손절 허용 · 정상 매도 잔고 감소의 불명 체결 오귀속 방지 · 불명 등록 시 쿨다운 기록. B-3 불명 대기 TTL(장 마감) · 재매수 시 폐기. B-3b TTL 폐기 전 KIS 잔고 대조 1회. 시뮬레이터는 5·6회차 테스트의 상태형 KIS 시뮬레이터(_Sim)를 재사용한다(주문 종단 safe_place_order 목, 체결 확인 종단 기록형 목, 예외 발생형 차단은 conftest). 기대값은 settings.* 참조. """ import logging import time from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest from app.core.config import settings from app.core.timeutil import KST from app.services.mean_reversion_strategy import ExitSignal # 상태형 시뮬레이터와 autouse 리셋 픽스처(_reset)를 그대로 가져온다 — 이름으로 import 해야 픽스처가 등록된다. from test_swing_r5_pending_sell_20261002 import _Sim, _reset, UNKNOWN, _partial_signal # noqa: F401 CODE = "005930" @pytest.fixture(autouse=True) def _no_redis_and_alerts(monkeypatch): """불명 대기 Redis 미러링·텔레그램 경보는 기록형 목(단독 실행 시 실 Redis 접속 방지).""" import app.services.swing.exit_service as es monkeypatch.setattr(es, "_persist_unknown_sell_v2", AsyncMock(return_value=True)) monkeypatch.setattr(es, "_forget_unknown_sell_v2", AsyncMock()) monkeypatch.setattr(es, "_restore_unknown_sells_v2", AsyncMock(return_value=False)) alerts = [] monkeypatch.setattr(es, "_notify_swing_exit_alert_v2", lambda k, c, m: alerts.append((k, m))) import app.services.trade_executor as te monkeypatch.setattr(te, "_recent_cycle_buys_v2", lambda: {}) return alerts def _filler(n): return [{"pdno": f"9{i:05d}", "hldg_qty": "1", "ord_psbl_qty": "1"} for i in range(n)] def _stop_signal(_remaining): return ExitSignal("SELL_ALL", "손절", 1.0) # ── B-1 ──────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_b1_second_page_stock_stop_loss_executes(monkeypatch): """보유가 페이지 한도를 넘어 종목이 2페이지에 있어도(완전 응답) 손절 주문이 나간다.""" sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop_signal) rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) async def _all(): return {"rt_cd": "0", "_pages": 2, "output1": _filler(rows) + [{"pdno": CODE, "hldg_qty": str(sim.kis["hldg"]), "ord_psbl_qty": str(sim.kis["psbl"])}]} sim.api.get_balance_all_v2 = AsyncMock(side_effect=_all) await sim.cycles(1) assert sim.sent == [10] sim.api.get_balance.assert_not_awaited() # 첫 페이지 전용 조회는 더 이상 쓰지 않는다 @pytest.mark.asyncio async def test_b1_partial_response_missing_stock_holds(monkeypatch): import app.services.swing.exit_service as es monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", AsyncMock(return_value={"rt_cd": "0", "_pages": 2, "_partial": True, "output1": _filler(3)})) assert await es._fetch_kis_holding_v3(CODE) is None @pytest.mark.asyncio async def test_b1_partial_response_found_stock_is_used(monkeypatch): import app.services.swing.exit_service as es monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", AsyncMock(return_value={"rt_cd": "0", "_pages": 2, "_partial": True, "output1": [{"pdno": CODE, "hldg_qty": "7", "ord_psbl_qty": "5"}]})) assert await es._fetch_kis_holding_v3(CODE) == (7, 5) @pytest.mark.asyncio async def test_b1_complete_response_missing_is_not_held_even_if_full_page(monkeypatch): import app.services.swing.exit_service as es rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + 5 monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", AsyncMock(return_value={"rt_cd": "0", "_pages": 2, "output1": _filler(rows)})) assert await es._fetch_kis_holding_v3(CODE) == (0, 0) @pytest.mark.asyncio async def test_b1_legacy_shape_keeps_full_page_heuristic(monkeypatch): import app.services.swing.exit_service as es rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", AsyncMock(return_value={"rt_cd": "0", "output1": _filler(rows)})) assert await es._fetch_kis_holding_v3(CODE) is None @pytest.mark.asyncio async def test_b1_exception_holds_and_logs_error(monkeypatch, caplog): import app.services.swing.exit_service as es monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", AsyncMock(side_effect=RuntimeError("KIS down"))) with caplog.at_level(logging.ERROR): assert await es._fetch_kis_holding_v3(CODE) is None assert any("매도 보류" in r.message for r in caplog.records if r.levelno >= logging.ERROR) @pytest.mark.asyncio async def test_b1_rt_cd_error_holds(monkeypatch): import app.services.swing.exit_service as es monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", AsyncMock(return_value={"rt_cd": "1", "msg1": "EGW00201"})) assert await es._fetch_kis_holding_v3(CODE) is None def test_b1_mdd_incomplete_uses_partial_flag(): from app.services.mdd_manager import _balance_incomplete_v2 rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) full = _filler(rows) assert _balance_incomplete_v2({"_pages": 2, "output1": full}, full) is False assert _balance_incomplete_v2({"_pages": 1, "_partial": True, "output1": full[:2]}, full[:2]) is True # _pages 없는 구 형태 = 기존 페이지 가득 휴리스틱 assert _balance_incomplete_v2({"output1": full}, full) is True assert _balance_incomplete_v2({"output1": full[:1]}, full[:1]) is False @pytest.mark.asyncio async def test_b1_mdd_reduction_calls_paginated_balance(monkeypatch): """MDD 축소는 get_balance_all_v2 를 쓴다 — 첫 페이지 전용 get_balance 는 호출하지 않는다.""" from app.services import mdd_manager as mm from app.services.korea_investment_api import korea_investment_api allv2 = AsyncMock(return_value={"rt_cd": "1", "msg1": "fail"}) # 실패 응답 → 축소 보류(주문 0) legacy = AsyncMock(side_effect=AssertionError("get_balance 호출 금지")) monkeypatch.setattr(korea_investment_api, "get_balance_all_v2", allv2) monkeypatch.setattr(korea_investment_api, "get_balance", legacy) out = await mm.mdd_manager._execute_reduction_impl("REDUCE_AND_PAUSE", 0.3, False, "pause", "k") assert out == [] allv2.assert_awaited_once() legacy.assert_not_awaited() # ── B-2 ──────────────────────────────────────────────────────────────────── def _open_unknown_sim(monkeypatch, signal, swing=20, responses=None): """첫 매도는 결과 불명 + 브로커 미체결 잔존(보유 불변·주문가능 감소), 이후 주문은 정상 체결.""" sim = _Sim(monkeypatch, swing=swing, dt=0, price=11500, signal=signal, responses=responses or [UNKNOWN], fill=lambda q, n: 0 if n == 0 else q) orig = sim._order async def _order(**kw): n = len(sim.sent) r = await orig(**kw) if n == 0: sim.kis["psbl"] -= kw["quantity"] # 미체결 불명 주문분이 주문가능에서 빠짐 return r import app.services.trade_executor as te monkeypatch.setattr(te, "safe_place_order", _order) return sim @pytest.mark.asyncio async def test_b2_pending_partial_take_profit_not_refired(monkeypatch, caplog): sim = _open_unknown_sim(monkeypatch, _partial_signal) with caplog.at_level(logging.WARNING): await sim.cycles(3) assert len(sim.sent) == 1 # 같은 1차 익절 재발화 0건 assert CODE in sim.es._pending_unknown_sells assert any("분할 익절" in r.message and "건너뜀" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_b2_pending_stop_loss_still_executes_within_kis_cap(monkeypatch): state = {"stop": False} def _sig(remaining): return _stop_signal(remaining) if state["stop"] else _partial_signal(remaining) sim = _open_unknown_sim(monkeypatch, _sig) await sim.cycles(1) assert sim.sent == [0] and CODE in sim.es._pending_unknown_sells state["stop"] = True await sim.cycles(1) # 손절은 허용 — 상한 = min(주문가능 10, 보유 20) − DT 0 = 10 (불명 주문분 제외, 과다매도 없음) assert sim.sent == [0, 10] assert sim.kis["hldg"] == 10 @pytest.mark.asyncio async def test_b2_normal_sell_during_pending_not_misattributed(monkeypatch): state = {"stop": False} def _sig(remaining): return _stop_signal(remaining) if state["stop"] else _partial_signal(remaining) sim = _open_unknown_sim(monkeypatch, _sig) await sim.cycles(1) pre = sim.es._pending_unknown_sells[CODE]["pre_hldg"] state["stop"] = True await sim.cycles(1) # 손절 10주 정상 접수·체결 → 대기 기준 잔고 차감 assert sim.es._pending_unknown_sells[CODE]["pre_hldg"] == pre - 10 state["stop"] = False await sim.cycles(2) # 대조: 보유 감소 10주는 정상 매도분 → 불명 체결로 기록 금지 assert not any("결과 불명 → KIS 잔고로 체결 확인" in (h.get("exit_reason") or "") for h in sim.history) assert CODE in sim.es._pending_unknown_sells # 불명 주문은 여전히 브로커에 미체결 @pytest.mark.asyncio async def test_b2_unknown_registration_records_cooldown(monkeypatch): sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal, responses=[UNKNOWN], fill=lambda q, n: 0) sim.es._recent_exits.clear() before = time.time() await sim.es.check_and_execute_exit_v2(MagicMock()) assert CODE in sim.es._pending_unknown_sells assert sim.es._recent_exits.get(CODE, 0) >= before # ── B-3 ──────────────────────────────────────────────────────────────────── def _epoch_kst(h, m, day=None): d = day or datetime.now(KST).date() return KST.localize(datetime(d.year, d.month, d.day, h, m)).timestamp() def test_b3_expired_after_market_close(): import app.services.swing.exit_service as es d = datetime(2026, 10, 12).date() pend = {"since": _epoch_kst(10, 0, d)} assert es._unknown_sell_expired_v2(pend, datetime(2026, 10, 12, 15, 0)) is False assert es._unknown_sell_expired_v2(pend, datetime(2026, 10, 12, 15, 31)) is True assert es._unknown_sell_expired_v2(pend, datetime(2026, 10, 13, 9, 5)) is True # 장 마감 후 등록은 다음 날 마감까지 유지 / 등록 시각 불명은 만료 판정 안 함 late = {"since": _epoch_kst(16, 0, d)} assert es._unknown_sell_expired_v2(late, datetime(2026, 10, 12, 18, 0)) is False assert es._unknown_sell_expired_v2({"since": None}, datetime(2026, 10, 13, 18, 0)) is False @pytest.mark.asyncio async def test_b3_expire_drops_with_alert(monkeypatch, _no_redis_and_alerts): """대조 불가(잔고 조회 실패) → 기존대로 폐기 + 경보.""" import app.services.swing.exit_service as es monkeypatch.setattr(es, "_presell_kis_holding_v2", AsyncMock(return_value=None)) d = datetime(2026, 10, 12).date() es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(10, 0, d)} dropped = await es._expire_unknown_sells_v2(datetime(2026, 10, 12, 15, 40)) assert dropped == [CODE] and CODE not in es._pending_unknown_sells es._forget_unknown_sell_v2.assert_awaited_once_with(CODE) assert any("수동 확인" in m for _, m in _no_redis_and_alerts) @pytest.mark.asyncio async def test_b3_rebuy_after_registration_drops(monkeypatch, _no_redis_and_alerts): import app.services.swing.exit_service as es import app.services.trade_executor as te reg = time.monotonic() es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": time.time(), "since_mono": reg} es._pending_unknown_sells["000660"] = {"qty": 1, "pre_hldg": 2, "since": time.time(), "since_mono": reg} monkeypatch.setattr(te, "_recent_cycle_buys_v2", lambda: {CODE: {"ts": reg + 5}, "000660": {"ts": reg - 5}}) dropped = await es._expire_unknown_sells_v2() assert dropped == [CODE] assert "000660" in es._pending_unknown_sells # 등록 이전 매수는 무관 @pytest.mark.asyncio async def test_b3_restored_entry_without_mono_dropped_on_any_buy(monkeypatch): import app.services.swing.exit_service as es import app.services.trade_executor as te es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": time.time()} monkeypatch.setattr(te, "_recent_cycle_buys_v2", lambda: {CODE: {"ts": time.monotonic()}}) assert await es._expire_unknown_sells_v2() == [CODE] @pytest.mark.asyncio async def test_b3b_expire_reconciles_first_fill_booked_no_drop(monkeypatch, _no_redis_and_alerts): """장 마감 경과 항목도 폐기 전 대조 1회 — 체결이 확인되면 그 결과로 종결(경보 없음).""" import app.services.swing.exit_service as es d = datetime(2026, 10, 12).date() es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(15, 10, d)} snap = AsyncMock(return_value=(5, 5)) monkeypatch.setattr(es, "_presell_kis_holding_v2", snap) async def _rec(code, hldg, sellable): assert (code, hldg, sellable) == (CODE, 5, 5) es._pending_unknown_sells.pop(code, None) # 체결 반영으로 대기 해제 return True rec = AsyncMock(side_effect=_rec) monkeypatch.setattr(es, "_reconcile_unknown_sell_v2", rec) dropped = await es._expire_unknown_sells_v2(datetime(2026, 10, 13, 9, 5)) assert dropped == [] rec.assert_awaited_once() assert _no_redis_and_alerts == [] es._forget_unknown_sell_v2.assert_not_awaited() @pytest.mark.asyncio async def test_b3b_expire_reconcile_keeps_pending_then_drops(monkeypatch, _no_redis_and_alerts): """대조했지만 판정 불가(대기 유지) → 폐기 + 경보.""" import app.services.swing.exit_service as es d = datetime(2026, 10, 12).date() es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(15, 10, d)} monkeypatch.setattr(es, "_presell_kis_holding_v2", AsyncMock(return_value=(10, 5))) rec = AsyncMock(return_value=False) monkeypatch.setattr(es, "_reconcile_unknown_sell_v2", rec) dropped = await es._expire_unknown_sells_v2(datetime(2026, 10, 13, 9, 5)) assert dropped == [CODE] rec.assert_awaited_once() assert any("수동 확인" in m for _, m in _no_redis_and_alerts) @pytest.mark.asyncio async def test_b3b_expire_reconcile_exception_drops(monkeypatch, _no_redis_and_alerts): import app.services.swing.exit_service as es d = datetime(2026, 10, 12).date() es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(15, 10, d)} monkeypatch.setattr(es, "_presell_kis_holding_v2", AsyncMock(side_effect=RuntimeError("boom"))) assert await es._expire_unknown_sells_v2(datetime(2026, 10, 13, 9, 5)) == [CODE]