- 스윙 체결가 확인 전 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
304 lines
15 KiB
Python
304 lines
15 KiB
Python
"""스윙 청산 하드닝 R4 (2026-10-09) — swing/exit_service 회귀 테스트.
|
|
|
|
#1 [HIGH] '전체 오류'·'보유 종목 전부 예외'가 정상 반환되어 스케줄러가 매 사이클 성공으로 기록 →
|
|
연속 실패·무완료 경보 우회. 이제 실행 결과(seq/ok)를 남기고 스케줄러가 실패로 집계한다.
|
|
종목별 연속 예외 카운터 + 임계 경보.
|
|
#2 [MEDIUM] 스윙 매도 KIS 업무거부가 매 주기 무경보 재시도. 이제 종목별 연속 거부 카운터 +
|
|
임계 경보, 계좌 단위 거부(KIS_ACCOUNT_REJECT_MSG_CODES)는 1회째 즉시 경보, /health degraded.
|
|
|
|
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 실패 주입은 예외 발생형,
|
|
임계·거부 코드는 settings/모듈 기본값 참조(하드코딩 금지).
|
|
"""
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import app.services.mean_reversion_strategy as mr
|
|
from app.core.config import settings
|
|
from app.services.mean_reversion_strategy import ExitSignal
|
|
|
|
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
|
|
|
|
|
|
class _SessionCM:
|
|
def __init__(self, db=None):
|
|
self.db = db if db is not None else MagicMock()
|
|
|
|
async def __aenter__(self):
|
|
return self.db
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset(monkeypatch):
|
|
import app.services.swing.exit_service as es
|
|
import app.services.swing.scheduler as sch
|
|
import app.services.telegram_notifier as tn
|
|
for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr",
|
|
"_presell_check_failures", "_no_price_skips", "_recent_exits",
|
|
"_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last"):
|
|
monkeypatch.setattr(es, name, {})
|
|
monkeypatch.setattr(es, "_exit_run_state_v2", {
|
|
"seq": 0, "ok": True, "error": None, "holdings": 0, "errored": 0, "errors": [],
|
|
})
|
|
from app.core import log_throttle
|
|
log_throttle.reset_for_tests()
|
|
import app.services.adaptive_strategy as ad
|
|
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
|
|
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
|
|
import app.services.trade_executor as te
|
|
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
|
|
# 텔레그램 실전송 차단(예외 발생형) + 경보 수집
|
|
monkeypatch.setattr(tn.telegram_notifier, "send_message",
|
|
AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지")))
|
|
sch._exit_health.update({
|
|
"last_success_at": None, "market_open_since": None,
|
|
"consecutive_failures": 0, "last_error": None, "timeouts": 0,
|
|
})
|
|
monkeypatch.setattr(sch, "_alert_sent_at", {})
|
|
alerts = []
|
|
monkeypatch.setattr(sch, "_notify_throttled_v2",
|
|
lambda key, category, message, repeat_sec=None: alerts.append((key, message)) or True)
|
|
return SimpleNamespace(es=es, sch=sch, alerts=alerts)
|
|
|
|
|
|
def _holding(code, price):
|
|
from app.core.timeutil import now_kst
|
|
return SimpleNamespace(stock_code=code, stock_name="T", quantity=10, average_price=10000,
|
|
current_price=price, market="KRX", updated_at=now_kst())
|
|
|
|
|
|
def _env(monkeypatch, *, codes=("005930",), price=8000, order=None, holdings_exc=None, at_exc=None):
|
|
"""기본: 평균 10000 → 현재가 8000 손절 신호(check_exit_signal 목) → 매도 주문."""
|
|
import app.services.swing.exit_service as es
|
|
import app.services.trade_executor as te
|
|
import app.services.db_worker as dbw
|
|
import app.services.portfolio_cache_bridge as pcb
|
|
from app.core.timeutil import now_kst
|
|
from app.services.daytrading.position_manager import day_trading_state
|
|
|
|
holdings = [_holding(c, price) for c in codes]
|
|
if holdings_exc is not None:
|
|
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(side_effect=holdings_exc))
|
|
else:
|
|
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=holdings))
|
|
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
|
|
at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None,
|
|
created_at=now_kst() - timedelta(days=2), entry_reason=None, entry_price=10000)
|
|
if at_exc is not None:
|
|
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(side_effect=at_exc))
|
|
else:
|
|
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at))
|
|
api = MagicMock()
|
|
bars = [{"stck_clpr": str(price + (i % 3) * 50), "stck_hgpr": str(price + 200 + (i % 3) * 50),
|
|
"stck_lwpr": str(price - 200 + (i % 3) * 50)} for i in range(30)]
|
|
api.get_daily_chart = AsyncMock(return_value={"output2": bars})
|
|
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}})
|
|
api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [
|
|
{"pdno": c, "hldg_qty": "10", "ord_psbl_qty": "10"} for c in codes]})
|
|
monkeypatch.setattr(es, "korea_investment_api", api)
|
|
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: ExitSignal(
|
|
action="SELL_ALL", reason="손절 테스트", sell_pct=1.0))
|
|
monkeypatch.setattr(day_trading_state, "positions", {})
|
|
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
|
|
sent = []
|
|
state = {"order": order}
|
|
|
|
async def _spo(**kw):
|
|
sent.append((kw["stock_code"], kw["quantity"]))
|
|
return state["order"]
|
|
monkeypatch.setattr(te, "safe_place_order", _spo)
|
|
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock())
|
|
monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock())
|
|
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock())
|
|
monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock())
|
|
monkeypatch.setattr(pcb, "reflect_sell", AsyncMock())
|
|
monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock())
|
|
monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock())
|
|
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
|
|
return SimpleNamespace(es=es, sent=sent, state=state)
|
|
|
|
|
|
async def _cycle(es):
|
|
es._recent_exits.clear()
|
|
return await es.check_and_execute_exit_v2(MagicMock())
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# #1 — 전체 오류 / 전 종목 예외가 실패로 집계
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_total_error_recorded_as_failed_run(monkeypatch, _reset):
|
|
env = _env(monkeypatch, holdings_exc=RuntimeError("QueuePool limit reached"))
|
|
exits, ok, errors = await env.es.check_and_execute_exit_v2_impl(MagicMock())
|
|
assert exits == [] and ok is False
|
|
assert any("QueuePool" in e for e in errors)
|
|
st = env.es.get_last_exit_run_status_v2()
|
|
assert st["seq"] == 1 and st["ok"] is False
|
|
assert "exit_run_failed" in env.es.get_swing_exit_health_v2()["degraded_reasons"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduler_records_failure_on_swallowed_total_error(monkeypatch, _reset):
|
|
"""[재현 반전] 이전: 전체 오류인데 _record_exit_success_v2 → 경보 영구 미발동."""
|
|
import app.services.swing.fill_sweep as fs
|
|
sch = _reset.sch
|
|
_env(monkeypatch, holdings_exc=RuntimeError("db pool exhausted"))
|
|
monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM())
|
|
monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0))
|
|
threshold = sch._DEFAULT_EXIT_FAIL_ALERT_COUNT
|
|
for _ in range(threshold):
|
|
await sch._run_exit_phase_v2()
|
|
assert sch._exit_health["last_success_at"] is None
|
|
assert sch._exit_health["consecutive_failures"] == threshold
|
|
assert "db pool exhausted" in str(sch._exit_health["last_error"])
|
|
assert any(k == "exit_failures" for k, _ in _reset.alerts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduler_success_when_run_ok(monkeypatch, _reset):
|
|
import app.services.swing.fill_sweep as fs
|
|
sch = _reset.sch
|
|
_env(monkeypatch, codes=()) # 무보유 — 정상
|
|
monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM())
|
|
monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0))
|
|
sch._exit_health["consecutive_failures"] = 2
|
|
await sch._run_exit_phase_v2()
|
|
assert sch._exit_health["consecutive_failures"] == 0
|
|
assert sch._exit_health["last_success_at"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduler_mocked_exit_keeps_success_semantics(monkeypatch, _reset):
|
|
"""목으로 대체돼 실행 seq 가 안 바뀌면(이번 실행 결과 없음) 이전 실패 상태에 끌려가지 않는다."""
|
|
import app.services.swing.fill_sweep as fs
|
|
sch, es = _reset.sch, _reset.es
|
|
es._exit_run_state_v2.update({"seq": 5, "ok": False, "error": "old"})
|
|
monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(return_value=[]))
|
|
monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM())
|
|
monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0))
|
|
await sch._run_exit_phase_v2()
|
|
assert sch._exit_health["consecutive_failures"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_holdings_raising_is_failed_run(monkeypatch, _reset):
|
|
env = _env(monkeypatch, codes=("005930", "000660"), order=OK, at_exc=RuntimeError("orm boom"))
|
|
exits, ok, errors = await env.es.check_and_execute_exit_v2_impl(MagicMock())
|
|
assert ok is False and exits == [] and env.sent == []
|
|
st = env.es.get_last_exit_run_status_v2()
|
|
assert st["holdings"] == 2 and st["errored"] == 2
|
|
assert "전부" in st["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_symbol_error_run_ok_but_symbol_alert_at_threshold(monkeypatch, _reset):
|
|
env = _env(monkeypatch, codes=("005930", "000660"), order=OK)
|
|
es = env.es
|
|
real_at = es.get_swing_active_trade
|
|
|
|
async def _at(db, code, *a, **kw):
|
|
if code == "000660":
|
|
raise RuntimeError("bad row")
|
|
return await real_at(db, code, *a, **kw)
|
|
monkeypatch.setattr(es, "get_swing_active_trade", _at)
|
|
threshold = es._exit_alert_threshold_v2(
|
|
"SWING_EXIT_SYMBOL_ERROR_ALERT_COUNT", es._DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT)
|
|
for i in range(threshold):
|
|
_ok_before = [k for k, _ in _reset.alerts]
|
|
await _cycle(es)
|
|
st = es.get_last_exit_run_status_v2()
|
|
assert st["ok"] is True and st["errored"] == 1
|
|
if i < threshold - 1:
|
|
assert "exit_symbol_error:000660" not in _ok_before
|
|
assert es._swing_exit_error_counts["000660"] == threshold
|
|
assert "005930" not in es._swing_exit_error_counts
|
|
assert ("exit_symbol_error:000660" in [k for k, _ in _reset.alerts])
|
|
assert "exit_symbol_errors" in es.get_swing_exit_health_v2()["degraded_reasons"]
|
|
# 복구되면 카운터 해제
|
|
monkeypatch.setattr(es, "get_swing_active_trade", real_at)
|
|
await _cycle(es)
|
|
assert "000660" not in es._swing_exit_error_counts
|
|
assert "exit_symbol_errors" not in es.get_swing_exit_health_v2()["degraded_reasons"]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# #2 — 매도 거부 연속 집계 · 경보 · health
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sell_reject_counts_and_alerts_at_threshold_then_clears(monkeypatch, _reset):
|
|
reject = {"rt_cd": "1", "msg_cd": "APBK0919", "msg1": "거래정지 종목"}
|
|
env = _env(monkeypatch, order=reject)
|
|
es = env.es
|
|
threshold = es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", es._DEFAULT_SELL_REJECT_ALERT_COUNT)
|
|
for i in range(threshold):
|
|
await _cycle(es)
|
|
assert es._swing_sell_reject_counts["005930"] == i + 1
|
|
keys = [k for k, _ in _reset.alerts]
|
|
assert ("sell_reject:005930" in keys) == (i + 1 >= threshold)
|
|
assert len(env.sent) == threshold # 손절 재시도 자체는 계속(가드가 매도를 막지 않는다)
|
|
h = es.get_swing_exit_health_v2()
|
|
assert "swing_sell_rejects" in h["degraded_reasons"]
|
|
assert "swing_sell_account_reject" not in h["degraded_reasons"]
|
|
assert "swing_sell_rejects" in _reset.sch.get_swing_scheduler_health_v2()["degraded_reasons"]
|
|
# 접수 성공 → 해제
|
|
env.state["order"] = OK
|
|
await _cycle(es)
|
|
assert "005930" not in es._swing_sell_reject_counts
|
|
assert "swing_sell_rejects" not in es.get_swing_exit_health_v2()["degraded_reasons"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_account_reject_alerts_immediately(monkeypatch, _reset):
|
|
codes = list(settings.KIS_ACCOUNT_REJECT_MSG_CODES or [])
|
|
assert codes, "KIS_ACCOUNT_REJECT_MSG_CODES 가 비어 있으면 이 경보는 비활성"
|
|
env = _env(monkeypatch, order={"rt_cd": "1", "msg_cd": codes[0], "msg1": "계좌 거부"})
|
|
await _cycle(env.es)
|
|
keys = [k for k, _ in _reset.alerts]
|
|
assert keys.count("sell_reject_account") == 1
|
|
h = env.es.get_swing_exit_health_v2()
|
|
assert "swing_sell_account_reject" in h["degraded_reasons"]
|
|
assert "swing_sell_account_reject" in _reset.sch.get_swing_scheduler_health_v2()["degraded_reasons"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_counter_pruned_when_holding_gone(monkeypatch, _reset):
|
|
env = _env(monkeypatch, order={"rt_cd": "1", "msg_cd": "APBK0919", "msg1": "x"})
|
|
await _cycle(env.es)
|
|
assert env.es._swing_sell_reject_counts.get("005930") == 1
|
|
_env(monkeypatch, codes=())
|
|
await _cycle(env.es)
|
|
assert env.es._swing_sell_reject_counts == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_failure_does_not_break_exit(monkeypatch, _reset):
|
|
"""경보 예약이 예외를 내도 카운터는 갱신되고 청산 루프는 계속(가드는 try 밖)."""
|
|
sch = _reset.sch
|
|
|
|
def _boom(*a, **kw):
|
|
raise RuntimeError("no loop")
|
|
monkeypatch.setattr(sch, "_notify_throttled_v2", _boom)
|
|
env = _env(monkeypatch, codes=("005930", "000660"),
|
|
order={"rt_cd": "1", "msg_cd": list(settings.KIS_ACCOUNT_REJECT_MSG_CODES)[0], "msg1": "x"})
|
|
await _cycle(env.es)
|
|
assert env.es._swing_sell_reject_counts == {"005930": 1, "000660": 1}
|
|
assert env.es.get_last_exit_run_status_v2()["ok"] is True
|
|
|
|
|
|
def test_threshold_rejects_disable_values(monkeypatch):
|
|
import app.services.swing.exit_service as es
|
|
monkeypatch.setattr(es, "settings", SimpleNamespace(SWING_SELL_REJECT_ALERT_COUNT=0))
|
|
assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 3
|
|
monkeypatch.setattr(es, "settings", SimpleNamespace(SWING_SELL_REJECT_ALERT_COUNT="abc"))
|
|
assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 3
|
|
monkeypatch.setattr(es, "settings", SimpleNamespace(SWING_SELL_REJECT_ALERT_COUNT=5))
|
|
assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 5
|