Files
auto-stock/backend/tests/test_swing_exit_hardening_r1b_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

244 lines
12 KiB
Python

"""스윙 청산 하드닝 R1-b (2026-10-09) — swing/exit_service 회귀 테스트.
#2 [MEDIUM] 스윙 매도 TradeHistory 의 executed_price 가 신호가였다 → NULL + 체결 확인 예약(공통 종단 목).
#3 [LOW] '1차 익절(50%)' 사유가 소량 전량매도에도 그대로 기록 → 실효 사유(_effective_exit_reason_v2).
#5 [MEDIUM] 판정 대상 밖 보유(strategy_type NULL·미상)로 청산 루프가 무경보 휴면 → 연속 주기 경보 + /health.
#6 [MEDIUM] 잔고 첫 페이지 포화 근접(손절 보류 절벽) → 경보 + /health.
규약(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": "77"}}
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
import app.services.swing.exit_service as es
import app.services.telegram_notifier as tn
import app.services.swing.scheduler as sch
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",
"_census_state_v2"):
monkeypatch.setattr(es, name, {})
monkeypatch.setattr(es, "_unowned_streak", 0)
monkeypatch.setattr(es, "_empty_holdings_streak", 0)
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("텔레그램 실전송 금지")))
alerts = []
monkeypatch.setattr(sch, "_notify_throttled_v2",
lambda key, category, message, repeat_sec=None: alerts.append((key, message)) or True)
return SimpleNamespace(es=es, alerts=alerts)
def _db(rows=None, exc=None):
db = MagicMock()
if exc is not None:
db.execute = AsyncMock(side_effect=exc)
else:
res = MagicMock()
res.all = MagicMock(return_value=list(rows or []))
db.execute = AsyncMock(return_value=res)
return db
def _env(monkeypatch, *, codes=("005930",), qty=10, price=8000, signal=None,
hldg=None, sellable=None):
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 = [SimpleNamespace(stock_code=c, stock_name="T", quantity=qty, average_price=10000,
current_price=price, market="KRX", updated_at=now_kst()) for c in codes]
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)
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)}})
_h = qty if hldg is None else hldg
_s = _h if sellable is None else sellable
api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [
{"pdno": c, "hldg_qty": str(_h), "ord_psbl_qty": str(_s)} for c in codes]})
monkeypatch.setattr(es, "korea_investment_api", api)
sig = signal or ExitSignal(action="SELL_ALL", reason="손절 테스트", sell_pct=1.0)
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: sig)
monkeypatch.setattr(day_trading_state, "positions", {})
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
sent = []
async def _spo(**kw):
sent.append((kw["stock_code"], kw["quantity"]))
return OK
monkeypatch.setattr(te, "safe_place_order", _spo)
th = AsyncMock()
monkeypatch.setattr(es, "enqueue_trade_history", th)
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())
fill = MagicMock()
monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", fill)
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, th=th, fill=fill)
async def _cycle(es, db=None):
es._recent_exits.clear()
return await es.check_and_execute_exit_v2(db if db is not None else MagicMock())
# ═══ #2 — 체결가는 신호가로 저장하지 않는다 ═════════════════════════════════
@pytest.mark.asyncio
async def test_stop_loss_sell_records_null_executed_price_and_schedules_fill_check(monkeypatch):
env = _env(monkeypatch, price=8000)
await _cycle(env.es)
assert env.sent == [("005930", 10)]
payload = env.th.await_args.args[0]
assert payload["order_type"] == "SELL"
assert payload["executed_price"] is None # 이전(main@10-07): 신호가 8000
assert payload["order_price"] == 8000
env.fill.assert_called_once()
kw = env.fill.call_args.kwargs
assert kw["order_id"] == "77" and kw["sell_qty"] == 10 and kw["ref_price"] == 8000.0
# ═══ #3 — 실효 청산 사유 ═════════════════════════════════════════════════════
def test_effective_reason_helper_cases():
from app.services.swing.exit_service import _effective_exit_reason_v2 as f
from app.services.trade_analyzer import _parse_exit_type
r = "1차 익절(50%): BB중앙(100)상회"
small = f(r, 0.5, 2, 2, 2)
assert small.startswith("소량 전량매도(분할 신호 50% → 실제 2/2주 전량") and small.endswith(r)
assert _parse_exit_type(small) == "take_profit"
assert f(r, 0.5, 5, 10, 10) == r # 정상 분할 — 원 사유 그대로
capped = f("손절 (close=1 <= stop=2)", 1.0, 4, 10, 10)
assert capped.startswith("KIS 상한 부분매도(전량 신호 → 실제 4/10주)")
assert _parse_exit_type(capped) == "stop_loss"
assert f("손절", 1.0, 10, 10, 10) == "손절"
assert f("x", "bad", 1, 1, 1) == "x" # 가공 실패 → 원 사유(매도는 막지 않음)
@pytest.mark.asyncio
async def test_small_position_partial_signal_records_full_sell_reason(monkeypatch):
"""[재현 반전] id330 KT&G 2주 전량 — 이전: exit_reason '1차 익절(50%)…' 그대로."""
sig = ExitSignal(action="SELL_PARTIAL", reason="1차 익절(50%): BB중앙(9000)상회", sell_pct=0.5)
env = _env(monkeypatch, qty=2, price=12000, signal=sig)
await _cycle(env.es)
assert env.sent == [("005930", 2)]
reason = env.th.await_args.args[0]["exit_reason"]
assert reason.startswith("소량 전량매도(분할 신호 50% → 실제 2/2주 전량")
assert "1차 익절(50%)" in reason
@pytest.mark.asyncio
async def test_kis_capped_full_signal_records_partial_reason(monkeypatch):
env = _env(monkeypatch, qty=10, hldg=10, sellable=4, price=8000)
await _cycle(env.es)
assert env.sent == [("005930", 4)]
reason = env.th.await_args.args[0]["exit_reason"]
assert reason.startswith("KIS 상한 부분매도(전량 신호 → 실제 4/10주)")
# ═══ #5 — 판정 대상 밖 보유(휴면) 경보 ══════════════════════════════════════
def _unowned_th():
import app.services.swing.exit_service as es
return es._exit_alert_threshold_v2("SWING_EXIT_UNOWNED_ALERT_CYCLES", es._DEFAULT_EXIT_UNOWNED_ALERT_CYCLES)
@pytest.mark.asyncio
async def test_empty_holdings_with_untagged_portfolio_alerts_exit_zero_eval(monkeypatch, _reset):
"""[재현 반전] 2026-05~09 휴면 유형 — 이전: 빈 보유 = 성공, warning 로그뿐."""
env = _env(monkeypatch, codes=())
db = _db(rows=[("005930", None), ("000660", "legacy_tag"), ("035420", "external")])
th = _unowned_th()
for _ in range(th - 1):
await _cycle(env.es, db)
assert not any(k == "exit_zero_eval" for k, _ in _reset.alerts)
await _cycle(env.es, db)
assert any(k == "exit_zero_eval" and "000660" in m and "005930" in m for k, m in _reset.alerts)
h = env.es.get_swing_exit_health_v2()
assert "exit_zero_eval" in h["degraded_reasons"]
assert h["unowned_streak"] == th and h["empty_holdings_streak"] == th
assert h["census"]["unowned"] == ["000660", "005930"]
@pytest.mark.asyncio
async def test_known_non_swing_tags_do_not_alert_and_reset_streak(monkeypatch, _reset):
env = _env(monkeypatch, codes=())
env.es._unowned_streak = 99
db = _db(rows=[("035420", "external"), ("105560", "daytrading"), ("AAPL", "us_swing"), ("000270", "manual")])
await _cycle(env.es, db)
assert env.es._unowned_streak == 0
assert _reset.alerts == []
assert env.es.get_swing_exit_health_v2()["degraded_reasons"] == []
@pytest.mark.asyncio
async def test_unowned_with_swing_holdings_reports_unowned_reason(monkeypatch, _reset):
env = _env(monkeypatch, codes=("005930",), price=9900) # 손절 아님
monkeypatch.setattr(env.es, "check_exit_signal", lambda **kw: None)
db = _db(rows=[("005930", "swing"), ("000660", None)])
for _ in range(_unowned_th()):
await _cycle(env.es, db)
reasons = env.es.get_swing_exit_health_v2()["degraded_reasons"]
assert "exit_unowned_holdings" in reasons and "exit_zero_eval" not in reasons
@pytest.mark.asyncio
async def test_census_failure_does_not_block_stop_loss(monkeypatch, _reset):
env = _env(monkeypatch, price=8000)
env.es._unowned_streak = 3
await _cycle(env.es, _db(exc=RuntimeError("pool exhausted")))
assert env.sent == [("005930", 10)] # 청산 우선
assert env.es._unowned_streak == 3 # 판단 불가 — 상태 유지
assert env.es.get_last_exit_run_status_v2()["ok"] is True
# ═══ #6 — 잔고 첫 페이지 포화 근접 ══════════════════════════════════════════
@pytest.mark.asyncio
async def test_balance_page_near_full_alerts(monkeypatch, _reset):
env = _env(monkeypatch, codes=())
warn = env.es._balance_page_warn_rows_v2()
assert warn == int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - env.es._BALANCE_PAGE_WARN_MARGIN
rows = [(f"{i:06d}", "external") for i in range(warn - 1)] + [("AAPL", "us_swing")]
await _cycle(env.es, _db(rows=rows))
assert "balance_page_near_full" not in env.es.get_swing_exit_health_v2()["degraded_reasons"]
rows = [(f"{i:06d}", "external") for i in range(warn)]
await _cycle(env.es, _db(rows=rows))
assert any(k == "balance_page_near_full" for k, _ in _reset.alerts)
assert "balance_page_near_full" in env.es.get_swing_exit_health_v2()["degraded_reasons"]