"""/daytrading API 하드닝 회귀 테스트 (2026-10-09, 하드닝 루프 라운드 1). 1. /close-all — 인메모리 포지션이 없으면 dt_cycle_lock 안에서 close_stale_positions() 로 전일·고아 DT 잔고를 정리한다(예전엔 '활성 포지션 없음'으로 즉시 반환). 2. /signals·/execute(dry_run) — 레거시 모듈 상태(항상 빈 _orb_cache/_positions/ _closed_today) 대신 엔진 상태(day_trading_state, entry_service 분봉 캐시)로 판정. 레거시 매매 사이클은 매매하지 않는 정지 스텁. 목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 로 계산. """ import asyncio from datetime import datetime from unittest.mock import AsyncMock, patch import pytest import pytz from fastapi import HTTPException from app.core.config import settings from app.api.endpoints import day_trading as dt from app.services.day_trading_strategy import ( DayTradeSignal, MinuteCandle, OpeningRange, calculate_day_trade_position_size, ) from app.services.daytrading import entry_service as ens from app.services.daytrading import exit_service as xs from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock class MustNotBeCalled(BaseException): """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" def _boom(name): async def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f def _boom_sync(name): def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f @pytest.fixture(autouse=True) def _isolate_state(): saved_pos = dict(day_trading_state.positions) saved_closed = set(day_trading_state.closed_today) saved_orb = dict(day_trading_state.orb_cache) saved_pnl = day_trading_state.daily_pnl saved_assets = day_trading_state.last_total_assets saved_wl = dict(ens._watchlist) saved_wl_auto = ens._watchlist_auto day_trading_state.reset_if_new_day() day_trading_state.positions.clear() day_trading_state.closed_today.clear() day_trading_state.orb_cache.clear() day_trading_state.daily_pnl = 0.0 ens._candle_cache.clear() # 레거시 모듈 상태는 비어 있다는 전제(실운영과 동일) dt._orb_cache.clear() dt._positions.clear() dt._closed_today.clear() # 주문 공통 종단은 어떤 테스트에서도 호출되면 안 된다 with patch.object(xs, "_execute_exit_impl", _boom("_execute_exit_impl")), \ patch.object(ens, "_place_and_record_entry_impl", _boom("_place_and_record_entry_impl")): yield day_trading_state.positions.clear() day_trading_state.positions.update(saved_pos) day_trading_state.closed_today.clear() day_trading_state.closed_today.update(saved_closed) day_trading_state.orb_cache.clear() day_trading_state.orb_cache.update(saved_orb) day_trading_state.daily_pnl = saved_pnl day_trading_state.last_total_assets = saved_assets ens._watchlist = saved_wl ens._watchlist_auto = saved_wl_auto ens._candle_cache.clear() # ═════════════════════════════════════════════════════════════════════════════ # 1. /close-all — 인메모리 비어 있으면 전일·고아 잔고 정리 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_close_all_empty_memory_closes_stale_under_cycle_lock(): seen = [] async def _stale(): seen.append(dt_cycle_lock.locked()) return [{"stock_code": "111111", "reason": "전일 미청산"}] with patch.object(dt, "is_market_open", return_value=True), \ patch.object(xs, "close_stale_positions", _stale), \ patch.object(xs, "force_close_all", _boom("force_close_all")): res = await dt.close_all_positions() assert seen == [True], "close_stale_positions 는 dt_cycle_lock 안에서 1회 호출돼야 한다" assert res["stale_checked"] is True assert res["exits"] == [{"stock_code": "111111", "reason": "전일 미청산"}] assert res["stale_exits"] == res["exits"] assert "1건" in res["message"] assert not dt_cycle_lock.locked() @pytest.mark.asyncio async def test_close_all_empty_memory_market_closed_does_not_sell(): with patch.object(dt, "is_market_open", return_value=False), \ patch.object(xs, "close_stale_positions", _boom("close_stale_positions")), \ patch.object(xs, "force_close_all", _boom("force_close_all")): res = await dt.close_all_positions() assert res["stale_checked"] is False assert res["exits"] == [] @pytest.mark.asyncio async def test_close_all_empty_memory_rejects_while_cycle_holds_lock(): with patch.object(dt, "is_market_open", return_value=True), \ patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): async with dt_cycle_lock: with pytest.raises(HTTPException) as ei: # 락을 기다리면(=회귀) 교착 대신 TimeoutError 로 실패하게 상한을 둔다 await asyncio.wait_for(dt.close_all_positions(), timeout=1.0) assert ei.value.status_code == 409 @pytest.mark.asyncio async def test_close_all_with_positions_still_delegates_to_force_close_all(): day_trading_state.positions["222222"] = {"entry_price": 10_000.0, "quantity": 3} fca = AsyncMock(return_value=[{"stock_code": "222222"}]) with patch.object(xs, "force_close_all", fca), \ patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): res = await dt.close_all_positions() fca.assert_awaited_once() assert res["exits"] == [{"stock_code": "222222"}] # ── [R4] 보유 경로도 dt_cycle_lock 안에서 청산 ──────────────────────────────── @pytest.mark.asyncio async def test_close_all_with_positions_runs_force_close_under_cycle_lock(): """보유 경로 force_close_all 이 스케줄러 진입 스캔과 겹치지 않게 dt_cycle_lock 안에서 호출.""" day_trading_state.positions["333333"] = {"entry_price": 10_000.0, "quantity": 2} seen = [] async def _fca(reason="강제 청산"): seen.append(dt_cycle_lock.locked()) day_trading_state.positions.pop("333333", None) return [{"stock_code": "333333"}] with patch.object(xs, "force_close_all", _fca), \ patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): res = await dt.close_all_positions() assert seen == [True] assert res["remaining_positions"] == [] assert "잔존" not in res["message"] assert not dt_cycle_lock.locked() @pytest.mark.asyncio async def test_close_all_with_positions_rejects_while_cycle_holds_lock(): """사이클(진입 스캔 포함)이 락을 쥐고 있으면 기다리지 않고 409 — 청산 경로 미호출.""" day_trading_state.positions["444444"] = {"entry_price": 10_000.0, "quantity": 1} with patch.object(xs, "force_close_all", _boom("force_close_all")), \ patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): async with dt_cycle_lock: with pytest.raises(HTTPException) as ei: await asyncio.wait_for(dt.close_all_positions(), timeout=1.0) assert ei.value.status_code == 409 @pytest.mark.asyncio async def test_close_all_reports_positions_remaining_after_force_close(): """청산 후에도 남은 포지션(진행 중·실패)은 '청산 완료' 문구에 숨기지 않고 드러낸다.""" day_trading_state.positions["555555"] = {"entry_price": 10_000.0, "quantity": 1} day_trading_state.positions["666666"] = {"entry_price": 10_000.0, "quantity": 1} async def _fca(reason="강제 청산"): day_trading_state.positions.pop("555555", None) return [{"stock_code": "555555"}] with patch.object(xs, "force_close_all", _fca), \ patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): res = await dt.close_all_positions() assert res["remaining_positions"] == ["666666"] assert "1건 잔존" in res["message"] # ═════════════════════════════════════════════════════════════════════════════ # 2. /signals·/execute — 엔진 상태 기준 # ═════════════════════════════════════════════════════════════════════════════ _KST = pytz.timezone("Asia/Seoul") def _today_candles(n: int = 30, price: float = 10_000.0) -> list: today = datetime.now(_KST).strftime("%Y%m%d") out = [] for i in range(n): hh, mm = divmod(9 * 60 + 30 + i, 60) out.append(MinuteCandle( date=today, time=f"{hh:02d}{mm:02d}00", open=price, high=price + 10, low=price - 10, close=price, volume=1000, cum_volume=1000 * (i + 1), cum_amount=price * 1000 * (i + 1), )) return out @pytest.mark.asyncio async def test_signals_use_engine_state_orb_held_closed_and_dt_budget(): total_assets = 10_000_000.0 available_cash = 5_000_000.0 price = 10_000.0 # 엔진 상태: 감시 3종목 중 1개 보유(DT 투입액 차감 대상), 1개 당일 청산 ens._watchlist = {"AAAAAA": "에이", "BBBBBB": "비", "CCCCCC": "씨"} held_amount = total_assets * settings.DAY_TRADE_MAX_RATIO_DT * 0.9 day_trading_state.positions["BBBBBB"] = { "entry_price": held_amount / 100, "quantity": 100, "stock_name": "비", } day_trading_state.closed_today.add("CCCCCC") engine_orb = OpeningRange(high=9_990.0, low=9_900.0, total_volume=10_000, avg_vol_per_min=100.0, open_price=9_950.0) day_trading_state.orb_cache["AAAAAA"] = engine_orb fetched = [] async def _candles(code): fetched.append(code) return _today_candles(price=price) orb_seen = [] confidence = 0.8 def _orb_signal(code, name, candles, orb, cur): orb_seen.append(orb) return DayTradeSignal( stock_code=code, stock_name=name, strategy="ORB", entry_price=cur, stop_loss=cur * 0.98, target_price=cur * 1.03, reason="test", confidence=confidence, ) with patch.object(dt, "is_day_trade_entry_window", return_value=True), \ patch.object(dt, "_get_portfolio_summary", AsyncMock(return_value={"total_assets": total_assets, "available_cash": available_cash})), \ patch.object(ens, "_get_candles_cached", _candles), \ patch.object(dt.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)), \ patch("app.services.market_playbook.is_vi_active", return_value=False), \ patch.object(dt, "check_orb_signal", _orb_signal), \ patch.object(dt, "check_vwap_bounce_signal", _boom_sync("check_vwap_bounce_signal")), \ patch.object(dt, "check_rsi_bb_signal", _boom_sync("check_rsi_bb_signal")): res = await dt.get_current_signals() assert res["source"] == "engine" assert fetched == ["AAAAAA"], "보유·당일청산 종목은 판정하지 않아야 한다" assert orb_seen == [engine_orb], "ORB 는 엔진 day_trading_state 의 값을 써야 한다" assert res["skipped_held"] == 1 and res["skipped_closed_today"] == 1 # DT 투입액(보유분) 차감 후 세 한도 재클램프 — 기대값은 settings 로 계산 budget = calculate_day_trade_position_size(total_assets, available_cash, held_amount) budget = min( budget * min(1.2, confidence / 0.7), total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO, total_assets * settings.DAY_TRADE_MAX_RATIO_DT - held_amount, available_cash, ) expected_qty = int(max(0.0, budget) // price) assert expected_qty > 0 assert res["count"] == 1 assert res["signals"][0]["stock_code"] == "AAAAAA" assert res["signals"][0]["quantity"] == expected_qty # 레거시처럼 보유분을 0 으로 보면 종목당 상한까지 커진다 — 그 값과 달라야 의미 있는 검증 assert expected_qty < int(total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO // price) @pytest.mark.asyncio async def test_signals_respects_open_slots(): ens._watchlist = {"AAAAAA": "에이"} for i in range(dt.MAX_POSITIONS): day_trading_state.positions[f"H{i:05d}"] = {"entry_price": 1.0, "quantity": 1} with patch.object(dt, "is_day_trade_entry_window", return_value=True), \ patch.object(dt, "_get_portfolio_summary", AsyncMock(return_value={"total_assets": 10_000_000.0, "available_cash": 5_000_000.0})), \ patch.object(dt, "_evaluate_engine_signal_v2", AsyncMock(return_value={ "signal": DayTradeSignal("AAAAAA", "에이", "RSI_BB", 10_000.0, 9_800.0, 10_300.0, "t", 0.7), "quantity": 5, "skip": None})): res = await dt.get_current_signals() assert res["gates"]["open_slots"] == 0 assert res["signals"] == [] @pytest.mark.asyncio async def test_execute_dry_run_uses_engine_positions_and_closed_today(): from app.api.endpoints.day_trading import ExecuteRequest day_trading_state.positions["AAAAAA"] = {"entry_price": 10_000.0, "quantity": 1} day_trading_state.closed_today.add("BBBBBB") with patch.object(dt, "_evaluate_engine_signal_v2", _boom("_evaluate_engine_signal_v2")): with pytest.raises(HTTPException) as e1: await dt.execute_day_trade(ExecuteRequest(stock_code="AAAAAA", dry_run=True)) with pytest.raises(HTTPException) as e2: await dt.execute_day_trade(ExecuteRequest(stock_code="BBBBBB", dry_run=True)) with pytest.raises(HTTPException) as e3: await dt.execute_day_trade(ExecuteRequest(stock_code="CCCCCC", dry_run=False)) assert e1.value.status_code == 400 assert e2.value.status_code == 400 assert e3.value.status_code == 410 @pytest.mark.asyncio async def test_legacy_cycle_is_inert_and_legacy_trading_helpers_removed(): with patch.object(dt, "is_market_open", _boom_sync("is_market_open")), \ patch.object(dt.korea_investment_api, "get_balance", _boom("get_balance")): res = await dt.run_day_trading_cycle() assert res["phase"] == "deprecated" assert res["entries"] == [] and res["exits"] == [] for name in ("_check_exits", "_scan_signals", "_execute_entry", "_execute_eod_flat", "restore_positions_from_db", "_place_buy_order", "_place_sell_order", "_collect_opening_range"): assert not hasattr(dt, name), f"레거시 매매 경로 {name} 가 남아 있다"