"""하드닝 9회차 그룹 C(데이트레이딩) 회귀 테스트. C-1 결과 불명 DT 매도 + ActiveTrade 없는 수동·HTS 보유분 → 수동 물량 재매도 방지(v3 판정) C-2 기동 복원 검증이 주문가능수량·당일 PENDING DT 매도를 본다(v3) C-3 잔고 조회 직후 일일 손실 한도 재확인 """ from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from app.core.config import settings from app.services.daytrading import entry_service as ens from app.services.daytrading import exit_service as es from app.services.daytrading import position_manager as pm from app.services.daytrading.position_manager import DayTradingState, day_trading_state CODE = "005930" class _MustNotCall(BaseException): """호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException.""" class OrderReached(BaseException): """safe_place_order 도달 — kwargs 를 실어 수량을 검증한다(값 반환 스텁 대신 예외형).""" async def _order_reached(*a, **k): raise OrderReached(k) def _raise(name): async def _f(*a, **k): raise _MustNotCall(name) return _f class _Result: def __init__(self, rows): self._rows = rows def scalars(self): return self def all(self): return list(self._rows) class _Session: def __init__(self, rows): self._rows = rows async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *_a, **_k): return _Result(self._rows) def _th(ot, st, q, code=CODE): return SimpleNamespace(stock_code=code, order_type=ot, status=st, executed_quantity=q, order_quantity=q, strategy_type="daytrading", created_at=datetime.now()) @pytest.fixture(autouse=True) def _isolate(monkeypatch): import app.core.redis as _r es._unresolved_exit_unknown.clear() es._unknown_lookup_failures.clear() monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT", 10_000) monkeypatch.setattr(_r, "setex_json", AsyncMock(return_value=True)) monkeypatch.setattr(_r, "get_json", AsyncMock(return_value=None)) monkeypatch.setattr(pm, "_notify_daily_state_impl", AsyncMock(return_value=None)) yield es._unresolved_exit_unknown.clear() es._unknown_lookup_failures.clear() def _kis(monkeypatch, hldg, sellable, nd=0, orders=(), ledger=None): async def _h(): return ({CODE: {"hldg": hldg, "sellable": sellable}}, True) async def _nd(code): return nd async def _o(code): return None if orders is None else list(orders) monkeypatch.setattr(es, "_fetch_kis_holdings_v3_impl", _h) monkeypatch.setattr(es, "_fetch_non_dt_active_qty_impl", _nd) monkeypatch.setattr(es, "_lookup_todays_orders_impl", _raise("v1 주문내역(실패=[]) 사용 금지")) monkeypatch.setattr(es, "_lookup_todays_orders_v2_impl", _o) if ledger is not None: monkeypatch.setattr(es, "_fetch_dt_ledger_net_qty_impl", ledger) async def _ledger(n): async def _f(code): return n return _f # ═══ C-1 ═════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_c1_manual_shares_after_filled_unknown_sell_not_resold(monkeypatch): """dt.md 재현: DT 10주 불명 매도(실체결) + HTS 5주(ActiveTrade 없음) → 재매도 0.""" _kis(monkeypatch, 5, 5, ledger=_raise("원장 조회 전에 보류되어야 한다")) st, resp, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) assert (st, resp, cap) == ("ambiguous", None, 0) # 재시도 경로 종단: 주문이 나가지 않고 보류가 유지된다 es._unresolved_exit_unknown[(es._today_key(), CODE)] = { "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, } with patch("app.services.trade_executor.safe_place_order", _order_reached): res = await es._place_and_finalize_exit_impl( CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) assert res is None assert (es._today_key(), CODE) in es._unresolved_exit_unknown assert es._last_exit_order_outcome[CODE] == "unknown" @pytest.mark.asyncio async def test_c1_genuine_not_accepted_still_resells(monkeypatch): """정상 not_accepted(원장 순체결 10, 잔고 10) → 10주 재매도.""" _kis(monkeypatch, 10, 10, ledger=await _ledger(10)) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) assert (st, cap) == ("not_accepted", 10) es._unresolved_exit_unknown[(es._today_key(), CODE)] = { "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, } with patch("app.services.trade_executor.safe_place_order", _order_reached): with pytest.raises(OrderReached) as ei: await es._place_and_finalize_exit_impl( CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) assert ei.value.args[0]["quantity"] == 10 @pytest.mark.asyncio async def test_c1_ledger_caps_resell(monkeypatch): """잔고상 DT 몫이 원장보다 크면(ActiveTrade 없는 수동분) 원장 순체결로 상한.""" _kis(monkeypatch, 15, 15, ledger=await _ledger(10)) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) assert (st, cap) == ("not_accepted", 10) @pytest.mark.asyncio async def test_c1_ledger_failure_holds(monkeypatch): """원장 조회 예외 → None → ledger_failed(재매도 보류, fail-closed).""" def _boom(): raise RuntimeError("db down") monkeypatch.setattr("app.core.database.AsyncSessionLocal", _boom) assert await es._fetch_dt_ledger_net_qty_impl(CODE) is None _kis(monkeypatch, 10, 10) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) assert (st, cap) == ("ledger_failed", 0) es._unresolved_exit_unknown[(es._today_key(), CODE)] = { "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, } with patch("app.services.trade_executor.safe_place_order", _order_reached): res = await es._place_and_finalize_exit_impl( CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) assert res is None @pytest.mark.asyncio async def test_c1_ledger_nonpositive_holds(monkeypatch): """원장 순체결 ≤ 0 → 재매도 보류(과다매도보다 보류, §3.5). 재시도 종단에서도 주문 0.""" _kis(monkeypatch, 10, 10, ledger=await _ledger(0)) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) assert (st, cap) == ("ledger_nonpositive", 0) es._unresolved_exit_unknown[(es._today_key(), CODE)] = { "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, } with patch("app.services.trade_executor.safe_place_order", _order_reached): res = await es._place_and_finalize_exit_impl( CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) assert res is None and (es._today_key(), CODE) in es._unresolved_exit_unknown @pytest.mark.asyncio async def test_c1_orders_lookup_none_holds(monkeypatch): """주문내역 v2 조회 실패(None) → 보류(원장까지 가지 않는다).""" _kis(monkeypatch, 10, 10, orders=None, ledger=_raise("주문내역 실패 시 원장 불필요")) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) assert (st, cap) == ("orders_failed", 0) @pytest.mark.asyncio async def test_c1_v_repro_manual_ge_q_filled_not_resold(monkeypatch): """v_repro_dt: 수동 M=10 ≥ q=10, DT 10주 체결(잔고 온전해 보임). 주문내역에 등록 이후 행 → 접수 확정.""" now = es._kst_now() row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", "rjct_qty": "0", "cncl_yn": "N", "odno": "0000999", "ord_tmd": now.strftime("%H%M%S")} _kis(monkeypatch, 10, 10, orders=[row], ledger=_raise("접수 확정 시 원장 불필요")) st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10) assert st == "accepted" and resp["output"]["ODNO"] == "0000999" @pytest.mark.asyncio async def test_c1_v_repro_rows_invisible_ledger_zero_holds(monkeypatch): """v_repro_dt 원본(주문내역 [] + 원장 0) → 재매도 0(보류).""" _kis(monkeypatch, 10, 10, orders=[], ledger=await _ledger(0)) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, None, 10) assert (st, cap) == ("ledger_nonpositive", 0) @pytest.mark.asyncio async def test_c1_empty_orders_positive_ledger_capped_by_q(monkeypatch): """주문내역 [](KIS 미접수 실증) + 원장 > 0 → 재매도, 상한 min(DT 매도가능, 원장, q).""" _kis(monkeypatch, 15, 15, orders=[], ledger=await _ledger(20)) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 5, es._kst_now(), 10) assert (st, cap) == ("not_accepted", 5) @pytest.mark.asyncio async def test_c1_row_before_registration_or_without_time_ignored(monkeypatch): """등록 시각(−허용 오차) 이전 행·주문시각 없는 행은 같은 수량이어도 접수로 보지 않는다(r5 L-1 유지).""" from datetime import timedelta now = es._kst_now() early = (now - timedelta(seconds=float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC) + 60)).strftime("%H%M%S") base = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", "rjct_qty": "0", "cncl_yn": "N"} rows = [dict(base, odno="0000111", ord_tmd=early), dict(base, odno="0000222")] _kis(monkeypatch, 10, 10, orders=rows, ledger=await _ledger(10)) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10) assert (st, cap) == ("not_accepted", 10) @pytest.mark.asyncio async def test_c1_pending_other_order_reduces_sellable_still_resells(monkeypatch): """보유 30·주문가능 26·비DT 20: 보유 기준 DT 몫 10 온전 → v2 대로 6주 재매도(보류 아님).""" _kis(monkeypatch, 30, 26, nd=20, ledger=await _ledger(10)) st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) assert (st, cap) == ("not_accepted", 6) @pytest.mark.asyncio async def test_c1_matching_order_row_is_accepted(monkeypatch): now = es._kst_now() row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", "rjct_qty": "0", "cncl_yn": "N", "odno": "0000777", "ord_tmd": now.strftime("%H%M%S")} _kis(monkeypatch, 5, 5, orders=[row], ledger=_raise("접수 확정 시 원장 불필요")) st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10) assert st == "accepted" and resp["output"]["ODNO"] == "0000777" @pytest.mark.asyncio async def test_c1_ledger_query_nets_pending_sell(monkeypatch): rows = [_th("BUY", "COMPLETED", 10), _th("SELL", "PENDING", 4), _th("SELL", "FAILED", 6)] monkeypatch.setattr("app.core.database.AsyncSessionLocal", lambda: _Session(rows)) assert await es._fetch_dt_ledger_net_qty_impl(CODE) == 6 # ═══ C-2 ═════════════════════════════════════════════════════════════════════ def _restored_state(qty=10): st = DayTradingState() st.positions[CODE] = { "stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0, "stop_loss": 9_800.0, "target_price": 10_300.0, "quantity": qty, "highest_price": 10_000.0, "entry_time": datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=9, minute=10), "reason": "", "order_id": "", "restored_from_db": True, } return st def _hold(hldg, sellable): async def _f(): return {CODE: {"hldg": hldg, "sellable": sellable}} return _f def _rows(rows): async def _f(): return rows return _f @pytest.mark.asyncio async def test_c2_sellable_below_hldg_holds_restore(): st = _restored_state(10) out = await st.verify_restored_today_positions_v3( session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 0), today_rows_fetcher=_rows([])) assert CODE in out["held"] and CODE not in st.positions @pytest.mark.asyncio async def test_c2_pending_dt_sell_holds_restore(): st = _restored_state(10) out = await st.verify_restored_today_positions_v3( session_factory=lambda: _Session([]), holdings_fetcher=_raise("PENDING 보류 후 잔고 조회 불필요"), today_rows_fetcher=_rows([_th("SELL", "PENDING", 10)])) assert out["held"] == [CODE] and CODE not in st.positions @pytest.mark.asyncio async def test_c2_normal_restore_kept(): st = _restored_state(10) out = await st.verify_restored_today_positions_v3( session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 10), today_rows_fetcher=_rows([_th("BUY", "COMPLETED", 10)])) assert out["held"] == [] and CODE in st.positions @pytest.mark.asyncio async def test_c2_ledger_failure_holds_when_orphan_detect_enabled(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True) st = _restored_state(10) out = await st.verify_restored_today_positions_v3( session_factory=lambda: _Session([]), holdings_fetcher=_raise("보류 후 잔고 조회 불필요"), today_rows_fetcher=_raise_plain()) assert out["held"] == [CODE] def _raise_plain(): async def _f(): raise RuntimeError("db down") return _f @pytest.mark.asyncio async def test_c2_intraday_restore_uses_sellable(monkeypatch): row = SimpleNamespace(stock_code=CODE, quantity=10) monkeypatch.setattr(day_trading_state, "positions", {}) n = await es._restore_lost_today_positions_v2_impl([row], {CODE: {"hldg": 10, "sellable": 0}}, {}) assert n == 0 and CODE not in day_trading_state.positions # ═══ C-3 ═════════════════════════════════════════════════════════════════════ TOTAL = 100_000_000.0 CASH = 50_000_000.0 def _at(hh, mm): fake_now = datetime.now(ens._KST).replace(hour=hh, minute=mm, second=0) class _DT(datetime): @classmethod def now(cls, tz=None): return fake_now if tz else fake_now.replace(tzinfo=None) return _DT class _Adp: fallback_used = False daytrading_enabled = True trading_mode = "normal" async def _scan(monkeypatch, daily_pnl): captured: list = [] async def fake_try_enter(code, name, total_assets, available_cash, is_lunch_break=False): captured.append(code) return None monkeypatch.setattr(day_trading_state, "positions", {}) monkeypatch.setattr(day_trading_state, "closed_today", set()) monkeypatch.setattr(day_trading_state, "last_total_assets", 0.0) # 재기동 직후 monkeypatch.setattr(day_trading_state, "daily_pnl", daily_pnl) monkeypatch.setattr(day_trading_state, "last_reset_date", pm._today_kst()) # 날짜 리셋 방지 monkeypatch.setattr(day_trading_state, "loss_streak_today", 0, raising=False) monkeypatch.setattr(ens, "_non_dt_held_db", set()) monkeypatch.setattr(ens, "_kis_holdings_snapshot", {}) monkeypatch.setattr(ens, "_kis_holdings_verified", True) monkeypatch.setattr(ens, "datetime", _at(10, 30)) ens.set_watchlist([{"code": CODE, "name": "삼성"}]) balance = {"output1": [], "output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}]} try: with patch.object(ens.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)), \ patch.object(ens.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)), \ patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()), \ patch.object(ens, "is_daytrading_allowed_by_playbook", return_value=True), \ patch.object(ens, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \ patch.object(ens.korea_investment_api, "get_balance", AsyncMock(return_value=balance)), \ patch.object(ens.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=CASH)), \ patch.object(ens, "_prefetch_minute_charts", AsyncMock(return_value=0)), \ patch.object(ens, "_try_enter", fake_try_enter): await ens.scan_and_enter() finally: ens.set_watchlist([]) return captured @pytest.mark.asyncio async def test_c3_loss_limit_rechecked_after_balance(monkeypatch): from app.services.day_trading_strategy import _get_daily_loss_limit pnl = TOTAL * _get_daily_loss_limit() * 1.5 # 한도 초과 손실 assert await _scan(monkeypatch, pnl) == [] @pytest.mark.asyncio async def test_c3_control_no_loss_reaches_entry(monkeypatch): assert await _scan(monkeypatch, 0.0) == [CODE]