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

321 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]