- 스윙 체결가 확인 전 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
436 lines
21 KiB
Python
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)
|