Files
auto-stock/backend/tests/test_dt_entry_hardening_r2_20261009.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

436 lines
21 KiB
Python

"""DT 진입 하드닝 라운드 2 회귀 테스트 (2026-10-09).
1. [MEDIUM] 자동 watchlist 30분 갱신이 ORB 보유 종목을 앞에 고정하고 상한으로 잘라
하루 종일 09:00 목록으로 동결되던 결함 + 신규/수집 실패 종목에 ORB 가 영영 없던 결함
→ 점수 순위 교체 + 선조회 분봉으로 ORB 지연 보충(추가 KIS 호출 없음).
2. [MEDIUM] DT 진입이 같은 종목의 타전략(스윙/외부/동기화) 보유를 확인하지 않던 결함
→ watchlist(DB 스냅샷, 읽기 전용) + 스캔 후보·주문 직전(KIS 잔고 output1) 이중 제외.
3. [MEDIUM] DT 진입 경로에 가격 필터(settings.MIN_PRICE_KRW)·레버리지 20% 가드 부재.
목킹 원칙(§4.1): 도달하면 안 되는 주문은 공통 종단(trade_executor.safe_place_order)에
BaseException 발생형으로 건다. 기대값은 settings 에서 계산한다.
"""
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.compiler import compiles
from app.core.config import settings
from app.models.models import ActiveTrade, Base, Portfolio, StockAnalysis
from app.services.day_trading_strategy import MinuteCandle
from app.services.daytrading import entry_service as ens
from app.services.daytrading.position_manager import day_trading_state
@compiles(JSONB, "sqlite")
def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환
return "JSON"
class OrderReached(BaseException):
"""주문 공통 종단 도달 (except Exception 에 삼켜지지 않게 BaseException)."""
async def _order_reached(*a, **k):
raise OrderReached("safe_place_order 도달")
TOTAL = 100_000_000.0
CASH = 50_000_000.0
@pytest.fixture(autouse=True)
def _isolate(monkeypatch):
saved_pos = dict(day_trading_state.positions)
saved_closed = set(day_trading_state.closed_today)
saved_orb = dict(day_trading_state.orb_cache)
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
day_trading_state.orb_cache.clear()
ens._pending_entries.clear()
ens._candle_cache.clear()
monkeypatch.setattr(ens, "_non_dt_held_db", set())
monkeypatch.setattr(ens, "_kis_holdings_snapshot", {})
monkeypatch.setattr(ens, "_kis_holdings_verified", True)
ens.set_watchlist([])
yield
day_trading_state.positions.clear()
day_trading_state.positions.update(saved_pos)
day_trading_state.closed_today.clear()
day_trading_state.closed_today.update(saved_closed)
day_trading_state.orb_cache.clear()
day_trading_state.orb_cache.update(saved_orb)
ens._pending_entries.clear()
ens._candle_cache.clear()
ens.set_watchlist([])
# ─────────────────────────────────────────────────────────────────────────────
# DB 헬퍼 (SQLite 인메모리 — 실 DB 미사용)
# ─────────────────────────────────────────────────────────────────────────────
async def _mk_session(with_holdings: bool = True):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
tables = [StockAnalysis.__table__]
if with_holdings:
tables += [ActiveTrade.__table__, Portfolio.__table__]
async with engine.begin() as conn:
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=tables))
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
def _today():
return datetime.now(ens._KST).date()
def _sa(code, score=5, price=None):
return StockAnalysis(
stock_code=code, stock_name=f"N{code}", analysis_date=_today(), score=score,
rsi=40.0, current_price=price if price is not None else settings.MIN_PRICE_KRW * 2,
)
# ═════════════════════════════════════════════════════════════════════════════
# 1. watchlist 갱신 동결 / ORB 지연 보충
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_refresh_replaces_by_score_even_when_all_have_orb():
"""기존 목록이 전부 ORB 를 가져도 갱신은 점수 순위로 교체된다(no-op 동결 해소)."""
engine, Session = await _mk_session()
async with Session() as s:
s.add_all([_sa("900001", 9), _sa("900002", 8), _sa("900003", 7)])
await s.commit()
ens.set_watchlist([{"code": c, "name": c} for c in ("100001", "100002", "100003")])
for c in ("100001", "100002", "100003"):
day_trading_state.set_orb(c, object())
with patch("app.core.database.AsyncSessionLocal", Session):
n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=3)
assert n == 3
assert set(ens.get_watchlist()) == {"900001", "900002", "900003"}
await engine.dispose()
@pytest.mark.asyncio
async def test_refresh_pads_with_orb_holders_when_candidates_short():
"""점수 후보가 상한 미만이면 기존 ORB 보유 종목으로 채운다(수집한 ORB 보존)."""
engine, Session = await _mk_session()
async with Session() as s:
s.add_all([_sa("900001", 9)])
await s.commit()
ens.set_watchlist([{"code": "100001", "name": "A"}, {"code": "100002", "name": "B"}])
day_trading_state.set_orb("100001", object())
with patch("app.core.database.AsyncSessionLocal", Session):
n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=3)
wl = ens.get_watchlist()
assert n == 2
assert set(wl) == {"900001", "100001"}, "ORB 없는 옛 종목은 보충 대상이 아니다"
await engine.dispose()
def _orb_day(price=10_000.0, start_hhmm=(9, 0), count=60):
d = datetime.now(ens._KST).strftime("%Y%m%d")
out = []
for i in range(count):
t = start_hhmm[0] * 60 + start_hhmm[1] + i
hh, mm = divmod(t, 60)
out.append(MinuteCandle(
date=d, time=f"{hh:02d}{mm:02d}00", open=price, high=price + 10 + i,
low=price - 10, close=price, volume=1000, cum_volume=1000 * (i + 1),
cum_amount=price * 1000 * (i + 1),
))
return out
def _at(hh, mm):
fake_now = datetime.now(ens._KST).replace(hour=hh, minute=mm, second=0)
class _DT(datetime):
@classmethod
def now(cls, tz=None):
return fake_now if tz else fake_now.replace(tzinfo=None)
return _DT
def test_orb_backfill_from_prefetched_candles_after_0930(monkeypatch):
"""09:30 이후 ORB 없는 감시종목은 선조회 분봉으로 ORB 가 채워진다(KIS 호출 없음)."""
import time as _t
monkeypatch.setattr(ens, "datetime", _at(10, 0))
monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False)
ens._candle_cache["200001"] = (_t.monotonic(), _orb_day())
n = ens._backfill_orb_from_cache_v2(["200001"])
assert n == 1
orb = day_trading_state.get_orb("200001")
# [R2-2] FULL_RANGE=False: 평상시와 같은 '첫 N봉' 정의(09:00~09:04) — 30봉 레인지가 아니다
n_bars = ens._ORB_FIRST_BARS
assert orb.high == 10_000.0 + 10 + (n_bars - 1)
assert orb.low == 10_000.0 - 10
assert round(orb.total_volume / orb.avg_vol_per_min) == n_bars
def test_orb_backfill_full_range_on_uses_whole_window(monkeypatch):
"""FULL_RANGE=True 면 09:00~09:29 창 전체(30봉) — 09:30 이후 봉은 섞이지 않는다."""
import time as _t
monkeypatch.setattr(ens, "datetime", _at(10, 0))
monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", True)
monkeypatch.setattr(ens, "_orb_finalized", set())
ens._candle_cache["200001"] = (_t.monotonic(), _orb_day())
assert ens._backfill_orb_from_cache_v2(["200001"]) == 1
orb = day_trading_state.get_orb("200001")
assert orb.high == 10_000.0 + 10 + 29
@pytest.mark.asyncio
async def test_restart_backfill_orb_matches_live_first_bars(monkeypatch):
"""[R2-2] 09:30 이후 재기동 소급(collect_opening_ranges)도 평상시 첫 수집과 같은 ORB.
평상시: 09:04 사이클에 봉 N개 → 첫 성공으로 고정. 재기동: 하루치 분봉 전체.
두 값이 같아야 한다(FULL_RANGE=False).
"""
from app.services.daytrading import entry_service as es
monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False)
# [R6] 첫 N봉이 완성된 시각이어야 확정된다 — 벽시계에 의존하지 않게 고정
monkeypatch.setattr(ens, "datetime", _at(10, 0))
monkeypatch.setattr(es.asyncio, "sleep", AsyncMock(return_value=None))
monkeypatch.setattr(day_trading_state, "load_orb_cache", AsyncMock(return_value=0))
monkeypatch.setattr(day_trading_state, "save_orb_cache", AsyncMock(return_value=True))
full_day = _orb_day(count=90)
live = full_day[: ens._ORB_FIRST_BARS]
feeds = {"300001": live, "300002": full_day}
monkeypatch.setattr(es, "parse_minute_candles", lambda raw: raw)
monkeypatch.setattr(es.korea_investment_api, "get_minute_chart_full_day",
AsyncMock(side_effect=lambda code: feeds[code]))
ens.set_watchlist([{"code": "300001", "name": "A"}, {"code": "300002", "name": "B"}])
await es.collect_opening_ranges()
a, b = day_trading_state.get_orb("300001"), day_trading_state.get_orb("300002")
assert a is not None and b is not None
assert (a.high, a.low, a.total_volume, a.avg_vol_per_min, a.open_price) == \
(b.high, b.low, b.total_volume, b.avg_vol_per_min, b.open_price)
def test_orb_backfill_skips_before_window_and_partial_coverage(monkeypatch):
import time as _t
monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False)
# 09:20 — 레인지 창이 끝나지 않았다
monkeypatch.setattr(ens, "datetime", _at(9, 20))
ens._candle_cache["200001"] = (_t.monotonic(), _orb_day())
assert ens._backfill_orb_from_cache_v2(["200001"]) == 0
# 10:00 이지만 당일 분봉이 09:10 부터만 있다(페이지 누락) → 잘린 레인지로 확정하지 않는다
monkeypatch.setattr(ens, "datetime", _at(10, 0))
ens._candle_cache["200002"] = (_t.monotonic(), _orb_day(start_hhmm=(9, 10)))
assert ens._backfill_orb_from_cache_v2(["200002"]) == 0
assert not day_trading_state.has_orb("200002")
# 이미 ORB 가 있으면 덮어쓰지 않는다
sentinel = object()
day_trading_state.set_orb("200003", sentinel)
ens._candle_cache["200003"] = (_t.monotonic(), _orb_day())
assert ens._backfill_orb_from_cache_v2(["200003"]) == 0
assert day_trading_state.get_orb("200003") is sentinel
# ═════════════════════════════════════════════════════════════════════════════
# 2. 타전략 보유 종목 제외
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_watchlist_excludes_non_dt_holdings_and_still_fills_cap():
engine, Session = await _mk_session()
async with Session() as s:
s.add_all([_sa(f"30000{i}", 10 - i) for i in range(6)])
s.add_all([
# 비-DT: 제외 대상
ActiveTrade(stock_code="300000", quantity=3, entry_price=1, status="active",
strategy_name="synced"),
ActiveTrade(stock_code="300001", quantity=3, entry_price=1, status="active",
strategy_name=None),
Portfolio(stock_code="300002", quantity=5, strategy_type="swing"),
# DT 소유·종료 행: 제외 아님
ActiveTrade(stock_code="300003", quantity=3, entry_price=1, status="active",
strategy_name="day_trade"),
ActiveTrade(stock_code="300004", quantity=3, entry_price=1, status="closed",
strategy_name="swing"),
Portfolio(stock_code="300005", quantity=5, strategy_type="daytrading"),
])
await s.commit()
with patch("app.core.database.AsyncSessionLocal", Session):
n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=3)
wl = ens.get_watchlist()
assert n == 3, "제외분만큼 더 읽어 상한을 채워야 한다"
assert set(wl) == {"300003", "300004", "300005"}
assert ens._non_dt_held_db == {"300000", "300001", "300002"}
await engine.dispose()
@pytest.mark.asyncio
async def test_held_snapshot_failure_keeps_previous_snapshot(monkeypatch):
"""스냅샷 조회 실패 시 비우지 않는다(비우면 가드가 조용히 풀린다)."""
engine, Session = await _mk_session(with_holdings=False) # 테이블 없음 → 조회 예외
monkeypatch.setattr(ens, "_non_dt_held_db", {"300000"})
with patch("app.core.database.AsyncSessionLocal", Session):
await ens._refresh_non_dt_held_db_v2()
assert ens._non_dt_held_db == {"300000"}
await engine.dispose()
def test_price_filter_uses_settings(monkeypatch):
monkeypatch.setattr(settings, "MIN_PRICE_KRW", 8000)
assert ens._price_filter_block_v2("일반주", 8000.0) is not None # '초과'만 허용
assert ens._price_filter_block_v2("일반주", 8001.0) is None
assert ens._price_filter_block_v2("KODEX 200", 3000.0) is None # ETF 예외
@pytest.mark.asyncio
async def test_watchlist_price_filter_from_settings(monkeypatch):
engine, Session = await _mk_session()
monkeypatch.setattr(settings, "MIN_PRICE_KRW", 8000)
async with Session() as s:
s.add_all([_sa("400001", 9, price=7000), _sa("400002", 8, price=9000)])
await s.commit()
with patch("app.core.database.AsyncSessionLocal", Session):
await ens.populate_daytrading_watchlist(min_score=1, max_stocks=5)
assert set(ens.get_watchlist()) == {"400002"}
await engine.dispose()
class _Adp:
fallback_used = False
daytrading_enabled = True
trading_mode = "normal"
async def _run_scan(monkeypatch, balance, captured):
async def fake_try_enter(code, name, total_assets, available_cash, is_lunch_break=False):
captured.append(code)
return None
monkeypatch.setattr(ens, "datetime", _at(10, 30))
with patch.object(ens.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)), \
patch.object(ens.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)), \
patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()), \
patch.object(ens, "is_daytrading_allowed_by_playbook", return_value=True), \
patch.object(ens, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \
patch.object(ens.korea_investment_api, "get_balance", AsyncMock(return_value=balance)), \
patch.object(ens.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=CASH)), \
patch.object(ens, "_prefetch_minute_charts", AsyncMock(return_value=0)), \
patch.object(ens, "_try_enter", fake_try_enter):
return await ens.scan_and_enter()
@pytest.mark.asyncio
async def test_scan_excludes_kis_held_non_dt_stock(monkeypatch):
"""KIS 잔고에 있으나 DT 인메모리 포지션이 아닌 종목(수동 목록 포함)은 스캔 후보에서 빠진다."""
ens.set_watchlist([{"code": "105560", "name": "KB"}, {"code": "005930", "name": "삼성"}])
balance = {
"output1": [{"pdno": "105560", "hldg_qty": "3", "prdt_name": "KB금융", "evlu_amt": "300000"}],
"output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}],
}
captured: list = []
await _run_scan(monkeypatch, balance, captured)
assert captured == ["005930"]
assert ens._kis_holdings_verified is True
@pytest.mark.asyncio
async def test_scan_db_snapshot_excludes_even_without_output1(monkeypatch):
ens.set_watchlist([{"code": "105560", "name": "KB"}, {"code": "005930", "name": "삼성"}])
monkeypatch.setattr(ens, "_non_dt_held_db", {"105560"})
balance = {"output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}]}
captured: list = []
await _run_scan(monkeypatch, balance, captured)
assert captured == ["005930"]
# output1 이 없으면 보유목록 검증 불가 → 레버리지 검증도 불가 상태로 표시
assert ens._kis_holdings_verified is False
# ═════════════════════════════════════════════════════════════════════════════
# _try_enter 가드 (주문 공통 종단 도달 여부로 판정)
# ═════════════════════════════════════════════════════════════════════════════
@pytest.fixture
def entry_env(monkeypatch):
import app.api.endpoints.mcp as mcp_mod
import app.services.trade_executor as te
state = {"price": settings.MIN_PRICE_KRW * 2.0}
def _candles():
return [SimpleNamespace(close=state["price"], date="", time="100000") for _ in range(6)]
def _rsi_bb(code, name, candles_5m, price):
return SimpleNamespace(
strategy="RSI_BB", confidence=0.7,
stop_loss=round(price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), 2),
target_price=round(price * (1 + settings.DAY_TRADE_MIN_STOP_LOSS_PCT
* settings.DAY_TRADE_TARGET_MULTIPLIER), 2),
reason="test",
)
monkeypatch.setattr(ens.loss_tracker, "is_banned_safe", AsyncMock(return_value=False))
monkeypatch.setattr(ens, "is_vi_active", lambda code: False)
monkeypatch.setattr(ens, "_get_candles_cached", AsyncMock(side_effect=lambda c: _candles()))
monkeypatch.setattr(ens, "filter_today_candles", lambda c: c)
monkeypatch.setattr(ens, "aggregate_to_5min", lambda c: c)
monkeypatch.setattr(ens, "calculate_vwap", lambda c: None)
monkeypatch.setattr(ens, "check_rsi_bb_signal", _rsi_bb)
monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment",
AsyncMock(return_value=(True, "")), raising=False)
monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=None))
monkeypatch.setattr(te, "safe_place_order", _order_reached)
return state
@pytest.mark.asyncio
async def test_try_enter_control_reaches_order(entry_env):
"""양성 대조 — 가드에 걸리지 않으면 주문 종단에 도달한다(목 체인이 유효함을 증명)."""
with pytest.raises(OrderReached):
await ens._try_enter("500001", "일반주", TOTAL, CASH)
@pytest.mark.asyncio
async def test_try_enter_price_filter_blocks(entry_env):
entry_env["price"] = float(settings.MIN_PRICE_KRW) # '초과' 아님
assert await ens._try_enter("500001", "일반주", TOTAL, CASH) is None
assert "500001" not in ens._pending_entries
@pytest.mark.asyncio
async def test_try_enter_rejects_non_dt_holding_before_order(entry_env, monkeypatch):
"""스캔 이후(사이클 중) 스냅샷에 들어온 타전략 보유도 주문 직전 재검사에서 막는다."""
monkeypatch.setattr(ens, "_non_dt_held_db", {"500001"})
assert await ens._try_enter("500001", "일반주", TOTAL, CASH) is None
monkeypatch.setattr(ens, "_non_dt_held_db", set())
monkeypatch.setattr(ens, "_kis_holdings_snapshot",
{"500001": {"qty": 3, "name": "일반주", "eval": 1.0}})
assert await ens._try_enter("500001", "일반주", TOTAL, CASH) is None
assert "500001" not in ens._pending_entries
@pytest.mark.asyncio
async def test_try_enter_leverage_cap(entry_env, monkeypatch):
lev_name = "KODEX 레버리지"
# 다른 레버리지 ETF 를 한도 바로 아래까지 보유 → 이번 주문 포함 시 초과
near_cap = TOTAL * settings.LEVERAGE_ETF_MAX_RATIO - 1.0
monkeypatch.setattr(ens, "_kis_holdings_snapshot", {
"122630": {"qty": 1, "name": "KODEX 레버리지", "eval": near_cap},
})
assert await ens._try_enter("233740", lev_name, TOTAL, CASH) is None
# 보유목록 검증 불가 → 레버리지 종목만 fail-closed
monkeypatch.setattr(ens, "_kis_holdings_snapshot", {})
monkeypatch.setattr(ens, "_kis_holdings_verified", False)
assert await ens._try_enter("233740", lev_name, TOTAL, CASH) is None
with pytest.raises(OrderReached):
await ens._try_enter("500001", "일반주", TOTAL, CASH)
# 여유가 있으면 통과
monkeypatch.setattr(ens, "_kis_holdings_verified", True)
with pytest.raises(OrderReached):
await ens._try_enter("233740", lev_name, TOTAL, CASH)