- 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>
321 lines
16 KiB
Python
321 lines
16 KiB
Python
"""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]
|