"""[하드닝 R4 2026-10-09] 종가 동시호가(15:20~15:30) 스윙 주문 체결가 확인. 결함: sweep 이 장중(is_market_open, 15:30 미만)에만 돌아 15:28 주문은 sweep 기회 0회 → executed_price 영구 NULL(ActiveTrade 진입가가 주문가로 남아 손절 기준 이동). 수정: 장 마감 직후 창(15:30 ~ +SWING_FILL_SWEEP_POST_CLOSE_MINUTES)에 장외 분기에서 sweep, 동시호가 주문은 15:30 전 조회 보류(시도 횟수 보존). """ import asyncio import sys from datetime import datetime, timedelta from decimal import Decimal from types import SimpleNamespace from unittest.mock import AsyncMock import pytest import app.core.database as database_mod import app.core.market_schedule as ms import app.services.db_worker as dbw import app.services.trade_executor as te from app.core.config import settings from app.services.swing import fill_sweep as fs # 2026-10-07 = 수요일(운영 관측일) _DAY = datetime(2026, 10, 7) def _at(h, m, s=0): return _DAY.replace(hour=h, minute=m, second=s) def _run(coro): return asyncio.run(coro) class _StopLoop(BaseException): pass class _FakeDB: def __init__(self, rows, active_rows=None): self.rows = rows self.active_rows = list(active_rows or []) self.n = 0 async def execute(self, q): self.n += 1 rows = self.rows if self.n == 1 else self.active_rows return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) def _row(created_at, **kw): base = dict(id=900, stock_code="005380", order_type="BUY", order_id="0001234", order_quantity=3, order_price=Decimal("337177"), total_amount=Decimal("1011531"), net_profit_loss=None, commission=None, strategy_type="swing", entry_reason="평균회귀 RSI 과매도", created_at=created_at) base.update(kw) return SimpleNamespace(**base) def _patch_exec(monkeypatch, mock): stub = SimpleNamespace(get_order_execution=mock) monkeypatch.setattr(te, "korea_investment_api", stub) mod = sys.modules.get("app.services.korea_investment_api") if mod is not None: monkeypatch.setattr(mod, "korea_investment_api", stub, raising=False) return mock @pytest.fixture def env(monkeypatch): calls = {"th": [], "at": []} async def _th(tid, fields): calls["th"].append((tid, dict(fields))) async def _at_up(data): calls["at"].append(dict(data)) monkeypatch.setattr(dbw, "enqueue_update_trade_history", _th) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _at_up) monkeypatch.setattr(ms, "is_krx_holiday", lambda d: False) fs._attempts.clear() fs._queued_ids.clear() fs._last_stats.update(day="", candidates=0, exhausted=0, queued=0, exhausted_warned=0) te._entry_fill_adjusted_orders.clear() return calls # ── 창 판정 ────────────────────────────────────────────────────────────────── def test_post_close_window_bounds(env, monkeypatch): mins = int(getattr(settings, "SWING_FILL_SWEEP_POST_CLOSE_MINUTES", fs._DEFAULT_POST_CLOSE_MINUTES)) assert not fs.post_close_sweep_active_v2(_at(15, 29, 59)) assert fs.post_close_sweep_active_v2(_at(15, 30)) assert fs.post_close_sweep_active_v2(_at(15, 30) + timedelta(minutes=mins) - timedelta(seconds=1)) assert not fs.post_close_sweep_active_v2(_at(15, 30) + timedelta(minutes=mins)) # 주말·휴장일 제외 assert not fs.post_close_sweep_active_v2(datetime(2026, 10, 10, 15, 35)) # 토 monkeypatch.setattr(ms, "is_krx_holiday", lambda d: True) assert not fs.post_close_sweep_active_v2(_at(15, 35)) def test_post_close_window_follows_settings(env, monkeypatch): # config.py 에 키가 아직 없다(Settings 는 미정의 필드 setattr 거부) — 설정 조회 지점을 덮는다 monkeypatch.setattr(fs, "_settings_num_v2", lambda n, d: 90.0 if n == "SWING_FILL_SWEEP_POST_CLOSE_MINUTES" else d) assert fs.post_close_sweep_active_v2(_at(16, 50)) assert not fs.post_close_sweep_active_v2(_at(17, 0)) # ── 동시호가 주문 조회 보류 ────────────────────────────────────────────────── def test_auction_order_not_looked_up_before_close(env, monkeypatch): m = _patch_exec(monkeypatch, AsyncMock(side_effect=AssertionError("15:30 전 동시호가 주문 조회"))) monkeypatch.setattr(fs, "now_kst", lambda: _at(15, 26)) assert _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_row(_at(15, 22, 6))]))) == 0 m.assert_not_called() assert 900 not in fs._attempts # 시도 횟수 보존 def test_continuous_session_order_still_looked_up_in_auction(env, monkeypatch): m = _patch_exec(monkeypatch, AsyncMock(return_value={"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 3})) monkeypatch.setattr(fs, "now_kst", lambda: _at(15, 26)) _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_row(_at(14, 0))]))) m.assert_awaited_once() def test_auction_order_confirmed_after_close(env, monkeypatch): _patch_exec(monkeypatch, AsyncMock(return_value={"ok": True, "found": True, "filled_qty": 3, "filled_price": 335500.0, "remaining_qty": 0})) monkeypatch.setattr(fs, "now_kst", lambda: _at(15, 31)) active = [SimpleNamespace(id=216, strategy_name="swing", entry_reason="평균회귀 RSI 과매도")] assert _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_row(_at(15, 28, 16))], active))) == 1 (tid, f), = env["th"] assert tid == 900 and f["executed_price"] == Decimal("335500.0") (adj,) = env["at"] assert adj["strategy_name"] == "swing" assert adj["entry_fill_adjust"]["filled_price"] == 335500.0 # ── 장 마감 후 실행기 ──────────────────────────────────────────────────────── def test_runner_outside_window_touches_nothing(env, monkeypatch): def _boom(): raise AssertionError("창 밖에서 세션 생성") monkeypatch.setattr(database_mod, "AsyncSessionLocal", _boom) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(side_effect=AssertionError("창 밖 sweep"))) assert _run(fs.run_post_close_fill_sweep_v2(_at(15, 0))) is None assert _run(fs.run_post_close_fill_sweep_v2(_at(18, 0))) is None class _CM: def __init__(self): self.session = object() async def __aenter__(self): return self.session async def __aexit__(self, *a): return False def test_runner_in_window_sweeps_and_caps_sleep(env, monkeypatch): cm = _CM() monkeypatch.setattr(database_mod, "AsyncSessionLocal", lambda: cm) sweep = AsyncMock(return_value=1) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", sweep) interval = float(getattr(settings, "SWING_FILL_SWEEP_POST_CLOSE_INTERVAL_SEC", fs._DEFAULT_POST_CLOSE_INTERVAL_SEC)) assert _run(fs.run_post_close_fill_sweep_v2(_at(15, 31))) == interval sweep.assert_awaited_once_with(cm.session) def test_runner_absorbs_sweep_failure(env, monkeypatch, caplog): monkeypatch.setattr(database_mod, "AsyncSessionLocal", lambda: _CM()) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(side_effect=RuntimeError("db down"))) with caplog.at_level("WARNING", logger=fs.logger.name): assert _run(fs.run_post_close_fill_sweep_v2(_at(15, 40))) is not None assert any("장 마감 후 체결가 보정 실패" in r.getMessage() for r in caplog.records) # ── 스케줄러 장외 분기 배선 ────────────────────────────────────────────────── def _closed_loop(monkeypatch, post_close_result): import app.services.swing.scheduler as sch slept = [] async def _sleep(sec): if sec < 10: # 기동 지연(2초) 통과 return slept.append(sec) raise _StopLoop() monkeypatch.setattr(sch.asyncio, "sleep", _sleep) monkeypatch.setattr(sch, "is_market_open", lambda: False) monkeypatch.setattr(sch, "_check_mdd_threshold_knobs_v2", AsyncMock(return_value=[])) monkeypatch.setattr(ms, "seconds_until_market_open", lambda: 60000.0) monkeypatch.setattr(fs, "run_post_close_fill_sweep_v2", post_close_result) with pytest.raises(_StopLoop): _run(sch.swing_scheduler()) return slept def test_scheduler_closed_branch_runs_post_close_sweep(monkeypatch): runner = AsyncMock(return_value=60.0) slept = _closed_loop(monkeypatch, runner) runner.assert_awaited_once() assert slept[-1] == 60.0 def test_scheduler_closed_branch_outside_window_keeps_long_sleep(monkeypatch): slept = _closed_loop(monkeypatch, AsyncMock(return_value=None)) assert slept[-1] == 1800.0 def test_scheduler_closed_branch_survives_runner_error(monkeypatch): slept = _closed_loop(monkeypatch, AsyncMock(side_effect=RuntimeError("boom"))) assert slept[-1] == 1800.0