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:
435
backend/tests/test_dt_entry_hardening_r2_20261009.py
Normal file
435
backend/tests/test_dt_entry_hardening_r2_20261009.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user