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:
217
backend/tests/test_fill_sweep_post_close_r4.py
Normal file
217
backend/tests/test_fill_sweep_post_close_r4.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""[하드닝 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
|
||||
Reference in New Issue
Block a user