- 스윙 체결가 확인 전 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
154 lines
6.8 KiB
Python
154 lines
6.8 KiB
Python
"""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
|