"""스윙 청산 하드닝 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"]