Files
auto-stock/backend/tests/test_dt_entry_hardening_r3_20261009.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

266 lines
12 KiB
Python

"""DT 진입 하드닝 라운드 3 회귀 테스트 (2026-10-09).
1. [LOW] DT ActiveTrade upsert payload 에 order_id 가 없어(운영 154/154 NULL) 재기동 복원
포지션의 ODNO 가 빈 값이던 결함 → payload 에 order_id 를 싣는다.
2. [LOW] 매수 0체결·부분체결 종결 보정이 인메모리·ActiveTrade 만 정리하고 Portfolio(reflect_buy)
반영은 되돌리지 않아, sync 전까지 고아 감지가 수동확인 경보를 오발하던 결함
→ 'daytrading' 태그 행에 한해 미체결분을 되돌린다(하한 = DT 인메모리 잔량, reflect_sell 미사용).
목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 주문 공통 종단(safe_place_order)은
도달 시 예외. 기대값은 settings 에서 계산한다.
"""
import asyncio
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app.core.config import settings
from app.models.models import ActiveTrade, Portfolio
from app.services.daytrading import entry_service as ens
from app.services.daytrading import fill_reconciler as fr
from app.services.daytrading.position_manager import _active_trade_row_to_position, day_trading_state
class MustNotBeCalled(BaseException):
"""호출 금지 경로 도달 (except Exception 에 삼켜지지 않게 BaseException)."""
def _boom(name):
async def _f(*a, **k):
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
return _f
CODE = "777777"
ODNO = "0000456"
@pytest.fixture(autouse=True)
def _isolate(monkeypatch):
import app.services.trade_executor as te
saved = dict(day_trading_state.positions)
saved_closed = set(day_trading_state.closed_today)
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
ens._pending_entries.clear()
monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order"))
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)):
yield
day_trading_state.positions.clear()
day_trading_state.positions.update(saved)
day_trading_state.closed_today.clear()
day_trading_state.closed_today.update(saved_closed)
ens._pending_entries.clear()
def _pos(qty=10, order_id=ODNO):
return {
"stock_name": "테스트", "strategy": "VWAP_BOUNCE", "entry_price": 10_000.0, "quantity": qty,
"stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_000.0,
"entry_time": datetime.now(), "reason": "", "order_id": order_id,
}
# ═════════════════════════════════════════════════════════════════════════════
# 1. ActiveTrade order_id 영속화
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_accepted_entry_persists_order_id_to_active_trade(monkeypatch):
import app.services.portfolio_cache_bridge as pcb
upserts, scheduled = [], []
async def _ups(p):
upserts.append(p)
monkeypatch.setattr(ens, "enqueue_trade_history", AsyncMock())
monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups)
monkeypatch.setattr(pcb, "reflect_buy", AsyncMock())
monkeypatch.setattr(ens, "_schedule_buy_fill_reconcile", lambda *a, **k: scheduled.append(a))
signal = SimpleNamespace(strategy="RSI_BB", stop_loss=9_700.0, target_price=10_450.0, reason="t")
await ens._record_accepted_entry_impl(CODE, "테스트", signal, 10, 10_000.0, ODNO)
assert len(upserts) == 1
assert upserts[0]["order_id"] == ODNO
assert upserts[0]["strategy_name"] == "day_trade"
# db_worker 신규 생성 경로(ActiveTrade(**create_data))가 받아들이는 키인지 — ORM 컬럼 존재 확인
row = ActiveTrade(**upserts[0])
assert row.order_id == ODNO
assert scheduled and scheduled[0][1] == ODNO
def test_restore_round_trip_keeps_order_id():
"""복원 변환이 영속화된 ODNO 를 그대로 인메모리 포지션에 싣는다(빈 값 아님)."""
row = SimpleNamespace(
stock_code=CODE, stock_name="테스트", strategy_name="day_trade", entry_price=10_000.0,
stop_loss=9_700.0, take_profit=10_450.0, quantity=10, highest_since_entry=None,
created_at=datetime.now(), entry_reason="r", order_id=ODNO, remaining_pct=1.0,
)
pos = _active_trade_row_to_position(row)
assert pos["order_id"] == ODNO
# ═════════════════════════════════════════════════════════════════════════════
# 2. 0체결·부분체결 종결 시 Portfolio 되돌림
# ═════════════════════════════════════════════════════════════════════════════
class _FakeCache:
def __init__(self, holdings):
self.holdings = holdings
self.apply_sell = AsyncMock()
def note_sell(self, *a, **k):
raise MustNotBeCalled("note_sell — 매수 취소를 매도로 기록하면 sync 가 실보유 행을 지울 수 있다")
def _row(qty, tag="daytrading"):
return Portfolio(stock_code=CODE, stock_name="테스트", quantity=qty, strategy_type=tag,
average_price=10_000, current_price=10_000)
@pytest.fixture
def fill_env(monkeypatch):
import app.services.portfolio_cache_bridge as pcb
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True)
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0)
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0)
cap = {"upserts": [], "closes": [], "updates": [], "portfolio": []}
async def _ups(p):
cap["upserts"].append(p)
async def _close(code, strategy_name=None):
cap["closes"].append((code, strategy_name))
async def _upd(oid, f):
cap["updates"].append((oid, f))
async def _pf(p):
cap["portfolio"].append(p)
monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups)
monkeypatch.setattr("app.services.db_worker.enqueue_close_active_trade", _close)
monkeypatch.setattr("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd)
monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf)
# 매도 경로로 되돌리면 note_sell 이 sync 를 오도한다 — 도달 금지
monkeypatch.setattr(pcb, "reflect_sell", _boom("reflect_sell"))
return cap
def _install_cache(monkeypatch, holdings, db_qty):
import app.services.portfolio_cache_bridge as pcb
cache = _FakeCache(holdings)
monkeypatch.setattr(pcb, "_cache", lambda: cache)
monkeypatch.setattr(pcb, "_db_quantity", AsyncMock(return_value=db_qty))
return cache
async def _reconcile(rows, qty=10):
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
ens._schedule_buy_fill_reconcile(CODE, ODNO, 10_000.0, qty)
await asyncio.gather(*list(fr._pending_tasks))
@pytest.mark.asyncio
async def test_zero_fill_terminal_reverts_portfolio_daytrading_row(fill_env, monkeypatch):
day_trading_state.positions[CODE] = _pos(qty=10)
cache = _install_cache(monkeypatch, [_row(10)], db_qty=10)
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
"ord_qty": "10", "rmn_qty": "0"}])
assert CODE not in day_trading_state.positions
assert fill_env["portfolio"] == [{"stock_code": CODE, "quantity": 0, "current_price": 10_000.0}]
assert cache.holdings == []
cache.apply_sell.assert_awaited_once()
assert cache.apply_sell.await_args.args[:2] == (CODE, 10)
@pytest.mark.asyncio
async def test_zero_fill_terminal_keeps_reentry_and_restore_block(fill_env, monkeypatch):
"""[R2-2 2026-10-09] 매수 0체결 종결 정리 뒤 재진입 차단(closed_today)과 당일 active 행
복원 차단(restore_block_reason_v2)이 둘 다 유지된다 — close 큐 지연 중 잔존 행이
인메모리로 되살아나 매도되지 않게.
"""
saved_exited = set(day_trading_state.dt_exited_today)
day_trading_state.dt_exited_today.discard(CODE)
try:
day_trading_state.positions[CODE] = _pos(qty=10)
_install_cache(monkeypatch, [_row(10)], db_qty=10)
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
"ord_qty": "10", "rmn_qty": "0"}])
assert CODE not in day_trading_state.positions
assert CODE in day_trading_state.closed_today
assert day_trading_state.restore_block_reason_v2(CODE) is not None
finally:
day_trading_state.dt_exited_today.clear()
day_trading_state.dt_exited_today.update(saved_exited)
@pytest.mark.asyncio
async def test_partial_fill_terminal_reverts_only_unfilled(fill_env, monkeypatch):
day_trading_state.positions[CODE] = _pos(qty=10)
cache = _install_cache(monkeypatch, [_row(10)], db_qty=10)
await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030",
"ord_qty": "10", "rmn_qty": "0"}])
assert day_trading_state.positions[CODE]["quantity"] == 6
assert fill_env["portfolio"][-1]["quantity"] == 6
assert cache.holdings[0].quantity == 6
@pytest.mark.asyncio
async def test_partial_revert_never_below_dt_inmem_when_sync_already_corrected(fill_env, monkeypatch):
"""sync 가 이미 실잔고(6)로 맞춘 뒤면 다시 빼지 않는다(하한 = DT 인메모리 6주)."""
day_trading_state.positions[CODE] = _pos(qty=10)
_install_cache(monkeypatch, [_row(6)], db_qty=6)
await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030",
"ord_qty": "10", "rmn_qty": "0"}])
assert fill_env["portfolio"][-1]["quantity"] == 6
@pytest.mark.asyncio
async def test_mixed_strategy_row_is_not_touched(fill_env, monkeypatch):
"""타 전략 태그 행은 스윙 보유분이 섞였을 수 있어 깎지 않는다(§3.5) — sync 에 맡긴다."""
day_trading_state.positions[CODE] = _pos(qty=10)
cache = _install_cache(monkeypatch, [_row(13, tag="swing")], db_qty=13)
import app.services.portfolio_cache_bridge as pcb
monkeypatch.setattr(pcb, "_db_quantity", _boom("_db_quantity"))
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
"ord_qty": "10", "rmn_qty": "0"}])
assert CODE not in day_trading_state.positions
assert fill_env["portfolio"] == []
assert cache.holdings[0].quantity == 13
cache.apply_sell.assert_not_awaited()
@pytest.mark.asyncio
async def test_replaced_position_zero_fill_does_not_revert(fill_env, monkeypatch):
"""다른 주문의 포지션이면(교체·이미 청산) 원장만 보정 — Portfolio 는 건드리지 않는다."""
day_trading_state.positions[CODE] = _pos(qty=10, order_id="OTHER")
_install_cache(monkeypatch, [_row(10)], db_qty=10)
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
"ord_qty": "10", "rmn_qty": "0"}])
assert day_trading_state.positions[CODE]["quantity"] == 10
assert fill_env["portfolio"] == []
@pytest.mark.asyncio
async def test_revert_helper_floor_and_no_increase(monkeypatch):
import app.services.portfolio_cache_bridge as pcb
out = []
async def _pf(p):
out.append(p)
monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf)
_install_cache(monkeypatch, [_row(3)], db_qty=3)
# 미체결 10 > 행 3, 하한 0 → 0 (음수 아님)
assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 10, 10_000.0, 0) == 0
_install_cache(monkeypatch, [_row(3)], db_qty=3)
# 하한이 행보다 커도 행 수량을 늘리지 않는다
assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 2, 10_000.0, 9) == 3
# 미체결 0 이면 아무것도 하지 않는다
monkeypatch.setattr(pcb, "_cache", _boom("_cache"))
assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 0, 10_000.0, 0) is None