"""스윙 6회차 — 대체가 손절의 고변동 판정(H-1)·연속 skip 경보(M-4)·로그 없는 except(Low). H-1 재현: 고변동 종목(ATR/종가 > 3%)이 -8% 일 때 실시세 주기에는 고변동 손절(-10%)이라 보유, 시세 조회 1회 실패(대체가) 주기에는 일봉 미조회 → ATR None → 일반 손절(-7%)로 시장가 오손절. 수정: 직전 정상 지표 경로의 ATR 을 캐시해 대체가 판정에 쓰고, 없으면 가장 느슨한 손절률. """ import logging import time 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 @pytest.fixture(autouse=True) def _reset(monkeypatch): import app.services.swing.exit_service as es for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr", "_presell_check_failures", "_no_price_skips"): monkeypatch.setattr(es, name, {}) 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) def _env(monkeypatch, *, avg=10000, price, prices, high_vol=True, holding_updated_ago=30): """prices: get_current_price 응답 순서(Exception 이면 조회 실패).""" import app.services.swing.exit_service as es import app.services.trade_executor as te from app.core.timeutil import now_kst holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10, average_price=avg, current_price=price, market="KRX", updated_at=now_kst() - timedelta(seconds=holding_updated_ago)) monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding])) 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) monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at)) api = MagicMock() rng = 250 if high_vol else 40 # 고변동: ATR≈500(>3%) bars = [{"stck_clpr": str(price + 100 + (i % 3) * 50), "stck_hgpr": str(price + 100 + rng + (i % 3) * 50), "stck_lwpr": str(price + 100 - rng + (i % 3) * 50)} for i in range(30)] api.get_daily_chart = AsyncMock(return_value={"output2": bars}) queue = list(prices) async def _gcp(code): p = queue.pop(0) if queue else RuntimeError("no more") if isinstance(p, Exception): raise p return {"output": {"stck_prpr": str(p)}} api.get_current_price = _gcp api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) monkeypatch.setattr(es, "korea_investment_api", api) from app.services.daytrading.position_manager import day_trading_state monkeypatch.setattr(day_trading_state, "positions", {}) orders = [] async def _spo(**kw): orders.append(kw["quantity"]) return {"rt_cd": "1", "msg_cd": "X", "msg1": "stop here"} monkeypatch.setattr(te, "safe_place_order", _spo) return es, orders async def _cycle(es): es._recent_exits.clear() await es.check_and_execute_exit_v2(MagicMock()) @pytest.mark.asyncio async def test_h1_high_vol_stale_quote_uses_cached_atr(monkeypatch): """[재현 반전] 실시세 주기 보유(-8%, 고변동) → 시세 실패 주기에도 보유(이전: -7% 오손절).""" es, orders = _env(monkeypatch, price=9200, prices=[9200, RuntimeError("EGW00201")]) await _cycle(es) assert orders == [] assert "005930" in es._last_atr await _cycle(es) assert orders == [] @pytest.mark.asyncio async def test_h1_stale_without_atr_uses_loosest_stop(monkeypatch): loosest = -max(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL)) tight = -min(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL)) between = int(10000 * (1 + (tight + loosest) / 2 / 100)) # 타이트 손절 초과·느슨 손절 미만 es, orders = _env(monkeypatch, price=between, prices=[RuntimeError("down")]) await _cycle(es) assert orders == [] beyond = int(10000 * (1 + (loosest - 1) / 100)) es2, orders2 = _env(monkeypatch, price=beyond, prices=[RuntimeError("down")]) await _cycle(es2) assert orders2 == [10] @pytest.mark.asyncio async def test_h1_cached_low_vol_atr_keeps_normal_stop(monkeypatch): """캐시된 ATR 이 저변동이면 일반 손절률 그대로(완화는 ATR 을 모를 때만).""" floor = -abs(settings.STOP_LOSS_PERCENT) below = int(10000 * (1 + (floor - 1) / 100)) es, orders = _env(monkeypatch, price=below, prices=[RuntimeError("down")]) from app.core.timeutil import now_kst es._last_atr["005930"] = (below * 0.01, time.monotonic(), now_kst()) # ATR 1% await _cycle(es) assert orders == [10] @pytest.mark.asyncio async def test_h1_expired_or_pre_entry_atr_ignored(monkeypatch): floor = -abs(settings.STOP_LOSS_PERCENT) below = int(10000 * (1 + (floor - 1) / 100)) # 일반 손절 초과, 느슨 손절 미만 from app.core.timeutil import now_kst age = float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC) + 60 es, orders = _env(monkeypatch, price=below, prices=[RuntimeError("down")]) es._last_atr["005930"] = (below * 0.01, time.monotonic() - age, now_kst() - timedelta(seconds=age)) await _cycle(es) assert orders == [] # 만료된 저변동 ATR 무시 → 느슨한 손절 es2, orders2 = _env(monkeypatch, price=below, prices=[RuntimeError("down")]) es2._last_atr["005930"] = (below * 0.01, time.monotonic(), now_kst() - timedelta(days=5)) # 진입 이전 await _cycle(es2) assert orders2 == [] def test_stale_fallback_stop_pct_v2_resolver_failure_floor(monkeypatch): import app.services.swing.exit_service as es def _boom(*a, **k): raise RuntimeError("x") monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", _boom) assert es._stale_fallback_stop_pct_v2("005930", 10000.0, None) == -max( abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL)) # ── M-4 연속 skip 경보 ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_m4_no_price_skip_alerts_per_stock(monkeypatch): n = int(settings.SWING_FALLBACK_SKIP_ALERT_AFTER) age = float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC) + 60 es, orders = _env(monkeypatch, price=8000, prices=[RuntimeError("down")] * (n + 1), holding_updated_ago=age) import app.services.telegram_notifier as tn notify = AsyncMock() monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", notify) for _ in range(n - 1): await _cycle(es) notify.assert_not_awaited() await _cycle(es) notify.assert_awaited_once() assert "005930" in notify.await_args.args[1] assert orders == [] @pytest.mark.asyncio async def test_m4_skip_counter_resets_on_live_price(monkeypatch): age = float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC) + 60 es, _ = _env(monkeypatch, price=10000, prices=[RuntimeError("down"), 10000], holding_updated_ago=age) await _cycle(es) assert es._no_price_skips.get("005930") == 1 await _cycle(es) assert "005930" not in es._no_price_skips # ── Low: 로그 없는 except → warning ─────────────────────────────────────── @pytest.mark.asyncio async def test_low_regime_and_dt_lookup_failures_warn(monkeypatch, caplog): es, _ = _env(monkeypatch, price=10000, prices=[10000]) monkeypatch.setattr(es, "check_market_regime", AsyncMock(side_effect=RuntimeError("regime"))) from app.services.daytrading.position_manager import day_trading_state class _Boom: def get(self, *a): raise RuntimeError("dt state") monkeypatch.setattr(day_trading_state, "positions", _Boom()) with caplog.at_level(logging.WARNING, logger=es.logger.name): await _cycle(es) msgs = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] assert any("시장 국면 조회 실패" in m for m in msgs) assert any("DT 보유분 조회 실패" in m for m in msgs)