"""[3회차 2026-10-02] 검증 결함 회귀 — H-1 / M-1 / M-2 / M-3 / M-4 / Low. (C-1·C-2·mid-loop 영속은 tests/test_mdd_reduction_retry_20261002.py) 주문 종단은 캡처형, 호출되면 안 되는 경로는 예외 발생형. 기대값은 settings.* 기준. """ import inspect from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from fastapi import HTTPException from app.core.config import settings from app.services import trade_executor as te class _MustNotCall(BaseException): pass def _boom(name): async def _f(*a, **k): raise _MustNotCall(f"{name} 가 호출되면 안 된다") return _f async def _nosleep(*a, **k): return None # ═════════════════════════════════════════════════════════════════════════════ # H-1: /us-trading/execute — SELL 허용(가드·부기), BUY 410 # ═════════════════════════════════════════════════════════════════════════════ @pytest.fixture def us_env(monkeypatch): from app.api.endpoints import us_trading as us from app.services.korea_investment_api import korea_investment_api as k import app.services.db_worker as dbw import app.services.portfolio_cache_bridge as pcb from app.services.loss_tracker import loss_tracker state = {"balance": {"rt_cd": "0", "output1": [{ "ovrs_pdno": "AAPL", "ovrs_item_name": "Apple", "ovrs_excg_cd": "NASD", "ord_psbl_qty": "5", "ovrs_cblc_qty": "5", "pchs_avg_pric": "200", "now_pric2": "180", }]}} monkeypatch.setattr(k, "get_overseas_balance", AsyncMock(side_effect=lambda: state["balance"])) orders, writes, reflected = [], [], [] async def _order(**kw): orders.append(kw) return {"rt_cd": "0", "output": {"ODNO": "7"}} monkeypatch.setattr(te, "safe_place_order", _order) async def _noop(*a, **k): return None async def _w(task): writes.append(task) async def _reflect(code, qty, price, remaining_qty=None): reflected.append((code, qty, remaining_qty)) monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) monkeypatch.setattr(dbw, "enqueue_db_write", _w) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop) monkeypatch.setattr(pcb, "reflect_sell", _reflect) rec, save = AsyncMock(), AsyncMock() monkeypatch.setattr(loss_tracker, "record_trade_result_safe", rec) monkeypatch.setattr(loss_tracker, "save_to_db", save) return SimpleNamespace(us=us, state=state, orders=orders, writes=writes, reflected=reflected, rec=rec, save=save) @pytest.mark.asyncio async def test_h1_us_buy_still_gone(us_env, monkeypatch): monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) with pytest.raises(HTTPException) as ei: await us_env.us.execute_us_trade(us_env.us.UsOrderRequest(ticker="AAPL", order_type="buy", quantity=1)) assert ei.value.status_code == 410 @pytest.mark.asyncio async def test_h1_us_sell_allowed_even_when_flag_off(us_env, monkeypatch): e = us_env monkeypatch.setattr(settings, "US_TRADING_ENABLED", False) with pytest.raises(HTTPException) as ei: await e.us.execute_us_trade(e.us.UsOrderRequest(ticker="AAPL", order_type="sell", quantity=6)) assert ei.value.status_code == 400 and e.orders == [] out = await e.us.execute_us_trade(e.us.UsOrderRequest(ticker="aapl", order_type="sell", quantity=5)) assert out["success"] is True and out["remaining"] == 0 o = e.orders[0] assert o["is_domestic"] is False and o["exchange_code"] == "NASD" and o["quantity"] == 5 assert o["order_type"] == "SELL" and o["price"] == pytest.approx(180.0) # 전량 → 비-DT ActiveTrade close + loss_tracker record/save assert e.writes and "strategy_name_not" in e.writes[0]["data"] e.rec.assert_awaited_once() e.save.assert_awaited_once() @pytest.mark.asyncio async def test_h1_us_sell_rejects_on_balance_error(us_env, monkeypatch): e = us_env monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) e.state["balance"] = {"rt_cd": "1", "msg_cd": "EGW00201"} with pytest.raises(HTTPException) as ei: await e.us.execute_us_trade(e.us.UsOrderRequest(ticker="AAPL", order_type="sell", quantity=1)) assert ei.value.status_code == 502 # ═════════════════════════════════════════════════════════════════════════════ # M-1: 매수 슬롯 = BUY&success, 전체 후보 순회, 보유 종목 청산 판정 계속, 거부 쿨다운 # ═════════════════════════════════════════════════════════════════════════════ class _Res: def __init__(self, v): self.v = v def scalars(self): return SimpleNamespace(all=lambda: list(self.v)) class _DB: def __init__(self, held): self.held = held async def execute(self, stmt): return _Res(self.held) @pytest.fixture def loop_env(monkeypatch): monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False) monkeypatch.setattr(settings, "MAX_DAILY_TRADES", 2) monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (20, 1.0)) monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0)) monkeypatch.setattr(te.asyncio, "sleep", _nosleep) te._order_reject_cooldown.clear() te._account_reject_at = None te._eval_rotation_offset = 0 seen = [] script = {} async def _eat(stock, db): seen.append((stock["stock_code"], bool(stock.get("_exit_only")))) return script.get(stock["stock_code"], {"action": "HOLD", "reason": "x"}) monkeypatch.setattr(te, "execute_auto_trade", _eat) yield SimpleNamespace(seen=seen, script=script) te._order_reject_cooldown.clear() te._account_reject_at = None te._eval_rotation_offset = 0 def _recs(codes): return {"recommendations_by_rule": [{"rule_name": "r", "stocks": [ {"stock_code": c, "rule_priority": 1, "score": 100 - i} for i, c in enumerate(codes) ]}]} @pytest.mark.asyncio async def test_m1_skip_hold_do_not_consume_slots(loop_env): e = loop_env e.script.update({ "A": {"action": "HOLD"}, # 보유 — 청산 판정 "B": {"action": "SKIP", "reason": "already_ordered_today"}, "C": {"action": "BUY", "success": True}, "D": {"action": "BUY", "success": False}, "E": {"action": "BUY", "success": True}, "F": {"action": "BUY", "success": True}, # 슬롯 소진 → 평가 안 됨 "G": {"action": "SELL", "success": True}, # 보유 — 슬롯 무관 청산 판정 }) out = await te._execute_auto_trade_background_locked(_recs(list("ABCDEFG")), _DB(["A", "G"])) codes = [c for c, _ in e.seen] # [4회차 L-3] 순회 순서 = 보유 종목(청산 판정 우선) → 신규 후보 assert codes == ["A", "G", "B", "C", "D", "E"], codes assert sum(1 for r in out if r["action"] == "BUY" and r.get("success")) == settings.MAX_DAILY_TRADES @pytest.mark.asyncio async def test_m1_evaluation_cap_and_reject_cooldown(loop_env, monkeypatch): e = loop_env monkeypatch.setattr(settings, "AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE", 2) te._note_order_reject_v2("B", {"rt_cd": "1", "msg1": "주문가능수량 부족"}) await te._execute_auto_trade_background_locked(_recs(list("ABCD")), _DB(["D"])) codes = [c for c, _ in e.seen] # 보유 D 먼저(상한 무관), B 쿨다운 skip, 신규 평가 상한 2(A,C) assert codes == ["D", "A", "C"], codes def test_m1_reject_cooldown_expires(monkeypatch): te._order_reject_cooldown.clear() te._note_order_reject_v2("X", {"rt_cd": "1"}) assert te._order_reject_cooldown_remaining_v2("X") > 0 monkeypatch.setattr(settings, "AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN", 0.0) assert te._order_reject_cooldown_remaining_v2("X") == 0.0 assert "X" not in te._order_reject_cooldown def test_m1_execute_buy_records_business_rejection_only(): src = inspect.getsource(te._execute_buy) i = src.index("_note_order_reject_v2(stock_code, order_result)") assert "not _is_retryable_order_failure(order_result)" in src[max(0, i - 200):i] assert te._is_retryable_order_failure({"rt_cd": "1", "msg_cd": "APBK0013"}) is False # ═════════════════════════════════════════════════════════════════════════════ # M-2: 토큰 무효 코드는 재시도 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio @pytest.mark.parametrize("code", ["EGW00121", "EGW00123"]) async def test_m2_token_invalid_is_retried(monkeypatch, code): k = te.korea_investment_api # trade_executor 가 실제로 참조하는 객체(모듈 재적재 대비) assert te._is_retryable_order_failure({"rt_cd": "1", "msg_cd": code}) is True calls = [] async def _po(*a, **kw): calls.append(a) return {"rt_cd": "1", "msg_cd": code} if len(calls) == 1 else {"rt_cd": "0", "output": {"ODNO": "1"}} monkeypatch.setattr(k, "place_order", _po) monkeypatch.setattr(te.asyncio, "sleep", _nosleep) r = await te._place_order_with_retry(True, "005930", "SELL", 1, 70000) assert r["rt_cd"] == "0" and len(calls) == 2 # ═════════════════════════════════════════════════════════════════════════════ # M-3: 현금 정의 단일화 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_m3_resolve_available_cash_priority(): from app.services.buy_guards import resolve_available_cash_v2 out2 = {"dnca_tot_amt": "-913965", "prvs_rcdl_excc_amt": "5224855"} k = SimpleNamespace(get_buyable_cash=AsyncMock(return_value=3_000_000)) assert await resolve_available_cash_v2(k, out2, "005930") == 3_000_000 k = SimpleNamespace(get_buyable_cash=AsyncMock(return_value=0)) assert await resolve_available_cash_v2(k, out2, "005930") == 5_224_855.0 async def _fail(*a, **kw): raise RuntimeError("x") k = SimpleNamespace(get_buyable_cash=_fail) assert await resolve_available_cash_v2(k, out2, "005930") == 5_224_855.0 def test_m3_trade_executor_uses_shared_cash_definition(): src = inspect.getsource(te._execute_buy) assert "resolve_available_cash_v2(" in src assert 'b.get("dnca_tot_amt"' not in src # ═════════════════════════════════════════════════════════════════════════════ # M-4: 사이클 매수 누적 — 취소 확정 시 제외, 해외 매수 국내 보유수 제외 # ═════════════════════════════════════════════════════════════════════════════ def test_m4_overseas_buys_excluded_from_domestic_count(): te._recent_cycle_buys.clear() te._note_cycle_buy_v2("005930", "삼성", 1, 70000) te._note_cycle_buy_v2("AAPL", "Apple", 1, 200) assert te._domestic_recent_cycle_buy_codes_v2() == {"005930"} te._recent_cycle_buys.clear() @pytest.mark.asyncio @pytest.mark.parametrize("remaining,expect_kept", [(0, False), (3, True)]) async def test_m4_dead_buy_removed_from_cycle_buys(monkeypatch, remaining, expect_kept): k = te.korea_investment_api # trade_executor 가 실제로 참조하는 객체 te._recent_cycle_buys.clear() te._note_cycle_buy_v2("005930", "삼성", 3, 70000) monkeypatch.setattr(k, "get_order_execution", AsyncMock(return_value={ "ok": True, "found": True, "filled_qty": 0, "remaining_qty": remaining, })) monkeypatch.setattr(te.asyncio, "sleep", _nosleep) async def _noop(*a, **kw): return None monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop) await te._verify_and_sync_execution("0001", "005930", "삼성", 3, 70000, "BUY", True, "KRX", 0, delay=0) assert ("005930" in te._recent_cycle_buys) is expect_kept te._recent_cycle_buys.clear() # ═════════════════════════════════════════════════════════════════════════════ # Low # ═════════════════════════════════════════════════════════════════════════════ def test_low_usd_buy_not_deducted_from_krw_cache(): src = inspect.getsource(te._execute_buy) i = src.index("_deduct_account_cache_v2(") assert "if is_domestic:" in src[max(0, i - 120):i] def test_low_partial_sell_keeps_highest_since_entry(): src = inspect.getsource(te._execute_sell) i = src.index('"highest_since_entry": max(') assert 'stock.get("highest_since_entry")' in src[i:i + 200] src2 = inspect.getsource(te.execute_auto_trade) assert 'stock["highest_since_entry"] = float(_hse)' in src2 @pytest.mark.asyncio async def test_low_legacy_sell_dt_unknown_is_503_before_order(monkeypatch): from app.api.endpoints import portfolio as pf import app.services.manual_sell_service as mss monkeypatch.setattr(mss, "dt_inmemory_qty_v2", lambda code: None) monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) holding = SimpleNamespace(quantity=10, stock_name="삼성", average_price=1, strategy_type="swing", current_price=1) class _HDB: async def execute(self, stmt): return SimpleNamespace(scalar_one_or_none=lambda: holding) with pytest.raises(HTTPException) as ei: await pf.sell_stock(pf.SellRequest(stock_code="005930", quantity=3), db=_HDB()) assert ei.value.status_code == 503