"""하드닝 R9 그룹 A — 집행·스케줄러 회귀 테스트 (2026-10-12). A-1 늦게 확정된 0체결·부분체결 스윙 BUY 의 ActiveTrade 원복(fill_sweep) + 직후 확인의 레지스트리 재등록 A-2 는 test_swing_scheduler_r9_20261012.py 목: KIS 체결조회는 테스트별 지정 응답만 가진 SimpleNamespace(다른 속성 접근은 AttributeError), db_worker 큐 함수는 호출 기록용 AsyncMock. 실 KIS·Redis·DB 호출 없음. """ import asyncio from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest import app.services.trade_executor as te import app.services.swing.fill_sweep as fs import app.services.db_worker as dbw import app.services.korea_investment_api as kia _real_sleep = asyncio.sleep @pytest.fixture(autouse=True) def _queue(monkeypatch): rec = SimpleNamespace( close=AsyncMock(), upsert=AsyncMock(), th=AsyncMock(), th_oid=AsyncMock(), ) monkeypatch.setattr(dbw, "enqueue_close_active_trade", rec.close) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", rec.upsert) monkeypatch.setattr(dbw, "enqueue_update_trade_history", rec.th) monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", rec.th_oid) monkeypatch.setattr(te, "_buy_entry_rollbacks", {}) monkeypatch.setattr(te, "_entry_fill_adjusted_orders", {}) monkeypatch.setattr(fs, "_queued_ids", set()) monkeypatch.setattr(fs, "_attempts", {}) monkeypatch.setattr(fs, "_auction_fill_pending_v2", lambda *a, **k: False) yield rec def _th_row(**kw): base = dict( id=101, stock_code="005930", order_id="0000012345", order_type="BUY", order_quantity=10, order_price=70000.0, total_amount=700000.0, strategy_type="swing", entry_reason="평균회귀 RSI 반등", status="COMPLETED", created_at=datetime.now() - timedelta(minutes=10), net_profit_loss=None, commission=None, ) base.update(kw) return SimpleNamespace(**base) def _at_row(**kw): base = dict(id=7, strategy_name="mean_reversion", entry_reason="평균회귀 RSI 반등", quantity=10, status="active") base.update(kw) return SimpleNamespace(**base) def _db_with(rows): res = MagicMock() res.scalars.return_value.all.return_value = rows db = MagicMock() db.execute = AsyncMock(return_value=res) return db def _kis(monkeypatch, info): api = SimpleNamespace(get_order_execution=AsyncMock(return_value=info)) monkeypatch.setattr(kia, "korea_investment_api", api) return api def _active(monkeypatch, rows): monkeypatch.setattr(fs, "_load_active_rows_for_fill_v2", AsyncMock(return_value=rows)) # ═══════════════════════ A-1 fill_sweep ════════════════════════════════ @pytest.mark.asyncio async def test_late_zero_fill_cancels_and_closes_active_trade_from_registry(monkeypatch, _queue): te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}) _active(monkeypatch, [_at_row()]) n = await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) assert n == 1 _queue.th.assert_awaited_once_with(101, {"status": "CANCELLED", "executed_quantity": 0}) _queue.close.assert_awaited_once_with("005930", "mean_reversion") assert te._buy_entry_rollbacks == {} @pytest.mark.asyncio async def test_late_zero_fill_after_restart_reconstructs_from_trade_history(monkeypatch, _queue): # 레지스트리 비어 있음(재기동) — TH entry_reason 으로 active 행 확정 _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": None, "cancelled": True}) _active(monkeypatch, [_at_row(), _at_row(id=3, strategy_name="synced", entry_reason="동기화 유입")]) await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) _queue.close.assert_awaited_once_with("005930", "mean_reversion") @pytest.mark.asyncio async def test_zero_fill_with_live_remainder_waits(monkeypatch, _queue): _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 10}) _active(monkeypatch, [_at_row()]) assert await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) == 0 _queue.th.assert_not_awaited() _queue.close.assert_not_awaited() assert 101 not in fs._queued_ids @pytest.mark.asyncio async def test_late_partial_fill_resizes_before_entry_adjust(monkeypatch, _queue): te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 4, "filled_price": 69900.0, "remaining_qty": 0}) _active(monkeypatch, [_at_row()]) await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) payloads = [c.args[0] for c in _queue.upsert.await_args_list] assert payloads[0] == {"stock_code": "005930", "strategy_name": "mean_reversion", "quantity": 4} assert "entry_fill_adjust" in payloads[1] and payloads[1]["strategy_name"] == "mean_reversion" _queue.close.assert_not_awaited() @pytest.mark.asyncio async def test_late_partial_fill_after_restart_reconstructs(monkeypatch, _queue): _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 6, "filled_price": 70000.0, "remaining_qty": 0}) _active(monkeypatch, [_at_row()]) await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) assert _queue.upsert.await_args_list[0].args[0]["quantity"] == 6 @pytest.mark.asyncio async def test_rollback_is_idempotent_across_sweeps(monkeypatch, _queue): te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}) _active(monkeypatch, [_at_row()]) db = _db_with([_th_row()]) await fs.sweep_unconfirmed_fills_v2(db) await fs.sweep_unconfirmed_fills_v2(db) # 워커 반영 전 같은 행이 다시 조회돼도 assert _queue.close.await_count == 1 assert _queue.th.await_count == 1 def test_reconstruct_already_resized_is_noop(): rb, why = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=4)], 4) assert rb is None and why == "already" def test_reconstruct_accumulate_and_ambiguous(): rb, _ = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=25)], 4) assert rb["accumulate"] is True rb, why = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=7)], 4) assert rb is None and "역산" in why rb, _ = fs.reconstruct_buy_rollback_v2(_th_row(), [], 0) assert rb is None @pytest.mark.asyncio async def test_accumulate_partial_requests_manual_not_resize(monkeypatch, _queue): monkeypatch.setattr(te, "_notify_buy_ledger_v2", lambda m: None) _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 4, "filled_price": 70000.0, "remaining_qty": 0}) _active(monkeypatch, [_at_row(quantity=25)]) await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) assert all("quantity" not in c.args[0] for c in _queue.upsert.await_args_list) @pytest.mark.asyncio async def test_dt_or_manual_buy_rows_not_rolled_back(monkeypatch, _queue): _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}) _active(monkeypatch, [_at_row()]) await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row(strategy_type="manual")])) _queue.close.assert_not_awaited() _queue.th.assert_not_awaited() # ═══════════════════════ A-1 직후 확인 재등록 ═══════════════════════════ async def _fast_sleep(_s): await _real_sleep(0) @pytest.mark.asyncio @pytest.mark.parametrize("info", [ {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 10}, {"ok": True, "found": True, "filled_qty": 3, "filled_price": 70000.0, "remaining_qty": 7}, ]) async def test_verify_restores_registry_when_not_terminal(monkeypatch, _queue, info): monkeypatch.setattr(te.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) monkeypatch.setattr(te, "korea_investment_api", SimpleNamespace(get_order_execution=AsyncMock(return_value=info))) te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) await te._verify_and_sync_execution_v2( "0000012345", "005930", "삼성전자", 10, 70000.0, "BUY", True, "KRX", 0, delay=0, ) rb = te._pop_buy_entry_rollback_v2("0000012345", "005930") assert rb and rb["strategy_name"] == "mean_reversion" and rb["quantity"] == 10 _queue.close.assert_not_awaited() @pytest.mark.asyncio async def test_verify_terminal_zero_fill_consumes_registry(monkeypatch, _queue): monkeypatch.setattr(te.asyncio, "sleep", _fast_sleep) monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) monkeypatch.setattr(te, "korea_investment_api", SimpleNamespace(get_order_execution=AsyncMock( return_value={"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}))) te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) await te._verify_and_sync_execution_v2( "0000012345", "005930", "삼성전자", 10, 70000.0, "BUY", True, "KRX", 0, delay=0, ) _queue.close.assert_awaited_once_with("005930", "mean_reversion") assert te._buy_entry_rollbacks == {} from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)