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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View 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