"""[하드닝 R5 2026-10-09] swing/fill_sweep BUY 진입가 보정(entry_fill_adjust, 비멱등) 재보정 방지. 보정 태스크가 큐에 머무는/DLQ 재시도 중인 동안 TradeHistory 행은 여전히 '미확인'으로 조회된다. 이전에는 note_entry_fill_adjusted_v2 미호출 + _attempts.pop 으로 다음 사이클이 같은 주문을 다시 보정해 진입가·손절선이 누적 이동했다. """ import asyncio from decimal import Decimal from types import SimpleNamespace from unittest.mock import AsyncMock import pytest import app.services.db_worker as dbw import app.services.trade_executor as te from app.services.swing import fill_sweep as fs import sys class _FakeDB: """매 sweep: 홀수번째 execute = TradeHistory 후보(워커 미반영이라 계속 같은 행), 그 외 = ActiveTrade.""" def __init__(self, rows, active_rows): self.rows = rows self.active_rows = active_rows self.queries = [] self._th_next = True async def execute(self, q): self.queries.append(q) sql = str(q) rows = self.rows if "trade_history" in sql else self.active_rows return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) def _th_row(**kw): base = dict(id=901, stock_code="005380", order_type="BUY", order_id="0000777", 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=None) base.update(kw) return SimpleNamespace(**base) _FILLED = {"ok": True, "found": True, "filled_qty": 3, "filled_price": 335500.0, "remaining_qty": 0} @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) m = AsyncMock(return_value=_FILLED) stub = SimpleNamespace(get_order_execution=m) 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) monkeypatch.setattr(fs, "_auction_fill_pending_v2", lambda *_a, **_k: 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() calls["exec"] = m return calls def _db(): return _FakeDB([_th_row()], [SimpleNamespace(id=216, strategy_name="swing", entry_reason="평균회귀 RSI 과매도")]) def test_entry_adjust_marks_order_and_not_requeued_next_cycle(env): db = _db() assert asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) == 1 assert len(env["at"]) == 1 assert te.entry_fill_already_adjusted_v2("0000777") # 워커가 아직 반영하지 않아 같은 행이 다시 후보로 조회돼도 재보정·재조회 없음 assert asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) == 0 assert len(env["at"]) == 1 assert env["exec"].await_count == 1 def test_entry_adjust_skipped_even_if_queued_ids_lost(env): """행 id 표시가 사라져도(재조회 경로) 주문번호 표시로 진입가 보정은 1회만.""" db = _db() asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) fs._queued_ids.clear() asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) assert len(env["at"]) == 1 assert len(env["th"]) == 2 # TradeHistory 갱신은 절대값 덮어쓰기라 멱등 def test_mark_set_before_enqueue_failure(env, monkeypatch): """큐잉이 예외로 실패해도 표시가 남아 이중 보정 경로가 열리지 않는다(유실 쪽으로 실패).""" async def _boom(_data): raise RuntimeError("queue full") monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _boom) with pytest.raises(RuntimeError): asyncio.run(fs.sweep_unconfirmed_fills_v2(_db())) assert te.entry_fill_already_adjusted_v2("0000777") assert 901 in fs._queued_ids def test_query_excludes_queued_ids(env): db = _db() asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) th_queries = [q for q in db.queries if "trade_history" in str(q)] compiled = th_queries[-1].compile(compile_kwargs={"literal_binds": True}) assert "901" in str(compiled)