"""DT 진입 하드닝 라운드 5 회귀 테스트 (2026-10-09). 1. [MEDIUM] 전 전략 합산 현금 사용 상한(MAX_TOTAL_CAPITAL_USAGE)을 DT 가 주문 전 사용률로만 판정하고 `_clamp_day_trade_budget` 은 현금 전액까지 허용 → 주문 후 사용률이 100% 까지 감. → 클램프에 "주문 후 사용률 ≤ 상한" 현금 여유(`_total_usage_cash_room_v2`)를 추가. 2. [LOW] 감시종목 선정 정렬이 score 단일 키라 동점군에서 비결정적 → score desc, 거래대금 desc(NULL 마지막), stock_code asc. 기대값은 settings 에서 계산한다(§4.1). 실 DB·KIS 미사용(SQLite 인메모리). """ from datetime import datetime import pytest from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.compiler import compiles from unittest.mock import patch from app.core.config import settings from app.models.models import ActiveTrade, Base, Portfolio, StockAnalysis from app.services.daytrading import entry_service as ens from app.services.daytrading.position_manager import day_trading_state @compiles(JSONB, "sqlite") def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환 return "JSON" TOTAL = 100_000_000.0 def _usage_after(total, cash, budget): return (total - cash + budget) / total # ───────────────────────────────────────────────────────────────────────────── # 1. 95% 합산 상한 — 주문 후 기준 # ───────────────────────────────────────────────────────────────────────────── def test_clamp_keeps_total_usage_reserve_after_order(): """스윙 ~70% + DT 투입 후 남은 현금이 유보분 근처면 주문 후 사용률이 상한을 넘지 않게 깎는다.""" reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) per_pos = TOTAL * settings.DAY_TRADE_MAX_CAPITAL_RATIO # 가용현금 = 유보 + 종목당 한도의 절반 → 예전엔 종목당 한도까지 허용됐다 cash = reserve + per_pos / 2 budget = ens._clamp_day_trade_budget( per_pos * 1.2, total_assets=TOTAL, available_cash=cash, current_day_trade_amount=0.0, ) assert budget == pytest.approx(per_pos / 2) assert _usage_after(TOTAL, cash, budget) <= settings.MAX_TOTAL_CAPITAL_USAGE + 1e-9 def test_clamp_zero_when_cash_within_reserve(): """가용현금이 유보분 이하이면 예산 0(진입 불가) — 예전엔 현금 전액 허용.""" reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) budget = ens._clamp_day_trade_budget( TOTAL, total_assets=TOTAL, available_cash=reserve * 0.9, current_day_trade_amount=0.0, ) assert budget == 0.0 def test_clamp_unchanged_when_cash_ample(): """현금이 충분하면 기존 세 한도(종목당·DT 잔여·현금) 결과 그대로 — 거래 축소 없음.""" per_pos = TOTAL * settings.DAY_TRADE_MAX_CAPITAL_RATIO budget = ens._clamp_day_trade_budget( per_pos * 1.2, total_assets=TOTAL, available_cash=TOTAL * 0.5, current_day_trade_amount=0.0, ) assert budget == pytest.approx(per_pos) def test_cash_room_reserve_ratio_clamped(monkeypatch): """상한이 1 초과로 잘못 설정돼도 현금 이상을 허용하지 않는다(유보 하한 0).""" monkeypatch.setattr(settings, "MAX_TOTAL_CAPITAL_USAGE", 1.5) assert ens._total_usage_cash_room_v2(TOTAL, 1_000_000.0) == pytest.approx(1_000_000.0) monkeypatch.setattr(settings, "MAX_TOTAL_CAPITAL_USAGE", -0.1) assert ens._total_usage_cash_room_v2(TOTAL, 1_000_000.0) == 0.0 # ───────────────────────────────────────────────────────────────────────────── # 2. 감시종목 결정적 정렬 # ───────────────────────────────────────────────────────────────────────────── @pytest.fixture def _isolate(monkeypatch): saved_orb = dict(day_trading_state.orb_cache) day_trading_state.orb_cache.clear() monkeypatch.setattr(ens, "_non_dt_held_db", set()) async def _noop(): return None monkeypatch.setattr(ens, "_refresh_non_dt_held_db_v2", _noop) ens.set_watchlist([]) yield day_trading_state.orb_cache.clear() day_trading_state.orb_cache.update(saved_orb) ens.set_watchlist([]) def _today(): return datetime.now(ens._KST).date() def _sa(code, score, volume, price=None): return StockAnalysis( stock_code=code, stock_name=f"N{code}", analysis_date=_today(), score=score, rsi=40.0, volume=volume, current_price=price if price is not None else int(settings.MIN_PRICE_KRW * 2), ) async def _run(rows, max_stocks): engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync( Base.metadata.create_all, tables=[StockAnalysis.__table__, ActiveTrade.__table__, Portfolio.__table__], ) Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with Session() as s: s.add_all(rows) await s.commit() try: with patch("app.core.database.AsyncSessionLocal", Session): n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=max_stocks) return n, list(ens.get_watchlist()) finally: await engine.dispose() @pytest.mark.asyncio async def test_watchlist_ties_broken_by_turnover_then_code(_isolate): """동점(score=3)군에서 거래대금 큰 순 → NULL 마지막 → 코드 오름차순으로 결정적으로 고른다.""" rows = [ _sa("000009", 3, None), _sa("000005", 3, 1_000), _sa("000001", 3, 1_000), # 거래대금 동점 → 코드 오름차순 _sa("000007", 3, 9_000), _sa("000099", 5, 1), # 점수 우선 ] n, wl = await _run(rows, max_stocks=4) assert n == 4 assert wl == ["000099", "000007", "000001", "000005"] @pytest.mark.asyncio async def test_watchlist_selection_independent_of_insert_order(_isolate): """삽입(물리) 순서를 뒤집어도 같은 구성원이 선정된다.""" base = [(f"1000{i:02d}", 3, 1_000 * (i % 3)) for i in range(12)] n1, wl1 = await _run([_sa(*r) for r in base], max_stocks=5) n2, wl2 = await _run([_sa(*r) for r in reversed(base)], max_stocks=5) assert n1 == n2 == 5 assert wl1 == wl2