fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
120
backend/tests/test_fill_sweep_idempotent_r5_20261009.py
Normal file
120
backend/tests/test_fill_sweep_idempotent_r5_20261009.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""[하드닝 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)
|
||||
Reference in New Issue
Block a user