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:
153
backend/tests/test_dt_entry_hardening_r5_20261009.py
Normal file
153
backend/tests/test_dt_entry_hardening_r5_20261009.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""DT 진입 하드닝 라운드 5 회귀 테스트 (2026-10-09).
|
||||
|
||||
1. [MEDIUM] 전 전략 합산 현금 사용 상한(MAX_TOTAL_CAPITAL_USAGE)을 DT 가 주문 전 사용률로만
|
||||
판정하고 `_clamp_day_trade_budget` 은 현금 전액까지 허용 → 주문 후 사용률이 100% 까지 감.
|
||||
→ 클램프에 "주문 후 사용률 ≤ 상한" 현금 여유(`_total_usage_cash_room_v2`)를 추가.
|
||||
2. [LOW] 감시종목 선정 정렬이 score 단일 키라 동점군에서 비결정적
|
||||
→ score desc, 거래대금 desc(NULL 마지막), stock_code asc.
|
||||
|
||||
기대값은 settings 에서 계산한다(§4.1). 실 DB·KIS 미사용(SQLite 인메모리).
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
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 unittest.mock import patch
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import ActiveTrade, Base, Portfolio, StockAnalysis
|
||||
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"
|
||||
|
||||
|
||||
TOTAL = 100_000_000.0
|
||||
|
||||
|
||||
def _usage_after(total, cash, budget):
|
||||
return (total - cash + budget) / total
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. 95% 합산 상한 — 주문 후 기준
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_clamp_keeps_total_usage_reserve_after_order():
|
||||
"""스윙 ~70% + DT 투입 후 남은 현금이 유보분 근처면 주문 후 사용률이 상한을 넘지 않게 깎는다."""
|
||||
reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE)
|
||||
per_pos = TOTAL * settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||
# 가용현금 = 유보 + 종목당 한도의 절반 → 예전엔 종목당 한도까지 허용됐다
|
||||
cash = reserve + per_pos / 2
|
||||
budget = ens._clamp_day_trade_budget(
|
||||
per_pos * 1.2, total_assets=TOTAL, available_cash=cash, current_day_trade_amount=0.0,
|
||||
)
|
||||
assert budget == pytest.approx(per_pos / 2)
|
||||
assert _usage_after(TOTAL, cash, budget) <= settings.MAX_TOTAL_CAPITAL_USAGE + 1e-9
|
||||
|
||||
|
||||
def test_clamp_zero_when_cash_within_reserve():
|
||||
"""가용현금이 유보분 이하이면 예산 0(진입 불가) — 예전엔 현금 전액 허용."""
|
||||
reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE)
|
||||
budget = ens._clamp_day_trade_budget(
|
||||
TOTAL, total_assets=TOTAL, available_cash=reserve * 0.9, current_day_trade_amount=0.0,
|
||||
)
|
||||
assert budget == 0.0
|
||||
|
||||
|
||||
def test_clamp_unchanged_when_cash_ample():
|
||||
"""현금이 충분하면 기존 세 한도(종목당·DT 잔여·현금) 결과 그대로 — 거래 축소 없음."""
|
||||
per_pos = TOTAL * settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||
budget = ens._clamp_day_trade_budget(
|
||||
per_pos * 1.2, total_assets=TOTAL, available_cash=TOTAL * 0.5, current_day_trade_amount=0.0,
|
||||
)
|
||||
assert budget == pytest.approx(per_pos)
|
||||
|
||||
|
||||
def test_cash_room_reserve_ratio_clamped(monkeypatch):
|
||||
"""상한이 1 초과로 잘못 설정돼도 현금 이상을 허용하지 않는다(유보 하한 0)."""
|
||||
monkeypatch.setattr(settings, "MAX_TOTAL_CAPITAL_USAGE", 1.5)
|
||||
assert ens._total_usage_cash_room_v2(TOTAL, 1_000_000.0) == pytest.approx(1_000_000.0)
|
||||
monkeypatch.setattr(settings, "MAX_TOTAL_CAPITAL_USAGE", -0.1)
|
||||
assert ens._total_usage_cash_room_v2(TOTAL, 1_000_000.0) == 0.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. 감시종목 결정적 정렬
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def _isolate(monkeypatch):
|
||||
saved_orb = dict(day_trading_state.orb_cache)
|
||||
day_trading_state.orb_cache.clear()
|
||||
monkeypatch.setattr(ens, "_non_dt_held_db", set())
|
||||
|
||||
async def _noop():
|
||||
return None
|
||||
monkeypatch.setattr(ens, "_refresh_non_dt_held_db_v2", _noop)
|
||||
ens.set_watchlist([])
|
||||
yield
|
||||
day_trading_state.orb_cache.clear()
|
||||
day_trading_state.orb_cache.update(saved_orb)
|
||||
ens.set_watchlist([])
|
||||
|
||||
|
||||
def _today():
|
||||
return datetime.now(ens._KST).date()
|
||||
|
||||
|
||||
def _sa(code, score, volume, price=None):
|
||||
return StockAnalysis(
|
||||
stock_code=code, stock_name=f"N{code}", analysis_date=_today(), score=score,
|
||||
rsi=40.0, volume=volume,
|
||||
current_price=price if price is not None else int(settings.MIN_PRICE_KRW * 2),
|
||||
)
|
||||
|
||||
|
||||
async def _run(rows, max_stocks):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
Base.metadata.create_all,
|
||||
tables=[StockAnalysis.__table__, ActiveTrade.__table__, Portfolio.__table__],
|
||||
)
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with Session() as s:
|
||||
s.add_all(rows)
|
||||
await s.commit()
|
||||
try:
|
||||
with patch("app.core.database.AsyncSessionLocal", Session):
|
||||
n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=max_stocks)
|
||||
return n, list(ens.get_watchlist())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watchlist_ties_broken_by_turnover_then_code(_isolate):
|
||||
"""동점(score=3)군에서 거래대금 큰 순 → NULL 마지막 → 코드 오름차순으로 결정적으로 고른다."""
|
||||
rows = [
|
||||
_sa("000009", 3, None),
|
||||
_sa("000005", 3, 1_000),
|
||||
_sa("000001", 3, 1_000), # 거래대금 동점 → 코드 오름차순
|
||||
_sa("000007", 3, 9_000),
|
||||
_sa("000099", 5, 1), # 점수 우선
|
||||
]
|
||||
n, wl = await _run(rows, max_stocks=4)
|
||||
assert n == 4
|
||||
assert wl == ["000099", "000007", "000001", "000005"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watchlist_selection_independent_of_insert_order(_isolate):
|
||||
"""삽입(물리) 순서를 뒤집어도 같은 구성원이 선정된다."""
|
||||
base = [(f"1000{i:02d}", 3, 1_000 * (i % 3)) for i in range(12)]
|
||||
n1, wl1 = await _run([_sa(*r) for r in base], max_stocks=5)
|
||||
n2, wl2 = await _run([_sa(*r) for r in reversed(base)], max_stocks=5)
|
||||
assert n1 == n2 == 5
|
||||
assert wl1 == wl2
|
||||
Reference in New Issue
Block a user