"""DT 진입 하드닝 라운드 3 회귀 테스트 (2026-10-09). 1. [LOW] DT ActiveTrade upsert payload 에 order_id 가 없어(운영 154/154 NULL) 재기동 복원 포지션의 ODNO 가 빈 값이던 결함 → payload 에 order_id 를 싣는다. 2. [LOW] 매수 0체결·부분체결 종결 보정이 인메모리·ActiveTrade 만 정리하고 Portfolio(reflect_buy) 반영은 되돌리지 않아, sync 전까지 고아 감지가 수동확인 경보를 오발하던 결함 → 'daytrading' 태그 행에 한해 미체결분을 되돌린다(하한 = DT 인메모리 잔량, reflect_sell 미사용). 목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 주문 공통 종단(safe_place_order)은 도달 시 예외. 기대값은 settings 에서 계산한다. """ import asyncio from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from app.core.config import settings from app.models.models import ActiveTrade, Portfolio from app.services.daytrading import entry_service as ens from app.services.daytrading import fill_reconciler as fr from app.services.daytrading.position_manager import _active_trade_row_to_position, day_trading_state class MustNotBeCalled(BaseException): """호출 금지 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" def _boom(name): async def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f CODE = "777777" ODNO = "0000456" @pytest.fixture(autouse=True) def _isolate(monkeypatch): import app.services.trade_executor as te saved = dict(day_trading_state.positions) saved_closed = set(day_trading_state.closed_today) day_trading_state.positions.clear() day_trading_state.closed_today.clear() ens._pending_entries.clear() monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): yield day_trading_state.positions.clear() day_trading_state.positions.update(saved) day_trading_state.closed_today.clear() day_trading_state.closed_today.update(saved_closed) ens._pending_entries.clear() def _pos(qty=10, order_id=ODNO): return { "stock_name": "테스트", "strategy": "VWAP_BOUNCE", "entry_price": 10_000.0, "quantity": qty, "stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_000.0, "entry_time": datetime.now(), "reason": "", "order_id": order_id, } # ═════════════════════════════════════════════════════════════════════════════ # 1. ActiveTrade order_id 영속화 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_accepted_entry_persists_order_id_to_active_trade(monkeypatch): import app.services.portfolio_cache_bridge as pcb upserts, scheduled = [], [] async def _ups(p): upserts.append(p) monkeypatch.setattr(ens, "enqueue_trade_history", AsyncMock()) monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups) monkeypatch.setattr(pcb, "reflect_buy", AsyncMock()) monkeypatch.setattr(ens, "_schedule_buy_fill_reconcile", lambda *a, **k: scheduled.append(a)) signal = SimpleNamespace(strategy="RSI_BB", stop_loss=9_700.0, target_price=10_450.0, reason="t") await ens._record_accepted_entry_impl(CODE, "테스트", signal, 10, 10_000.0, ODNO) assert len(upserts) == 1 assert upserts[0]["order_id"] == ODNO assert upserts[0]["strategy_name"] == "day_trade" # db_worker 신규 생성 경로(ActiveTrade(**create_data))가 받아들이는 키인지 — ORM 컬럼 존재 확인 row = ActiveTrade(**upserts[0]) assert row.order_id == ODNO assert scheduled and scheduled[0][1] == ODNO def test_restore_round_trip_keeps_order_id(): """복원 변환이 영속화된 ODNO 를 그대로 인메모리 포지션에 싣는다(빈 값 아님).""" row = SimpleNamespace( stock_code=CODE, stock_name="테스트", strategy_name="day_trade", entry_price=10_000.0, stop_loss=9_700.0, take_profit=10_450.0, quantity=10, highest_since_entry=None, created_at=datetime.now(), entry_reason="r", order_id=ODNO, remaining_pct=1.0, ) pos = _active_trade_row_to_position(row) assert pos["order_id"] == ODNO # ═════════════════════════════════════════════════════════════════════════════ # 2. 0체결·부분체결 종결 시 Portfolio 되돌림 # ═════════════════════════════════════════════════════════════════════════════ class _FakeCache: def __init__(self, holdings): self.holdings = holdings self.apply_sell = AsyncMock() def note_sell(self, *a, **k): raise MustNotBeCalled("note_sell — 매수 취소를 매도로 기록하면 sync 가 실보유 행을 지울 수 있다") def _row(qty, tag="daytrading"): return Portfolio(stock_code=CODE, stock_name="테스트", quantity=qty, strategy_type=tag, average_price=10_000, current_price=10_000) @pytest.fixture def fill_env(monkeypatch): import app.services.portfolio_cache_bridge as pcb monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) cap = {"upserts": [], "closes": [], "updates": [], "portfolio": []} async def _ups(p): cap["upserts"].append(p) async def _close(code, strategy_name=None): cap["closes"].append((code, strategy_name)) async def _upd(oid, f): cap["updates"].append((oid, f)) async def _pf(p): cap["portfolio"].append(p) monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups) monkeypatch.setattr("app.services.db_worker.enqueue_close_active_trade", _close) monkeypatch.setattr("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd) monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf) # 매도 경로로 되돌리면 note_sell 이 sync 를 오도한다 — 도달 금지 monkeypatch.setattr(pcb, "reflect_sell", _boom("reflect_sell")) return cap def _install_cache(monkeypatch, holdings, db_qty): import app.services.portfolio_cache_bridge as pcb cache = _FakeCache(holdings) monkeypatch.setattr(pcb, "_cache", lambda: cache) monkeypatch.setattr(pcb, "_db_quantity", AsyncMock(return_value=db_qty)) return cache async def _reconcile(rows, qty=10): with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): ens._schedule_buy_fill_reconcile(CODE, ODNO, 10_000.0, qty) await asyncio.gather(*list(fr._pending_tasks)) @pytest.mark.asyncio async def test_zero_fill_terminal_reverts_portfolio_daytrading_row(fill_env, monkeypatch): day_trading_state.positions[CODE] = _pos(qty=10) cache = _install_cache(monkeypatch, [_row(10)], db_qty=10) await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", "ord_qty": "10", "rmn_qty": "0"}]) assert CODE not in day_trading_state.positions assert fill_env["portfolio"] == [{"stock_code": CODE, "quantity": 0, "current_price": 10_000.0}] assert cache.holdings == [] cache.apply_sell.assert_awaited_once() assert cache.apply_sell.await_args.args[:2] == (CODE, 10) @pytest.mark.asyncio async def test_zero_fill_terminal_keeps_reentry_and_restore_block(fill_env, monkeypatch): """[R2-2 2026-10-09] 매수 0체결 종결 정리 뒤 재진입 차단(closed_today)과 당일 active 행 복원 차단(restore_block_reason_v2)이 둘 다 유지된다 — close 큐 지연 중 잔존 행이 인메모리로 되살아나 매도되지 않게. """ saved_exited = set(day_trading_state.dt_exited_today) day_trading_state.dt_exited_today.discard(CODE) try: day_trading_state.positions[CODE] = _pos(qty=10) _install_cache(monkeypatch, [_row(10)], db_qty=10) await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", "ord_qty": "10", "rmn_qty": "0"}]) assert CODE not in day_trading_state.positions assert CODE in day_trading_state.closed_today assert day_trading_state.restore_block_reason_v2(CODE) is not None finally: day_trading_state.dt_exited_today.clear() day_trading_state.dt_exited_today.update(saved_exited) @pytest.mark.asyncio async def test_partial_fill_terminal_reverts_only_unfilled(fill_env, monkeypatch): day_trading_state.positions[CODE] = _pos(qty=10) cache = _install_cache(monkeypatch, [_row(10)], db_qty=10) await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030", "ord_qty": "10", "rmn_qty": "0"}]) assert day_trading_state.positions[CODE]["quantity"] == 6 assert fill_env["portfolio"][-1]["quantity"] == 6 assert cache.holdings[0].quantity == 6 @pytest.mark.asyncio async def test_partial_revert_never_below_dt_inmem_when_sync_already_corrected(fill_env, monkeypatch): """sync 가 이미 실잔고(6)로 맞춘 뒤면 다시 빼지 않는다(하한 = DT 인메모리 6주).""" day_trading_state.positions[CODE] = _pos(qty=10) _install_cache(monkeypatch, [_row(6)], db_qty=6) await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030", "ord_qty": "10", "rmn_qty": "0"}]) assert fill_env["portfolio"][-1]["quantity"] == 6 @pytest.mark.asyncio async def test_mixed_strategy_row_is_not_touched(fill_env, monkeypatch): """타 전략 태그 행은 스윙 보유분이 섞였을 수 있어 깎지 않는다(§3.5) — sync 에 맡긴다.""" day_trading_state.positions[CODE] = _pos(qty=10) cache = _install_cache(monkeypatch, [_row(13, tag="swing")], db_qty=13) import app.services.portfolio_cache_bridge as pcb monkeypatch.setattr(pcb, "_db_quantity", _boom("_db_quantity")) await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", "ord_qty": "10", "rmn_qty": "0"}]) assert CODE not in day_trading_state.positions assert fill_env["portfolio"] == [] assert cache.holdings[0].quantity == 13 cache.apply_sell.assert_not_awaited() @pytest.mark.asyncio async def test_replaced_position_zero_fill_does_not_revert(fill_env, monkeypatch): """다른 주문의 포지션이면(교체·이미 청산) 원장만 보정 — Portfolio 는 건드리지 않는다.""" day_trading_state.positions[CODE] = _pos(qty=10, order_id="OTHER") _install_cache(monkeypatch, [_row(10)], db_qty=10) await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", "ord_qty": "10", "rmn_qty": "0"}]) assert day_trading_state.positions[CODE]["quantity"] == 10 assert fill_env["portfolio"] == [] @pytest.mark.asyncio async def test_revert_helper_floor_and_no_increase(monkeypatch): import app.services.portfolio_cache_bridge as pcb out = [] async def _pf(p): out.append(p) monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf) _install_cache(monkeypatch, [_row(3)], db_qty=3) # 미체결 10 > 행 3, 하한 0 → 0 (음수 아님) assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 10, 10_000.0, 0) == 0 _install_cache(monkeypatch, [_row(3)], db_qty=3) # 하한이 행보다 커도 행 수량을 늘리지 않는다 assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 2, 10_000.0, 9) == 3 # 미체결 0 이면 아무것도 하지 않는다 monkeypatch.setattr(pcb, "_cache", _boom("_cache")) assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 0, 10_000.0, 0) is None