- 스윙 체결가 확인 전 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
321 lines
15 KiB
Python
321 lines
15 KiB
Python
"""/daytrading API 하드닝 회귀 테스트 (2026-10-09, 하드닝 루프 라운드 1).
|
|
|
|
1. /close-all — 인메모리 포지션이 없으면 dt_cycle_lock 안에서 close_stale_positions()
|
|
로 전일·고아 DT 잔고를 정리한다(예전엔 '활성 포지션 없음'으로 즉시 반환).
|
|
2. /signals·/execute(dry_run) — 레거시 모듈 상태(항상 빈 _orb_cache/_positions/
|
|
_closed_today) 대신 엔진 상태(day_trading_state, entry_service 분봉 캐시)로 판정.
|
|
레거시 매매 사이클은 매매하지 않는 정지 스텁.
|
|
|
|
목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 로 계산.
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
import pytz
|
|
from fastapi import HTTPException
|
|
|
|
from app.core.config import settings
|
|
from app.api.endpoints import day_trading as dt
|
|
from app.services.day_trading_strategy import (
|
|
DayTradeSignal, MinuteCandle, OpeningRange, calculate_day_trade_position_size,
|
|
)
|
|
from app.services.daytrading import entry_service as ens
|
|
from app.services.daytrading import exit_service as xs
|
|
from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock
|
|
|
|
|
|
class MustNotBeCalled(BaseException):
|
|
"""호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException)."""
|
|
|
|
|
|
def _boom(name):
|
|
async def _f(*a, **k):
|
|
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
|
|
return _f
|
|
|
|
|
|
def _boom_sync(name):
|
|
def _f(*a, **k):
|
|
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
|
|
return _f
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_state():
|
|
saved_pos = dict(day_trading_state.positions)
|
|
saved_closed = set(day_trading_state.closed_today)
|
|
saved_orb = dict(day_trading_state.orb_cache)
|
|
saved_pnl = day_trading_state.daily_pnl
|
|
saved_assets = day_trading_state.last_total_assets
|
|
saved_wl = dict(ens._watchlist)
|
|
saved_wl_auto = ens._watchlist_auto
|
|
day_trading_state.reset_if_new_day()
|
|
day_trading_state.positions.clear()
|
|
day_trading_state.closed_today.clear()
|
|
day_trading_state.orb_cache.clear()
|
|
day_trading_state.daily_pnl = 0.0
|
|
ens._candle_cache.clear()
|
|
# 레거시 모듈 상태는 비어 있다는 전제(실운영과 동일)
|
|
dt._orb_cache.clear()
|
|
dt._positions.clear()
|
|
dt._closed_today.clear()
|
|
# 주문 공통 종단은 어떤 테스트에서도 호출되면 안 된다
|
|
with patch.object(xs, "_execute_exit_impl", _boom("_execute_exit_impl")), \
|
|
patch.object(ens, "_place_and_record_entry_impl", _boom("_place_and_record_entry_impl")):
|
|
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)
|
|
day_trading_state.daily_pnl = saved_pnl
|
|
day_trading_state.last_total_assets = saved_assets
|
|
ens._watchlist = saved_wl
|
|
ens._watchlist_auto = saved_wl_auto
|
|
ens._candle_cache.clear()
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# 1. /close-all — 인메모리 비어 있으면 전일·고아 잔고 정리
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_all_empty_memory_closes_stale_under_cycle_lock():
|
|
seen = []
|
|
|
|
async def _stale():
|
|
seen.append(dt_cycle_lock.locked())
|
|
return [{"stock_code": "111111", "reason": "전일 미청산"}]
|
|
|
|
with patch.object(dt, "is_market_open", return_value=True), \
|
|
patch.object(xs, "close_stale_positions", _stale), \
|
|
patch.object(xs, "force_close_all", _boom("force_close_all")):
|
|
res = await dt.close_all_positions()
|
|
|
|
assert seen == [True], "close_stale_positions 는 dt_cycle_lock 안에서 1회 호출돼야 한다"
|
|
assert res["stale_checked"] is True
|
|
assert res["exits"] == [{"stock_code": "111111", "reason": "전일 미청산"}]
|
|
assert res["stale_exits"] == res["exits"]
|
|
assert "1건" in res["message"]
|
|
assert not dt_cycle_lock.locked()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_all_empty_memory_market_closed_does_not_sell():
|
|
with patch.object(dt, "is_market_open", return_value=False), \
|
|
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")), \
|
|
patch.object(xs, "force_close_all", _boom("force_close_all")):
|
|
res = await dt.close_all_positions()
|
|
assert res["stale_checked"] is False
|
|
assert res["exits"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_all_empty_memory_rejects_while_cycle_holds_lock():
|
|
with patch.object(dt, "is_market_open", return_value=True), \
|
|
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
|
|
async with dt_cycle_lock:
|
|
with pytest.raises(HTTPException) as ei:
|
|
# 락을 기다리면(=회귀) 교착 대신 TimeoutError 로 실패하게 상한을 둔다
|
|
await asyncio.wait_for(dt.close_all_positions(), timeout=1.0)
|
|
assert ei.value.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_all_with_positions_still_delegates_to_force_close_all():
|
|
day_trading_state.positions["222222"] = {"entry_price": 10_000.0, "quantity": 3}
|
|
fca = AsyncMock(return_value=[{"stock_code": "222222"}])
|
|
with patch.object(xs, "force_close_all", fca), \
|
|
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
|
|
res = await dt.close_all_positions()
|
|
fca.assert_awaited_once()
|
|
assert res["exits"] == [{"stock_code": "222222"}]
|
|
|
|
|
|
# ── [R4] 보유 경로도 dt_cycle_lock 안에서 청산 ────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_all_with_positions_runs_force_close_under_cycle_lock():
|
|
"""보유 경로 force_close_all 이 스케줄러 진입 스캔과 겹치지 않게 dt_cycle_lock 안에서 호출."""
|
|
day_trading_state.positions["333333"] = {"entry_price": 10_000.0, "quantity": 2}
|
|
seen = []
|
|
|
|
async def _fca(reason="강제 청산"):
|
|
seen.append(dt_cycle_lock.locked())
|
|
day_trading_state.positions.pop("333333", None)
|
|
return [{"stock_code": "333333"}]
|
|
|
|
with patch.object(xs, "force_close_all", _fca), \
|
|
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
|
|
res = await dt.close_all_positions()
|
|
assert seen == [True]
|
|
assert res["remaining_positions"] == []
|
|
assert "잔존" not in res["message"]
|
|
assert not dt_cycle_lock.locked()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_all_with_positions_rejects_while_cycle_holds_lock():
|
|
"""사이클(진입 스캔 포함)이 락을 쥐고 있으면 기다리지 않고 409 — 청산 경로 미호출."""
|
|
day_trading_state.positions["444444"] = {"entry_price": 10_000.0, "quantity": 1}
|
|
with patch.object(xs, "force_close_all", _boom("force_close_all")), \
|
|
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
|
|
async with dt_cycle_lock:
|
|
with pytest.raises(HTTPException) as ei:
|
|
await asyncio.wait_for(dt.close_all_positions(), timeout=1.0)
|
|
assert ei.value.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_all_reports_positions_remaining_after_force_close():
|
|
"""청산 후에도 남은 포지션(진행 중·실패)은 '청산 완료' 문구에 숨기지 않고 드러낸다."""
|
|
day_trading_state.positions["555555"] = {"entry_price": 10_000.0, "quantity": 1}
|
|
day_trading_state.positions["666666"] = {"entry_price": 10_000.0, "quantity": 1}
|
|
|
|
async def _fca(reason="강제 청산"):
|
|
day_trading_state.positions.pop("555555", None)
|
|
return [{"stock_code": "555555"}]
|
|
|
|
with patch.object(xs, "force_close_all", _fca), \
|
|
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
|
|
res = await dt.close_all_positions()
|
|
assert res["remaining_positions"] == ["666666"]
|
|
assert "1건 잔존" in res["message"]
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# 2. /signals·/execute — 엔진 상태 기준
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
_KST = pytz.timezone("Asia/Seoul")
|
|
|
|
|
|
def _today_candles(n: int = 30, price: float = 10_000.0) -> list:
|
|
today = datetime.now(_KST).strftime("%Y%m%d")
|
|
out = []
|
|
for i in range(n):
|
|
hh, mm = divmod(9 * 60 + 30 + i, 60)
|
|
out.append(MinuteCandle(
|
|
date=today, time=f"{hh:02d}{mm:02d}00",
|
|
open=price, high=price + 10, low=price - 10, close=price,
|
|
volume=1000, cum_volume=1000 * (i + 1), cum_amount=price * 1000 * (i + 1),
|
|
))
|
|
return out
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signals_use_engine_state_orb_held_closed_and_dt_budget():
|
|
total_assets = 10_000_000.0
|
|
available_cash = 5_000_000.0
|
|
price = 10_000.0
|
|
# 엔진 상태: 감시 3종목 중 1개 보유(DT 투입액 차감 대상), 1개 당일 청산
|
|
ens._watchlist = {"AAAAAA": "에이", "BBBBBB": "비", "CCCCCC": "씨"}
|
|
held_amount = total_assets * settings.DAY_TRADE_MAX_RATIO_DT * 0.9
|
|
day_trading_state.positions["BBBBBB"] = {
|
|
"entry_price": held_amount / 100, "quantity": 100, "stock_name": "비",
|
|
}
|
|
day_trading_state.closed_today.add("CCCCCC")
|
|
engine_orb = OpeningRange(high=9_990.0, low=9_900.0, total_volume=10_000,
|
|
avg_vol_per_min=100.0, open_price=9_950.0)
|
|
day_trading_state.orb_cache["AAAAAA"] = engine_orb
|
|
|
|
fetched = []
|
|
|
|
async def _candles(code):
|
|
fetched.append(code)
|
|
return _today_candles(price=price)
|
|
|
|
orb_seen = []
|
|
confidence = 0.8
|
|
|
|
def _orb_signal(code, name, candles, orb, cur):
|
|
orb_seen.append(orb)
|
|
return DayTradeSignal(
|
|
stock_code=code, stock_name=name, strategy="ORB", entry_price=cur,
|
|
stop_loss=cur * 0.98, target_price=cur * 1.03, reason="test", confidence=confidence,
|
|
)
|
|
|
|
with patch.object(dt, "is_day_trade_entry_window", return_value=True), \
|
|
patch.object(dt, "_get_portfolio_summary",
|
|
AsyncMock(return_value={"total_assets": total_assets,
|
|
"available_cash": available_cash})), \
|
|
patch.object(ens, "_get_candles_cached", _candles), \
|
|
patch.object(dt.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)), \
|
|
patch("app.services.market_playbook.is_vi_active", return_value=False), \
|
|
patch.object(dt, "check_orb_signal", _orb_signal), \
|
|
patch.object(dt, "check_vwap_bounce_signal", _boom_sync("check_vwap_bounce_signal")), \
|
|
patch.object(dt, "check_rsi_bb_signal", _boom_sync("check_rsi_bb_signal")):
|
|
res = await dt.get_current_signals()
|
|
|
|
assert res["source"] == "engine"
|
|
assert fetched == ["AAAAAA"], "보유·당일청산 종목은 판정하지 않아야 한다"
|
|
assert orb_seen == [engine_orb], "ORB 는 엔진 day_trading_state 의 값을 써야 한다"
|
|
assert res["skipped_held"] == 1 and res["skipped_closed_today"] == 1
|
|
|
|
# DT 투입액(보유분) 차감 후 세 한도 재클램프 — 기대값은 settings 로 계산
|
|
budget = calculate_day_trade_position_size(total_assets, available_cash, held_amount)
|
|
budget = min(
|
|
budget * min(1.2, confidence / 0.7),
|
|
total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO,
|
|
total_assets * settings.DAY_TRADE_MAX_RATIO_DT - held_amount,
|
|
available_cash,
|
|
)
|
|
expected_qty = int(max(0.0, budget) // price)
|
|
assert expected_qty > 0
|
|
assert res["count"] == 1
|
|
assert res["signals"][0]["stock_code"] == "AAAAAA"
|
|
assert res["signals"][0]["quantity"] == expected_qty
|
|
# 레거시처럼 보유분을 0 으로 보면 종목당 상한까지 커진다 — 그 값과 달라야 의미 있는 검증
|
|
assert expected_qty < int(total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO // price)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signals_respects_open_slots():
|
|
ens._watchlist = {"AAAAAA": "에이"}
|
|
for i in range(dt.MAX_POSITIONS):
|
|
day_trading_state.positions[f"H{i:05d}"] = {"entry_price": 1.0, "quantity": 1}
|
|
with patch.object(dt, "is_day_trade_entry_window", return_value=True), \
|
|
patch.object(dt, "_get_portfolio_summary",
|
|
AsyncMock(return_value={"total_assets": 10_000_000.0,
|
|
"available_cash": 5_000_000.0})), \
|
|
patch.object(dt, "_evaluate_engine_signal_v2", AsyncMock(return_value={
|
|
"signal": DayTradeSignal("AAAAAA", "에이", "RSI_BB", 10_000.0, 9_800.0,
|
|
10_300.0, "t", 0.7),
|
|
"quantity": 5, "skip": None})):
|
|
res = await dt.get_current_signals()
|
|
assert res["gates"]["open_slots"] == 0
|
|
assert res["signals"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_dry_run_uses_engine_positions_and_closed_today():
|
|
from app.api.endpoints.day_trading import ExecuteRequest
|
|
day_trading_state.positions["AAAAAA"] = {"entry_price": 10_000.0, "quantity": 1}
|
|
day_trading_state.closed_today.add("BBBBBB")
|
|
with patch.object(dt, "_evaluate_engine_signal_v2", _boom("_evaluate_engine_signal_v2")):
|
|
with pytest.raises(HTTPException) as e1:
|
|
await dt.execute_day_trade(ExecuteRequest(stock_code="AAAAAA", dry_run=True))
|
|
with pytest.raises(HTTPException) as e2:
|
|
await dt.execute_day_trade(ExecuteRequest(stock_code="BBBBBB", dry_run=True))
|
|
with pytest.raises(HTTPException) as e3:
|
|
await dt.execute_day_trade(ExecuteRequest(stock_code="CCCCCC", dry_run=False))
|
|
assert e1.value.status_code == 400
|
|
assert e2.value.status_code == 400
|
|
assert e3.value.status_code == 410
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_cycle_is_inert_and_legacy_trading_helpers_removed():
|
|
with patch.object(dt, "is_market_open", _boom_sync("is_market_open")), \
|
|
patch.object(dt.korea_investment_api, "get_balance", _boom("get_balance")):
|
|
res = await dt.run_day_trading_cycle()
|
|
assert res["phase"] == "deprecated"
|
|
assert res["entries"] == [] and res["exits"] == []
|
|
for name in ("_check_exits", "_scan_signals", "_execute_entry", "_execute_eod_flat",
|
|
"restore_positions_from_db", "_place_buy_order", "_place_sell_order",
|
|
"_collect_opening_range"):
|
|
assert not hasattr(dt, name), f"레거시 매매 경로 {name} 가 남아 있다"
|