"""[하드닝 R1 2026-10-09] trade_executor 회귀 테스트. 1. 추천 경로 매도(_execute_sell) ↔ swing/exit_service 공용 가드 - 공용 청산 쿨다운(exit_service._recent_exits / EXIT_COOLDOWN_SEC) - 매도 직전 KIS 실보유 상한(min(주문가능, 보유) − DT) — 목은 두 경로의 공통 종단인 exit_service._fetch_kis_holding_v2 에 건다. 2. 가격 필터 ETF/리츠 예외의 종목명 판정이 부분문자열이 아니다. """ import inspect import time from types import SimpleNamespace from unittest.mock import AsyncMock import pytest import app.services.trade_executor as te import app.services.swing.exit_service as es CODE = "005930" class _Boom(Exception): pass def _boom(name): async def _f(*a, **k): raise _Boom(f"{name} 가 호출되면 안 된다") return _f class _Scal: def first(self): return None def all(self): return [] class _SellDB: async def execute(self, stmt): return SimpleNamespace(scalars=lambda: _Scal(), scalar_one_or_none=lambda: None) @pytest.fixture def env(monkeypatch): """주문·DB 큐·체결확인을 격리. 주문은 기록만, 공용 쿨다운 딕셔너리는 테스트별 새 객체.""" import app.services.db_worker as dbw from app.services.loss_tracker import loss_tracker from app.services.daytrading.position_manager import day_trading_state class _Orders(list): result = {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}} orders = _Orders() async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3): orders.append({"quantity": quantity, "price": price}) return orders.result monkeypatch.setattr(te, "_place_order_with_retry", _po) monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True)) monkeypatch.setattr(te, "_release_order_lock", AsyncMock(return_value=None)) monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) async def _noop(*a, **k): return None for name in ("_verify_and_sync_execution", "_verify_and_sync_execution_v2", "_verify_and_sync_execution_v3"): if hasattr(te, name): monkeypatch.setattr(te, name, _noop) monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop) monkeypatch.setattr(dbw, "enqueue_close_active_trade", _noop) monkeypatch.setattr(dbw, "enqueue_db_write", _noop) monkeypatch.setattr(loss_tracker, "record_trade_result_safe", AsyncMock()) monkeypatch.setattr(loss_tracker, "save_to_db", AsyncMock()) # 시장가 경로(전량)만 쓰므로 시세 조회는 호출되면 안 된다 monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price")) monkeypatch.setattr(es, "_recent_exits", {}) monkeypatch.setattr(es, "_presell_check_failures", {}) monkeypatch.setattr(day_trading_state, "positions", {}) return orders def _kis(hldg, sellable): async def _f(stock_code): return (hldg, sellable) return _f async def _sell(sell_pct=1.0, qty=10, remaining_pct=1.0): h = SimpleNamespace(quantity=qty, average_price=60000, profit_loss_rate=0, strategy_type="swing", stock_name="삼성", current_price=70000) stock = {"stock_code": CODE, "strategy_type": "swing", "strategy_name": "swing", "remaining_pct": remaining_pct} return await te._execute_sell(stock, _SellDB(), h, 70000, ["x"], True, "KRX", CODE, "삼성", sell_pct=sell_pct) # ── 1. 공용 청산 쿨다운 ───────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_recent_swing_exit_blocks_recommendation_sell(env, monkeypatch): """exit_service 가 막 청산한 종목은 추천 경로가 같은 단계를 다시 팔지 않는다.""" monkeypatch.setattr(es, "_fetch_kis_holding_v2", _boom("_fetch_kis_holding_v2")) es._recent_exits[CODE] = time.time() - 5 r = await _sell(0.5) assert r["action"] == "SKIP" and r["reason"] == "recent_swing_exit_cooldown" assert env == [] @pytest.mark.asyncio async def test_cooldown_expired_allows_sell(env, monkeypatch): monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) es._recent_exits[CODE] = time.time() - float(es.EXIT_COOLDOWN_SEC) - 1 r = await _sell(1.0) assert r["action"] == "SELL" and env and env[-1]["quantity"] == 10 @pytest.mark.asyncio async def test_successful_sell_marks_shared_cooldown(env, monkeypatch): """추천 경로 매도 접수 → exit_service 도 같은 쿨다운을 본다.""" monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) before = time.time() await _sell(1.0) assert es._recent_exits.get(CODE, 0) >= before @pytest.mark.asyncio async def test_rejected_sell_does_not_mark_cooldown(env, monkeypatch): """업무 거부(FAILED)는 체결이 없으므로 쿨다운에 기록하지 않는다(즉시 재시도 허용).""" monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) env.result = {"rt_cd": "1", "msg_cd": "APBK0000", "msg1": "거부"} r = await _sell(1.0) assert r.get("success") is False assert CODE not in es._recent_exits # ── 2. 매도 직전 KIS 실보유 상한 ────────────────────────────────────────────── @pytest.mark.asyncio async def test_kis_cap_limits_stale_portfolio_quantity(env, monkeypatch): """Portfolio 10주(낡음)인데 KIS 실보유 6주 중 DT 4주 → 스윙 매도는 2주까지만.""" from app.services.daytrading.position_manager import day_trading_state day_trading_state.positions[CODE] = {"quantity": 4} monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(6, 6)) r = await _sell(1.0, qty=10) assert r["action"] == "SELL" assert env[-1]["quantity"] == 2, "KIS 실보유 − DT 를 넘는 수량이 주문됐다(DT 물량 과다매도)" @pytest.mark.asyncio async def test_kis_cap_uses_orderable_quantity(env, monkeypatch): """대기 매도 주문분(주문가능 < 보유)은 다시 팔지 않는다.""" monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 3)) await _sell(1.0, qty=10) assert env[-1]["quantity"] == 3 @pytest.mark.asyncio async def test_kis_check_failure_holds_sell(env, monkeypatch): async def _none(stock_code): return None monkeypatch.setattr(es, "_fetch_kis_holding_v2", _none) r = await _sell(1.0) assert r["action"] == "SKIP" and r["reason"] == "kis_holding_check_failed" assert env == [] @pytest.mark.asyncio async def test_kis_sellable_zero_no_order(env, monkeypatch): monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(0, 0)) r = await _sell(0.5) assert r["action"] == "SKIP" and r["reason"] == "kis_sellable_zero" assert env == [] @pytest.mark.asyncio async def test_kis_cap_not_applied_when_holding_matches(env, monkeypatch): """정상 상태(장부=KIS)에서는 분할매도 수량이 바뀌지 않는다 — 원본 대비 50% = 5주.""" monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) await _sell(0.5, qty=10) assert env[-1]["quantity"] == 5 def test_guards_are_outside_try(): """쿨다운·KIS 상한 가드는 주문 try 블록보다 앞(§4: 가드를 try 안에 두지 말 것).""" src = inspect.getsource(te._execute_sell) i_try = src.index("recent_cutoff = _get_kst_now()") assert src.index("_swing_exit_cooldown_block_v2(stock_code)") < i_try assert src.index("_recommendation_sell_kis_cap_v2(") < i_try # ── 3. 가격 필터 ETF/리츠 예외 판정 ───────────────────────────────────────────── @pytest.mark.parametrize("name", [ "KODEX 200", "TIGER 미국S&P500", "SOL 미국배당다우존스", "ACE 미국S&P500", "HANARO 200", "KBSTAR 200", "ARIRANG 고배당주", "KODEX200", "신한 인버스 2X 원유선물 ETN", "SK리츠", "롯데리츠", "맥쿼리인프라", "SPDR S&P 500 ETF TRUST", "Realty Income REIT", ]) def test_price_exempt_names(name): assert te._is_price_filter_exempt_name_v2(name) is True @pytest.mark.parametrize("name", [ "인프라웨어", "NETFLIX", "SOLUM", "ACELYRIN", "ACEMAN", "리츠칼튼호텔", "에이스테크", "삼성전자", "SOLID POWER", "", None, ]) def test_non_exempt_names(name): """부분문자열 일치로 일반주가 가격 필터(§5.1 >MIN_PRICE)를 우회하지 않는다.""" assert te._is_price_filter_exempt_name_v2(name) is False def test_price_filter_uses_positional_helper(): src = inspect.getsource(te.execute_auto_trade) assert "_is_etf = _is_price_filter_exempt_name_v2(stock_name)" in src assert "_ETF_KEYWORDS" not in src