"""[2026-10-02] 가드 우회 엔드포인트 회귀 테스트 — H-3 / H-4 / H-5 / M-5 / C1 / Low. 원칙: 주문 종단(`trade_executor.safe_place_order`)·KIS 는 conftest 가 예외 발생형으로 막고 있고, 여기서는 필요한 것만 캡처형으로 덮는다. 호출되면 안 되는 경로는 예외 발생형. 기대값은 settings.* 에서 계산한다(하드코딩 금지). """ from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from fastapi import HTTPException from pydantic import ValidationError from app.core.config import settings class _MustNotCall(BaseException): pass def _boom(name): async def _f(*a, **k): raise _MustNotCall(f"{name} 가 호출되면 안 된다") return _f # ═════════════════════════════════════════════════════════════════════════════ # H-3: POST /recommendations/auto-trade → execute_auto_trade_background 위임 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_h3_auto_trade_blocked_when_disabled(monkeypatch): from app.api.endpoints import recommendations as rec monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", False) monkeypatch.setattr(rec, "get_rule_based_recommendations", _boom("추천 조회")) monkeypatch.setattr(rec, "execute_auto_trade_background", _boom("자동매매")) with pytest.raises(HTTPException) as ei: await rec.execute_auto_trading(db=object()) assert ei.value.status_code == 423 @pytest.mark.asyncio async def test_h3_auto_trade_rejects_when_bg_lock_held(monkeypatch): from app.api.endpoints import recommendations as rec from app.services import trade_executor as te monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True) monkeypatch.setattr(settings, "TRADING_HALTED", False) monkeypatch.setattr(rec, "get_rule_based_recommendations", _boom("추천 조회")) await te._auto_trade_bg_lock.acquire() try: with pytest.raises(HTTPException) as ei: await rec.execute_auto_trading(db=object()) assert ei.value.status_code == 409 finally: te._auto_trade_bg_lock.release() @pytest.mark.asyncio async def test_h3_auto_trade_delegates_to_background(monkeypatch): from app.api.endpoints import recommendations as rec from app.services import trade_executor as te monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True) monkeypatch.setattr(settings, "TRADING_HALTED", False) payload = {"recommendations_by_rule": [{"rule_name": "r", "stocks": [{"stock_code": "005930"}]}]} monkeypatch.setattr(rec, "get_rule_based_recommendations", AsyncMock(return_value=payload)) bg = AsyncMock(return_value=[ {"action": "BUY", "success": True}, {"action": "BUY", "success": False}, {"action": "SKIP"}, ]) monkeypatch.setattr(rec, "execute_auto_trade_background", bg) # 직접 종목별 실행 경로가 남아 있으면 즉시 실패 monkeypatch.setattr(te, "execute_auto_trade", _boom("execute_auto_trade(직접)")) out = await rec.execute_auto_trading(db="DB") bg.assert_awaited_once_with(payload, "DB") assert "execute_auto_trade" not in vars(rec), "엔드포인트 모듈이 직접 실행 함수를 import 하고 있다" assert "진입 1건" in out["message"] assert len(out["trades"]) == 2 # ═════════════════════════════════════════════════════════════════════════════ # H-5: /us-trading/execute 410, /cycle 플래그 OFF 423 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio @pytest.mark.parametrize("flag", [False, True]) async def test_h5_execute_is_gone_regardless_of_flag(monkeypatch, flag): from app.api.endpoints import us_trading as us monkeypatch.setattr(settings, "US_TRADING_ENABLED", flag) req = us.UsOrderRequest(ticker="AAPL", order_type="buy", quantity=1, price=100.0) with pytest.raises(HTTPException) as ei: await us.execute_us_trade(req) assert ei.value.status_code == 410 @pytest.mark.asyncio async def test_h5_cycle_blocked_when_flag_off(monkeypatch): from app.api.endpoints import us_trading as us import app.services.us_market_scheduler as ums monkeypatch.setattr(settings, "US_TRADING_ENABLED", False) monkeypatch.setattr(ums, "us_trade_cycle", _boom("us_trade_cycle")) with pytest.raises(HTTPException) as ei: await us.trigger_us_trade_cycle() assert ei.value.status_code == 423 @pytest.mark.asyncio async def test_h5_cycle_runs_and_http_exception_passthrough(monkeypatch): from app.api.endpoints import us_trading as us import app.services.us_market_scheduler as ums monkeypatch.setattr(settings, "US_TRADING_ENABLED", True) monkeypatch.setattr(ums, "us_trade_cycle", AsyncMock(return_value={"ok": 1})) assert await us.trigger_us_trade_cycle() == {"ok": 1} async def _raise_http(): raise HTTPException(status_code=409, detail="x") monkeypatch.setattr(ums, "us_trade_cycle", _raise_http) with pytest.raises(HTTPException) as ei: await us.trigger_us_trade_cycle() assert ei.value.status_code == 409, "HTTPException 이 500 으로 뭉개졌다" # ═════════════════════════════════════════════════════════════════════════════ # H-4: 공용 매수 가드 # ═════════════════════════════════════════════════════════════════════════════ def _cap(): return settings.MAX_POSITION_SIZE def test_h4_requested_quantity_is_clamped_to_cap(): from app.services.buy_guards import size_manual_buy_v2 total, cash, px = 10_000_000.0, 5_000_000.0, 10_000.0 d = size_manual_buy_v2( stock_code="005930", order_price=px, current_price=px, requested_quantity=1000, total_assets=total, available_cash=cash, position_cap_ratio=_cap(), existing_qty=0, leverage_room=None, ) assert d.clamped is True assert d.quantity == int(total * _cap() // px) assert d.quantity * px <= total * _cap() def test_h4_cumulative_cap_subtracts_existing_holding(): from app.services.buy_guards import BuyGuardRejected, size_manual_buy_v2 total, px = 10_000_000.0, 10_000.0 full_qty = int(total * _cap() // px) # 이미 상한 꽉 참 → 거부 with pytest.raises(BuyGuardRejected) as ei: size_manual_buy_v2( stock_code="005930", order_price=px, current_price=px, requested_quantity=None, total_assets=total, available_cash=total, position_cap_ratio=_cap(), existing_qty=full_qty, leverage_room=None, ) assert ei.value.reason == "budget_below_min" # 절반 보유 → 나머지 절반만 half = full_qty // 2 d = size_manual_buy_v2( stock_code="005930", order_price=px, current_price=px, requested_quantity=None, total_assets=total, available_cash=total, position_cap_ratio=_cap(), existing_qty=half, leverage_room=None, ) assert (half + d.quantity) * px <= total * _cap() + 1e-6 def test_h4_cash_reserve_enforced(): from app.services.buy_guards import BuyGuardRejected, size_manual_buy_v2 total, px = 10_000_000.0, 10_000.0 reserve = total * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) with pytest.raises(BuyGuardRejected): size_manual_buy_v2( stock_code="005930", order_price=px, current_price=px, requested_quantity=1, total_assets=total, available_cash=reserve + px / 2, position_cap_ratio=_cap(), existing_qty=0, leverage_room=None, ) def test_h4_leverage_room_includes_this_order(): from app.services.buy_guards import leverage_room_v2 total = 10_000_000.0 held = [SimpleNamespace(stock_name="KODEX 레버리지", quantity=100, current_price=total * settings.LEVERAGE_ETF_MAX_RATIO / 100 - 1000, average_price=0)] room = leverage_room_v2("TIGER 인버스", held, total) assert room == pytest.approx(100 * 1000) assert leverage_room_v2("삼성전자", held, total) is None def test_h4_cash_field_trust_order(): from app.services.buy_guards import pick_available_cash_v2 assert pick_available_cash_v2({ "dnca_tot_amt": "-913965", "nxdy_excc_amt": "4524276", "prvs_rcdl_excc_amt": "5224855", }) == 5224855.0 @pytest.mark.parametrize("existing_qty,invest", [(0, 400_000), (10, 400_000), (25, 50_000), (39, 10_000)]) def test_h4_position_room_matches_trade_executor(existing_qty, invest): """buy_guards 의 누적 상한 정의가 trade_executor._apply_position_cap_room_v2 와 같다.""" from app.services import trade_executor as te from app.services.buy_guards import position_cap_room_v2 total, px = 10_000_000.0, 10_000.0 holding = SimpleNamespace(quantity=existing_qty) te_amount, te_reason = te._apply_position_cap_room_v2( invest, holding, "TEST01", "t", px, total, _cap(), ) room = position_cap_room_v2(total, _cap(), existing_qty, px) if existing_qty == 0: assert te_reason is None and room == pytest.approx(total * _cap()) elif te_reason is None: assert te_amount == pytest.approx(min(invest, room)) else: assert te_reason == "position_cap_reached" class _Res: def __init__(self, v): self.v = v def scalar_one_or_none(self): return self.v def scalar(self): return self.v def scalars(self): return SimpleNamespace(all=lambda: list(self.v or []), first=lambda: (self.v or [None])[0]) class _FakeDB: def __init__(self, results): self.results = list(results) self.stmts = [] async def execute(self, stmt): self.stmts.append(stmt) return _Res(self.results.pop(0)) async def delete(self, obj): raise _MustNotCall("직접 delete") async def commit(self): raise _MustNotCall("직접 commit") def _adp(mode="normal", fallback=False, size=None, trades=5): return SimpleNamespace( fallback_used=fallback, trading_mode=mode, position_size=size if size is not None else settings.MAX_POSITION_SIZE, max_daily_trades=trades, rsi_entry=settings.MEAN_REVERSION_RSI_THRESHOLD, bear_rsi_entry=settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD, ) @pytest.fixture def manual_buy_env(monkeypatch): from app.api.endpoints import manual_buy as mb from app.services import trade_executor as te import app.services.adaptive_strategy as ad import app.services.correlation_filter as cf import app.services.db_worker as dbw import app.services.system_buy_registry as sbr total, cash, px = 10_000_000.0, 5_000_000.0, 10_000.0 k = mb.korea_investment_api monkeypatch.setattr(k, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": str(int(px)), "hts_kor_isnm": "삼성전자"}})) monkeypatch.setattr(k, "get_balance", AsyncMock(return_value={"output2": [{"tot_evlu_amt": str(total), "dnca_tot_amt": "-1"}]})) monkeypatch.setattr(k, "get_buyable_cash", AsyncMock(return_value=cash)) monkeypatch.setattr(mb.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) monkeypatch.setattr(mb.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: _adp()) monkeypatch.setattr(cf, "validate_correlation_filter", AsyncMock(return_value=(True, "ok"))) orders = [] async def _order(stock_code, order_type, quantity, price=None, **kw): orders.append(quantity) return {"rt_cd": "0", "output": {"ODNO": "1"}} monkeypatch.setattr(te, "safe_place_order", _order) async def _noop(*a, **k): return None monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop) monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _noop) monkeypatch.setattr(sbr, "note_system_buy", lambda *a, **k: None) return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, ad=ad, k=k, cf=cf) @pytest.mark.asyncio async def test_h4_manual_buy_endpoint_clamps_requested_quantity(manual_buy_env): e = manual_buy_env db = _FakeDB([None, 0, []]) # holding / count / current_holdings req = e.mb.BuyRequest(stock_code="005930", quantity=1000) out = await e.mb._buy_stock_impl(req, "005930", db) expected = int(e.total * settings.MAX_POSITION_SIZE // e.px) assert e.orders == [expected] assert out["clamped"] is True and out["requested_quantity"] == 1000 @pytest.mark.asyncio async def test_h4_manual_buy_endpoint_cumulative_with_existing(manual_buy_env): e = manual_buy_env full = int(e.total * settings.MAX_POSITION_SIZE // e.px) holding = SimpleNamespace(quantity=full, stock_name="삼성전자") db = _FakeDB([holding, [holding]]) with pytest.raises(HTTPException) as ei: await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", db) assert ei.value.status_code == 400 assert e.orders == [] @pytest.mark.asyncio async def test_h4_manual_buy_blocked_in_crisis(manual_buy_env, monkeypatch): e = manual_buy_env monkeypatch.setattr(e.ad, "get_current_adaptive_params", lambda: _adp(mode="crisis", trades=0)) with pytest.raises(HTTPException) as ei: await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []])) assert ei.value.status_code == 423 assert e.orders == [] @pytest.mark.asyncio async def test_h4_manual_buy_rejects_on_balance_failure(manual_buy_env, monkeypatch): e = manual_buy_env async def _fail(*a, **k): raise RuntimeError("KIS down") monkeypatch.setattr(e.k, "get_balance", _fail) with pytest.raises(HTTPException) as ei: await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930", quantity=1), "005930", _FakeDB([None, 0, []])) assert ei.value.status_code == 502 assert e.orders == [] @pytest.mark.asyncio async def test_h4_manual_buy_rejects_on_sector_filter(manual_buy_env, monkeypatch): e = manual_buy_env monkeypatch.setattr(e.cf, "validate_correlation_filter", AsyncMock(return_value=(False, "sector_full"))) with pytest.raises(HTTPException) as ei: await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []])) assert ei.value.status_code == 400 assert e.orders == [] # ═════════════════════════════════════════════════════════════════════════════ # M-5: 수동 청산 부기 # ═════════════════════════════════════════════════════════════════════════════ def test_m5_allocation_dt_first_and_swing_only(): from app.services.manual_sell_service import allocate_sell_v2 a = allocate_sell_v2(sold=6, row_qty_before=10, dt_qty=4, swing_only=False) assert (a.dt_sold, a.swing_sold, a.dt_left, a.swing_left) == (4, 2, 0, 4) b = allocate_sell_v2(sold=6, row_qty_before=10, dt_qty=4, swing_only=True) assert (b.dt_sold, b.swing_sold, b.swing_left) == (0, 6, 0) c = allocate_sell_v2(sold=3, row_qty_before=5, dt_qty=9, swing_only=False) # stale DT assert c.dt_before == 5 and c.dt_sold == 3 @pytest.mark.asyncio async def test_m5_active_trade_payloads_always_carry_strategy_filter(monkeypatch): import app.services.db_worker as dbw from app.services.manual_sell_service import allocate_sell_v2, sync_active_trade_after_manual_sell_v2 writes, upserts = [], [] async def _w(task): writes.append(task) async def _u(p): upserts.append(p) monkeypatch.setattr(dbw, "enqueue_db_write", _w) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u) # 전량(스윙 6 + DT 4) → 비-DT close + DT close await sync_active_trade_after_manual_sell_v2("005930", allocate_sell_v2(10, 10, 4, False)) assert [w["data"].get("strategy_name_not") or w["data"].get("strategy_names") for w in writes] assert all(("strategy_name_not" in w["data"]) ^ ("strategy_names" in w["data"]) for w in writes) assert len(writes) == 2 and upserts == [] # 부분(DT 4 전량 + 스윙 2) → DT close, 스윙 upsert(잔량 4, 비율 4/6) writes.clear() await sync_active_trade_after_manual_sell_v2("005930", allocate_sell_v2(6, 10, 4, False)) assert len(writes) == 1 and "strategy_names" in writes[0]["data"] assert upserts[0]["quantity"] == 4 and upserts[0]["strategy_name_not"] assert upserts[0]["remaining_pct_multiply"] == pytest.approx(4 / 6) def test_m5_swing_sell_request_rejects_nonpositive(): from app.api.endpoints.swing_portfolio import SwingSellRequest for bad in (0, -3): with pytest.raises(ValidationError): SwingSellRequest(stock_code="005930", quantity=bad) @pytest.fixture def swing_sell_env(monkeypatch): from app.api.endpoints import swing_portfolio as sp from app.services import trade_executor as te import app.services.db_worker as dbw import app.services.portfolio_cache_bridge as pcb from app.services.loss_tracker import loss_tracker from app.services.daytrading.position_manager import day_trading_state monkeypatch.setattr(sp.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "10000"}})) orders, writes, reflected = [], [], [] async def _order(stock_code, order_type, quantity, price=None, **kw): orders.append((order_type, quantity)) return {"rt_cd": "0", "output": {"ODNO": "9"}} 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 = AsyncMock() save = AsyncMock() monkeypatch.setattr(loss_tracker, "record_trade_result_safe", rec) monkeypatch.setattr(loss_tracker, "save_to_db", save) day_trading_state.positions.pop("005930", None) day_trading_state.positions["005930"] = {"quantity": 4, "entry_price": 10000} yield SimpleNamespace(sp=sp, orders=orders, writes=writes, reflected=reflected, rec=rec, save=save) day_trading_state.positions.pop("005930", None) @pytest.mark.asyncio async def test_m5_swing_sell_excludes_dt_shares(swing_sell_env): e = swing_sell_env holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=11000, strategy_type="swing") with pytest.raises(HTTPException) as ei: await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930", quantity=8), db=_FakeDB([holding])) assert ei.value.status_code == 400 and e.orders == [] out = await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding])) assert e.orders == [("SELL", 6)], "DT 보유분(4주)까지 매도했다" assert out["quantity"] == 6 # ActiveTrade 는 비-DT 필터로 close, Portfolio 는 큐 경유 반영 assert e.writes and all("strategy_name_not" in w["data"] for w in e.writes) assert e.reflected == [("005930", 6, 4)] # 스윙분 전량 청산 → loss_tracker record + save_to_db e.rec.assert_awaited_once() e.save.assert_awaited_once() def test_m5_legacy_sell_no_longer_closes_without_strategy_name(): import inspect from app.api.endpoints import portfolio as pf src = inspect.getsource(pf.sell_stock) assert "strategy_name=None" not in src assert "sync_active_trade_after_manual_sell_v2" in src assert "await db.commit()" not in src, "Portfolio 직접 쓰기(§3.2)" # ═════════════════════════════════════════════════════════════════════════════ # C1: 추천 스캐너 평균회귀 임계 출처 플래그 # ═════════════════════════════════════════════════════════════════════════════ def _stock(rsi): return SimpleNamespace( rsi=rsi, current_price=9_100, bollinger_lower=9_000, bollinger_upper=11_000, bollinger_middle=10_000, ma20=None, ma60=None, adx_14=20, ) @pytest.fixture def mr_env(monkeypatch): import app.services.adaptive_strategy as ad from app.services.strategy_config_service import strategy_cfg state = {"adp": _adp(fallback=True), "cfg": {}} monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: state["adp"]) monkeypatch.setattr(strategy_cfg, "get", lambda key, default=None: state["cfg"].get(key, default)) return state def test_c1_flag_off_keeps_trading_rules_thresholds(monkeypatch, mr_env): from app.api.endpoints import recommendations as rec monkeypatch.setattr(settings, "MEAN_REVERSION_CANONICAL_PARAMS_ENABLED", False) mr_env["adp"] = _adp(fallback=False) # OFF 면 적응형도 무시해야 한다 assert rec._mean_reversion_rsi_thresholds_v2({})[:2] == (40, 30) assert rec._mean_reversion_rsi_thresholds_v2({"rsi_bull_max": 33, "rsi_bear_max": 22})[:2] == (33, 22) assert rec.is_mean_reversion_candidate(_stock(40), {}, "bull") is True assert rec.is_mean_reversion_candidate(_stock(41), {}, "bull") is False assert rec.is_mean_reversion_candidate(_stock(31), {}, "bear") is False def test_c1_flag_on_priority_adaptive_then_cfg_then_settings(monkeypatch, mr_env): from app.api.endpoints import recommendations as rec monkeypatch.setattr(settings, "MEAN_REVERSION_CANONICAL_PARAMS_ENABLED", True) # settings b, r, src = rec._mean_reversion_rsi_thresholds_v2({"rsi_bull_max": 1}) assert (b, r, src) == (settings.MEAN_REVERSION_RSI_THRESHOLD, settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD, "settings") # strategy_cfg > settings mr_env["cfg"] = {"MEAN_REVERSION_RSI_THRESHOLD": "44", "MEAN_REVERSION_BEAR_RSI_THRESHOLD": "33"} assert rec._mean_reversion_rsi_thresholds_v2({})[:2] == (44.0, 33.0) # adaptive > strategy_cfg mr_env["adp"] = SimpleNamespace(**{**vars(_adp(fallback=False)), "rsi_entry": 38.0, "bear_rsi_entry": 28.0}) assert rec._mean_reversion_rsi_thresholds_v2({})[:2] == (38.0, 28.0) assert rec.is_mean_reversion_candidate(_stock(38), {}, "bull") is True assert rec.is_mean_reversion_candidate(_stock(39), {}, "bull") is False # ═════════════════════════════════════════════════════════════════════════════ # Low: DELETE /trades/pending 은 당일 행을 지우지 않는다 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_low_delete_pending_only_before_today(): from app.api.endpoints import trades as tr class _DB(_FakeDB): async def commit(self): return None db = _DB([[]]) out = await tr.delete_pending_trades(db=db) assert out["deleted_count"] == 0 sql = str(db.stmts[0].compile(compile_kwargs={"literal_binds": False})) assert "created_at <" in sql, "당일 이전 조건이 없다 — 당일 PENDING(일일 한도 근거)까지 삭제"